Skip to content

feat(memory): per-turn injection controls (maxInjectedRecords + scopes allow-list) - #1149

Open
Chasen-Liao wants to merge 7 commits into
KunAgent:masterfrom
Chasen-Liao:feat/memory-architecture
Open

feat(memory): per-turn injection controls (maxInjectedRecords + scopes allow-list)#1149
Chasen-Liao wants to merge 7 commits into
KunAgent:masterfrom
Chasen-Liao:feat/memory-architecture

Conversation

@Chasen-Liao

@Chasen-Liao Chasen-Liao commented Aug 12, 2026

Copy link
Copy Markdown

概述

记忆系统的每轮注入控制 + 三个 P0 缺陷修复,共 6 个 commit。核心思路:自动读取、显式写入——每轮按当前上下文自动注入相关记忆,agent 经工具显式写记忆,记忆随置信度衰减自动淡出。

改动内容

每轮注入上限(maxInjectedRecords)

此前 maxInjectedRecords 配置声明了但从未生效,三处调用硬编码 limit: 8。现在 retrieve() 计算 effectiveLimit = min(input.limit ?? cap, cap),配置可调小(省 token/降噪)也能调大(召回更多长尾记忆)。

作用域注入白名单(scopes allow-list)

此前 config.scopes 未生效,所有记忆都是注入候选。现在只注入白名单内的作用域(user / workspace / project);把 user 移出即关闭身份记忆。scopes: [] 停用注入但保留读写。

P0-1:user 记忆配额分档

user 记忆无条件注入且排最前,一旦活跃数达到上限(默认 8)会把 workspace/project 记忆整体挤出。现在 retrieve() 为评分池保底 max(1, floor(cap/2)) 槽位、剩余回填,项目相关记忆不再被身份记忆挤掉。

P0-3:组合查询 + 退化兜底

检索 query 此前只用当前 turn 的 prompt,续接轮("继续"、"接着做")与存储内容零 n-gram 重叠导致漏检。现在 query 由 prompt + active goal + 最近对话/工具事实 组合(查询只用于内部评分、不花 prompt token);退化 prompt(<2 gram)由 resolver 标记、store 兜底注入最近更新记录。简化阶段删除了死代码 fallbackQuery(goal 已在主 query、重试必空),并把退化判定上移到 resolver。

P0-2:置信度衰减接线

置信度半衰期此前在生产从未生效minConfidence 恒 0 + memory_create 硬编码 kind='user' 短路衰减)。现在新增 minConfidence 配置(默认 0.2),memory_create 写入 kind='tool'(confidence 0.7),agent 记录随 180 天半衰期淡出;用户显式更正的记忆保持 kind='user' + confidence=1 不衰减锚点。

工具清理

.gitignore 增加本地编辑器/MCP 工具配置(codegraph 索引、Claude 设置、Cursor MCP 等),保持 git status 干净。

行为变化说明

  • 配置 scopes 现在真正生效(此前是惰性配置)
  • agent 写入的陈旧记忆超过半衰期后自动淡出,不再永久注入
  • 续接轮能召回相关项目记忆,不再只剩身份记忆

测试验证

  • vitest:memory / manager / loop / contracts / tools 相关套件 426 passeddesign-svg-tool.test.ts 1 个失败为 pre-existing,与本次无关,已用 stash 隔离验证)
  • tsc --noEmit 类型检查通过

🤖 Generated with Claude Code

Chasen-Liao and others added 6 commits August 12, 2026 11:05
…n cap

The config field was declared and surfaced in the manifest but never read;
all three retrieval call sites hard-coded limit: 8. FileMemoryStore.retrieve
now computes effectiveLimit = min(input.limit ?? cap, cap) from the store's
maxInjectedRecords, and the core loop / agent-sdk / cursor-sdk call sites
stop passing limit so the config can both lower and raise injection. The
retrieve limit and the manager RPC schema field become optional so remote
mode round-trips an omitted limit without a zod strict-parse failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
config.scopes was declared but never read: all active memories were
injection candidates regardless of scope. It now gates which scopes are
eligible for per-turn injection; user-scope identity memory is dropped
when 'user' is excluded. Extract DEFAULT_SCOPES for the config default
and cover the scope matrix in unit + manager-level integration tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ignore per-machine editor/MCP tooling config (codegraph index, Claude
settings, Cursor MCP, root .mcp.json) so git status stays clean without
excluding the tracked .claude/ and .cursor/ openspec sync files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two P0 defects in memory retrieval:

1. User-scope memories were injected unconditionally and ranked first, so
   once their count reached maxInjectedRecords they filled the whole
   per-turn budget and squeezed out scored workspace/project hits.
   retrieve() now reserves max(1, floor(cap/2)) slots for the scored pool
   when it has hits and backfills unused slots in priority order.

2. Continuation turns ("继续", "fix this") scored zero hits because the
   retrieval query was the bare turn prompt, sharing no n-grams with
   stored content. The query now composes the prompt with the active goal
   and recent user/assistant/tool history, and retrieve() retries the
   fallback query before falling back to recent-first records for
   degenerate (<2 gram) queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Drop the dead `fallbackQuery` retry: the active goal is already part of
  the composed query and its n-grams are a subset of the query's, so the
  store-side retry could never score a hit the primary query missed.
- Move the degenerate-prompt decision up to the resolver: it flags the
  recency fallback on the bare prompt (< 2 n-grams), fixing the previous
  gate that counted n-grams on the composed query and never fired.
- Collapse allocateQuota to a deficit-flow slice, reuse
  toolResultTextWithoutImages, and name the query budget constants.
- Add the missing manager-path quota regression test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confidence half-life decay never took effect in production: minConfidence
defaulted to 0 (so isMemoryActive always passed) and memory_create hard-coded
provenance kind 'user', which skips decay entirely.

- Add minConfidence (default 0.2) to MemoryCapabilityConfig, surfaced through
  the manifest, example config, and renderer contract.
- FileMemoryStore reads config.minConfidence instead of an unwired option.
- memory_create writes kind 'tool' (confidence 0.7) so agent-derived facts
  fade over the 180-day half-life; explicit user corrections stay anchored
  at kind 'user' / confidence 1.
- Tests: decay filtering, minConfidence=0 opt-out, kind 'tool' provenance,
  and config bounds / manifest round-trip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Chasen-Liao

Copy link
Copy Markdown
Author

Code review

Found 3 issues:

  1. loop/ value-imports a node:fs-backed store (layering violation)turn-context-resolver.ts:15 adds import { ngrams } from '../memory/memory-store.js', but memory-store.ts imports node:fs/promises at the top, creating a loop → memory → node:fs dependency edge. docs/kun-contributing.md §2 says loop/ may only depend on ports/domain/cache/telemetry/contracts. The pure ngrams helper should live in domain/.

import type { InstructionRuntime, InstructionTurnResolution } from '../instructions/instruction-runtime.js'
import { ngrams } from '../memory/memory-store.js'
import type { MemoryStore } from '../memory/memory-store.js'
import type { GuiPlanContext, ToolHost, ToolHostContext } from '../ports/tool-host.js'

  1. An agent can re-anchor a decayed memory via memory_update (decay bypass)memory-store.ts:116 still forces provenance.kind='user' on any content correction, while memory-tool-provider.ts:78's memory_update does not distinguish user vs agent origin. An agent that edits the content once re-anchors a decayed memory as never-decaying, defeating this PR's "decay agent-written memories" goal.

...(corrected
? {
correctedFrom: current.correctedFrom ?? current.content,
provenance: { ...(current.provenance ?? defaultLegacyProvenance(current)), kind: 'user' }
}
: {}),

  1. allocateQuota contradicts the "user injected unconditionally" commentmemory-store.ts:177 still claims "every active user memory injected unconditionally", but allocateQuota (:218 scoredQuota = max(1, floor(effectiveLimit/2))) truncates user memories when the scored pool has hits. User memories are no longer injected unconditionally; comment and implementation disagree.

// (config.scopes, the default includes it) every active user memory is
// injected unconditionally; excluding `user` turns identity memories off
// entirely, and scored retrieval covers the workspace/project pool.
const userMemories = allowed.filter((record) => record.scope === 'user')
const scoredPool = allowed.filter((record) => record.scope !== 'user')
let scored = this.scoreRecords(scoredPool, input.query, nowMs)
// Continuation turns ("继续", "fix this") share zero n-grams with stored
// content, so the scored pool comes back empty even when relevant memories
// exist. The caller flags degenerate prompts; fall back to the most
// recently updated records when nothing else scores. `scoredPool` inherits
// the updatedAt-descending order from `list()`, so a plain slice suffices.
if (scored.length === 0 && input.allowRecencyFallback) {
scored = scoredPool.slice(0, 3)
}
return this.allocateQuota(userMemories, scored, effectiveLimit)
}
private scoreRecords(records: MemoryRecord[], query: string, nowMs: number): MemoryRecord[] {
return records
.map((record) => ({ record, score: scoreMemory(
record,
query,
nowMs,
this.options.confidenceHalfLifeMs ?? DEFAULT_MEMORY_CONFIDENCE_HALF_LIFE_MS
) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || b.record.updatedAt.localeCompare(a.record.updatedAt))
.map((entry) => entry.record)
}
/**
* User-scope memories are injected unconditionally and always rank first, so
* without separate quotas they can crowd the whole per-turn budget once their
* count reaches maxInjectedRecords, starving scored workspace/project
* memories. Reserve at least half the budget (floor, min 1) for the scored
* pool when it has hits; unused scored quota falls back to user memories.
*/
private allocateQuota(
userMemories: MemoryRecord[],
scored: MemoryRecord[],
effectiveLimit: number
): MemoryRecord[] {
const scoredQuota = scored.length > 0 ? Math.max(1, Math.floor(effectiveLimit / 2)) : 0
const userPicked = userMemories.slice(0, effectiveLimit - Math.min(scoredQuota, scored.length))

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

…uota edge

- move ngrams to domain/memory-scoring.ts so loop/ no longer imports a
  node:fs-backed store (kun-contributing §2 layering)
- add MemoryRecord.anchored to orthogonalize decay governance from
  provenance.kind; memory_update passes source:'agent' so an agent-side
  correction can no longer re-anchor a decayed memory; readAll lazily
  migrates legacy records
- drop max(1,…) in allocateQuota so limit=1 no longer starves user memories,
  and align the "unconditionally injected" comment with the quota behaviour

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant