Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 42 additions & 7 deletions .pi/extensions/nmg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
matchSessionRecallCue,
} from "../../../src/core/session-recall.ts";
import { decideMemoryLoad } from "../../../src/core/gate.ts";
import { recallInput } from "../../../src/core/recall-matcher.ts";
import type {
ExtensionAPI,
ExtensionCommandContext,
Expand Down Expand Up @@ -337,19 +338,42 @@ export default function nmgExtension(pi: ExtensionAPI): void {
gate: NonNullable<RecallHandshake["recallGate"]>;
}
| undefined;
let retainedHandshake:
{ sessionId: string; prompt?: string; result?: RecallHandshake } | undefined;
const beginDisclosureTurn = async (
sessionId: string,
enabled: boolean,
options: HttpCallOptions = {},
prompt?: string,
): Promise<RecallHandshake | undefined> => {
if (!enabled) return undefined;
if (
!enabled &&
retainedHandshake?.sessionId === sessionId &&
retainedHandshake.prompt === prompt
)
return retainedHandshake.result;
recallTurn = undefined;
const result = (await invoke(
"sessionActiveGraph",
{ action: "beginDisclosureTurn", sessionId, projectDir: projectDirectory() },
{
action: "beginDisclosureTurn",
sessionId,
projectDir: projectDirectory(),
...(prompt !== undefined
? {
recall: {
prompt: recallInput(prompt),
projectDir: projectDirectory(),
scheduled: false,
},
}
: {}),
},
options,
).catch(() => undefined)) as NmgSessionActiveGraphResult | undefined;
return result?.action === "beginDisclosureTurn" ? result : undefined;
const handshake = result?.action === "beginDisclosureTurn" ? result : undefined;
retainedHandshake = { sessionId, prompt, result: handshake };
return handshake;
};
const searchCueParams = (
sessionId: string,
Expand Down Expand Up @@ -552,9 +576,13 @@ export default function nmgExtension(pi: ExtensionAPI): void {
piUserTurnKey(ctx.sessionManager, event.prompt),
);
await budget.run("connection", () => connection());
recallRequest = taskWindow.prepare(sessionId, event.prompt);
const handshake = await budget.run("disclosure.begin", (signal) =>
beginDisclosureTurn(sessionId, isNewUserTurn, { signal }),
beginDisclosureTurn(sessionId, isNewUserTurn, { signal }, event.prompt),
);
recallRequest = taskWindow.prepare(
sessionId,
event.prompt,
handshake?.sessionRecall?.decision,
);
if (handshake?.recallGate && event.prompt.length <= 4096) {
recallTurn = {
Expand All @@ -564,7 +592,11 @@ export default function nmgExtension(pi: ExtensionAPI): void {
skipped: !recallRequest,
gate: handshake.recallGate,
};
if (!recallRequest && matchSessionRecallCue(event.prompt, handshake.recallGate.phrases)) {
if (
!handshake.sessionRecall &&
!recallRequest &&
matchSessionRecallCue(event.prompt, handshake.recallGate.phrases)
) {
const decision = decideMemoryLoad(event.prompt, { sessionCue: true });
if (decision.mode === "cue") {
recallRequest = { ...decision, mode: "cue", query: event.prompt };
Expand Down Expand Up @@ -1151,6 +1183,7 @@ export default function nmgExtension(pi: ExtensionAPI): void {
taskWindow.clear(sessionId);
recallFlow.clear(sessionId);
recallTurn = undefined;
retainedHandshake = undefined;
agentAttributionFlow.clear(sessionId);
controllerShadow.clear(sessionId);
if (!connectionPromise) return;
Expand Down Expand Up @@ -1409,8 +1442,10 @@ export default function nmgExtension(pi: ExtensionAPI): void {
["enable", "disable", "clear", "correct", "remove"].includes(
params.action === "invoke" ? (params.operation ?? "") : params.action,
)
)
) {
recallTurn = undefined;
retainedHandshake = undefined;
}
const active = await connection();
const result = await invokeDaemon(active, "lab", {
action: params.action,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Status:** implemented
**Approved:** explicit
**Relates to:** [Recall intent](2026-10-10-recall-intent-and-diagnostics.md)
**Relates to:** [Recall intent](2026-10-10-recall-intent-and-diagnostics.md), [Unified recall vocabulary](2026-10-20-unified-recall-vocabulary.md)

## Problem

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Status:** implemented
**Approved:** explicit
**Relates to:** [召回意图](2026-10-10-recall-intent-and-diagnostics.zh-CN.md)
**Relates to:** [召回意图](2026-10-10-recall-intent-and-diagnostics.zh-CN.md), [统一召回词表](2026-10-20-unified-recall-vocabulary.zh-CN.md)

## Problem

Expand Down
76 changes: 76 additions & 0 deletions docs/decisions/implemented/2026-10-20-unified-recall-vocabulary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Recall vocabulary as data with one rotating pool

[中文](2026-10-20-unified-recall-vocabulary.zh-CN.md)

**Status:** implemented
**Approved:** explicit
**Relates to:** [Session-only recall cue trial](2026-10-15-session-recall-cues.md)

## Problem

Recall entry was split into two owners with different lifetimes. Intent triggers
lived in nineteen regular expressions that were permanent, exempt from capacity
and unchanged by any observed use, so a wrong or dead trigger could only be fixed
by editing code. Phrases learned during a session lived in a separate
eight-entry cache with no relationship to those triggers. Adding a word required
writing a pattern, and nothing ever retired a stale one.

## Decision

The vocabulary is data. `src/core/recall-vocabulary.ts` lists terms, and each
term carries its phrase, whether it selects `retrieve` or `cue`, and optional
intent bounds: words before or after inside a gap, sentence scope, query start,
or a following name. Adding, changing or retiring a trigger edits that list;
no new regular expression is required. `src/core/recall-matcher.ts` applies those
bounds and `src/core/recall-pool.ts` owns retention.

One rotating pool holds both origins. A session pool is seeded from that
vocabulary and admits learned phrases into the same capacity, so there is no
separate cache and no seed immunity. Retention is a segmented LRU driven only by
original user input: a match in a distinct user turn is a use, the second
distinct turn promotes the entry into the protected segment, and repeated matches
inside one turn are not further uses. Protected overflow demotes the least
recently used protected entry; capacity pressure evicts the least recently used
unprotected entry. Automatic recall, injected context, answers and tool output
never update recency, so the system cannot keep a term alive by talking about it.

Outside the `recall_gate` lease, classification stays stateless against the seed
vocabulary, preserving existing entry decisions. Inside the lease the session
pool is authoritative, including the decision `none`: a term that was evicted or
removed cannot be resurrected by lookup, and the pool is not refilled from disk.
When several entries match, a `retrieve` entry wins, so a learned phrase cannot
downgrade an explicit recollection question.

## Alternatives considered

- Keep permanent trigger patterns and add learned phrases beside them: two
lifetimes for one behavior, so the pool never reflects which triggers actually
work.
- Derive retention from the collected recall corpus first: the user rejected
further collection and strict derivation as a precondition for a usable rule.
- Weight entries by how often they matched: a term that keeps matching its own
injected context would protect itself without any independent evidence.
- Drop the intent bounds along with the regular expressions: bare `what`, `用` or
`remember` would fire on ordinary requests, and `你记得把测试跑完` would be read as
a recollection question.
- Add per-entry clocks or turn-count expiry in the same change: that tightens
retention at the same time as it changes eviction order, so a later result
could not be attributed to either.

## Consequences

A trigger that stops being used loses its slot to one that is used, including a
built-in one, and the vocabulary is edited as a list rather than as code. The
intent bounds remain a lexical proxy: they cannot tell a genuine recollection
request from a quoted one, and a term removed for one session stays removed only
for that session. Capacity is 128 entries per session with at most 64 protected;
both are constants in the vocabulary module, not measured optima. The pool is
session-scoped, so no cross-session promotion is defined, and matching still does
not certify intent, search success, necessity or benefit.

## Deferred

Parameter calibration from natural use (capacity, protected share, promotion
threshold), turn-count expiry, and any cross-session promotion remain separate
work. No deployment, default activation or natural quality/cost claim is part of
this change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 召回词表作为数据与单一轮换池

[English](2026-10-20-unified-recall-vocabulary.md)

**Status:** implemented
**Approved:** explicit
**Relates to:** [仅当前会话的召回词组试点](2026-10-15-session-recall-cues.md)

## Problem

召回入口被拆成两个寿命不同的 owner。意图触发词写在十九条正则里,永久有效、不占容量、
也不受任何实际使用影响;一个错词或死词只能靠改代码移除。会话中学到的词组另存在
八个槽位的缓存里,与这些触发词没有任何关系。加一个词要写正则,而旧词永远不会退场。

## Decision

词表本身就是数据。`src/core/recall-vocabulary.ts` 列出词条,每条包含词组、它选择
`retrieve` 还是 `cue`,以及可选的意图条件:前后多少字内出现指定词、句内范围、句首、
后面紧跟名字。增删或修改触发词只改这份列表,不需要新写正则。
`src/core/recall-matcher.ts` 执行这些条件,`src/core/recall-pool.ts` 负责保留。

两种来源共用一个轮换池。会话池由该词表初始化,学到的词组进入同一容量,
因此没有独立缓存,也没有种子豁免。保留策略是分段 LRU,且只由原始用户输入驱动:
在不同用户回合中命中记一次使用,第二个不同回合把它提升到保护段,
同一回合内的重复命中不再计数。保护段溢出时降级最近最少使用的保护项;
容量压力时淘汰最近最少使用的非保护项。自动召回、注入内容、助手回答和工具输出
都不更新新旧顺序,所以系统无法靠反复提到某个词把它养常驻。

未启用 `recall_gate` 租约时,分类保持对种子词表的无状态判定,既有入口结论不变。
租约启用后以会话池为准,包括 `none` 结论:被淘汰或移除的词不会因查询而复活,
池也不会从磁盘回填。多个词条同时命中时 `retrieve` 优先,
因此学到的词组无法把明确的回忆提问降级。

## Alternatives considered

- 保留永久触发正则,在旁边再放学到词组:同一行为两个寿命,
池子永远不会反映哪些触发词真的有效。
- 先用采集数据推导保留策略:用户明确拒绝把追加采集和严格推导当作可用规则的前置条件。
- 按命中次数加权:不断命中自己注入内容的词会自我保护,却没有独立证据。
- 连正则一起丢掉意图条件:裸 `what`、`用`、`remember` 会在普通请求上触发,
`你记得把测试跑完` 也会被当成回忆提问。
- 同一改动里再加按回合过期:既改淘汰顺序又收紧保留,
后续结果无法归因到哪一项。

## Consequences

不再使用的触发词会把槽位让给在用的词,内置词也不例外;词表按列表维护而不是按代码维护。
意图条件仍是词法近似:分不清真实回忆请求和引用文本,某个词只在被移除的那个会话里失效。
容量为每会话 128 条、保护段最多 64 条,都是词表模块中的常量,不是实测最优值。
池是会话级的,因此没有定义跨会话提升;命中依然不证明意图、检索成功、必要性或收益。

## Deferred

从自然使用标定参数(容量、保护比例、提升阈值)、按回合过期、跨会话提升都留在后续工作。
本次改动不包含部署、默认启用或自然质量/成本结论。
34 changes: 26 additions & 8 deletions docs/design/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -2143,14 +2143,32 @@ No per-turn skip/hint notice or event history is injected. Quotes/code,
translation/no-memory forms and Latin word boundaries have
conservative exclusions; they are not a semantic intent or permission model.

The service owns at most 32 session entries with eight cues each: phrases are
3–80 UTF-16 units, reasons at most 240, source excerpts at most 320, and query
capture/matching at most 4,096. The shared high-confidence secret boundary rejects
secret-bearing query capture and reasons; it is not a general privacy classifier.
Capacity eviction is FIFO by admission, not by automatic match count. Matching
neither renews a cue nor extends the lease. A project change drops the session's
cues; remove/clear, disable, lease expiry or renewal, session release, and daemon
replacement prevent their reuse. No cross-session or permanent promotion is defined.
Entry vocabulary is data rather than code. Each term states its phrase, whether
it selects `retrieve` or `cue`, and its optional intent bounds — words before or
after inside a gap, sentence scope, query start, or a following name — so adding
or retiring a trigger edits `src/core/recall-vocabulary.ts` and writes no regular
expression. A `retrieve` term outranks a `cue` term when both match, and a learned
phrase always enters at `cue`, so it cannot downgrade an explicit recollection
question. Without the Lab lease, classification stays stateless against that
vocabulary.

The service owns at most 32 session entries. Each session owns one pool seeded
from the vocabulary and limited to 128 entries; at most 64 of them are protected.
Learned phrases compete for the same capacity, so there is no separate cue cache
and no seed immunity. Retention is a segmented LRU driven only by original user
input: a match in a distinct user turn is a use, the second distinct turn promotes
the entry, and repeated matches inside one turn are not further uses. Protected
overflow demotes the least recently used protected entry; capacity pressure evicts
the least recently used unprotected entry. Automatic recall, injected context,
answers and tool output never update recency. Phrases are 3–80 UTF-16 units,
reasons at most 240, source excerpts at most 320, and query capture/matching at
most 4,096. The shared high-confidence secret boundary rejects secret-bearing
query capture and reasons; it is not a general privacy classifier. When the lease
is active the session pool is authoritative, including `none`: an evicted or
removed term is never reseeded from disk, and matching neither extends the lease
nor survives a project change; remove/clear, disable, lease expiry or renewal,
session release and daemon replacement prevent reuse. No cross-session or
permanent promotion is defined.
A read-on-request ring retains at most 16 admission/removal/eviction events per
session. Reintroduction means only that a recent retirement is still in that ring,
not independently verified need or a complete relearning history. Clear/lifecycle
Expand Down
Loading