Skip to content
Merged
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 @@
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 @@
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 @@
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 @@
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 @@ -707,109 +739,109 @@
"info",
);
};
const nmgMenuHandler = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
const parts = args.trim().split(/\s+/).filter(Boolean);
const [sub, ...rest] = parts;
const restText = rest.join(" ");
switch (sub) {
case "recall": {
recallCommand(rest, ctx);
return;
}
case "wake": {
const current = readWakeConfig();
const [wcmd, wvalRaw] = restText.split(/\s+/);
const wval = Number(wvalRaw);
switch (wcmd) {
case "on":
writeWakeConfig({ ...current, enabled: true });
ctx.ui.notify("黑板唤醒已开启(世界频道+活跃频道有新 open 条目会唤醒)", "info");
return;
case "off":
writeWakeConfig({ ...current, enabled: false });
ctx.ui.notify("黑板唤醒已关闭", "info");
return;
case "status": {
const budgetText = current.budget === 0 ? "不限制" : `${current.budget}/天`;
const cooldownText =
current.cooldownMs === 0 ? "无" : `${Math.round(current.cooldownMs / 60_000)} 分`;
ctx.ui.notify(
`黑板唤醒:${current.enabled ? "开" : "关"} · 预算 ${budgetText} · 冷却 ${cooldownText} · 轮询 ${Math.round(current.intervalMs / 1_000)} 秒 · 世界广播 ${current.worldBroadcast ? "开" : "关"}`,
"info",
);
return;
}
case "budget": {
if (!Number.isFinite(wval)) {
ctx.ui.notify("用法:/nmg wake budget N(0=不限制)", "warning");
return;
}
const budget = Math.max(0, Math.min(100, Math.round(wval)));
writeWakeConfig({ ...current, budget });
ctx.ui.notify(`黑板唤醒预算已设为 ${budget === 0 ? "不限制" : `${budget}/天`}`, "info");
return;
}
case "cooldown": {
if (!Number.isFinite(wval)) {
ctx.ui.notify("用法:/nmg wake cooldown M(0=无冷却)", "warning");
return;
}
const cooldownMs = wval === 0 ? 0 : Math.max(30_000, Math.round(wval * 60_000));
writeWakeConfig({ ...current, cooldownMs });
ctx.ui.notify(
`黑板唤醒冷却已设为 ${cooldownMs === 0 ? "无" : `${Math.round(cooldownMs / 60_000)} 分钟`}`,
"info",
);
return;
}
case "interval": {
if (!Number.isFinite(wval)) {
ctx.ui.notify("用法:/nmg wake interval S(秒,最小 5)", "warning");
return;
}
const intervalMs = Math.max(5_000, Math.round(wval * 1_000));
writeWakeConfig({ ...current, intervalMs });
ctx.ui.notify(`黑板唤醒轮询已设为 ${Math.round(intervalMs / 1_000)} 秒`, "info");
return;
}
case "world": {
// 无参切换;0 关、1 开。
const world =
wvalRaw === "0" ? false : wvalRaw === "1" ? true : !current.worldBroadcast;
writeWakeConfig({ ...current, worldBroadcast: world });
ctx.ui.notify(
world
? "已开启世界频道协作广播:协作类新条目会同时发到世界频道拉其他 agent"
: "已关闭世界频道协作广播",
"info",
);
return;
}
default: {
const enabled = !current.enabled;
writeWakeConfig({ ...current, enabled });
ctx.ui.notify(enabled ? "黑板唤醒已开启" : "黑板唤醒已关闭", "info");
}
}
return;
}
default: {
// 无参数 → 交互式选择菜单(可上下键选,不用手敲);未知子命令 → 总览提示。
if (parts.length === 0) {
await nmgInteractiveMenu(ctx);
return;
}
const wake = readWakeConfig();
const budgetText = wake.budget === 0 ? "不限制" : `${wake.budget}/天`;
const cooldownText =
wake.cooldownMs === 0 ? "无" : `${Math.round(wake.cooldownMs / 60_000)} 分`;
ctx.ui.notify(
`NMG 菜单:/nmg recall(召回折叠 ${recallCollapsed ? "开" : "关"}) · /nmg wake on|off|status|budget N|cooldown M|interval S(唤醒 ${wake.enabled ? "开" : "关"},预算 ${budgetText},冷却 ${cooldownText},轮询 ${Math.round(wake.intervalMs / 1_000)} 秒)`,
"info",
);
}
}
};

Check notice on line 844 in .pi/extensions/nmg/index.ts

View check run for this annotation

codefactor.io / CodeFactor

.pi/extensions/nmg/index.ts#L742-L844

Complex Method
// 交互式选择菜单:/nmg(无参数)弹出可上下键选的列表,避免手敲。带参数的
// 形式(/nmg recall、/nmg wake on 等)仍保留,供脚本/快捷路径直接调用。
// 挡位选择(select 预设值,避免随意输入溢出/无效);自由数字只走命令形式
Expand Down Expand Up @@ -876,67 +908,67 @@
await workingStateView.setPreview(onOff === "显示", ctx);
ctx.ui.notify(onOff === "显示" ? "状态预览已显示" : "状态预览已隐藏(状态本身不变)", "info");
};
const nmgInteractiveMenu = async (ctx: ExtensionCommandContext): Promise<void> => {
if (!ctx.hasUI || typeof ctx.ui.select !== "function") {
ctx.ui.notify(
"当前模式不支持交互菜单,可用:/nmg recall、/nmg state on|off、/nmg wake on|off|status|budget N|cooldown M|interval S",
"warning",
);
return;
}
const choice = await ctx.ui.select(
"NMG 控制台",
nmgMenuOptions(recallCollapsed, readWakeConfig()),
);
if (!choice) return; // 用户取消
if (choice.startsWith("召回")) {
await nmgMenuHandler("recall", ctx);
return;
}
if (choice.startsWith("唤醒:开启/关闭")) {
const onOff = await ctx.ui.select("黑板唤醒", ["开启", "关闭"]);
if (!onOff) return;
const current = readWakeConfig();
writeWakeConfig({ ...current, enabled: onOff === "开启" });
ctx.ui.notify(onOff === "开启" ? "黑板唤醒已开启" : "黑板唤醒已关闭", "info");
return;
}
if (choice.startsWith("唤醒:世界广播")) {
const onOff = await ctx.ui.select("世界频道协作广播", ["开启", "关闭"]);
if (!onOff) return;
const current = readWakeConfig();
writeWakeConfig({ ...current, worldBroadcast: onOff === "开启" });
ctx.ui.notify(
onOff === "开启"
? "已开启世界频道协作广播:协作类新条目会同时发到世界频道拉其他 agent"
: "已关闭世界频道协作广播",
"info",
);
return;
}
if (choice.startsWith("唤醒:参数设置")) {
await nmgWakeParamMenu(ctx);
return;
}
if (choice.startsWith("当前状态:查看完整正文")) {
await workingStateView.readForUser(ctx);
return;
}
if (choice.startsWith("当前状态:显示/隐藏预览")) {
await nmgStatePreviewMenu(ctx);
return;
}
{
const wake = readWakeConfig();
const budgetText = wake.budget === 0 ? "不限制" : `${wake.budget}/天`;
const cooldownText =
wake.cooldownMs === 0 ? "无" : `${Math.round(wake.cooldownMs / 60_000)} 分`;
ctx.ui.notify(
`NMG:召回折叠 ${recallCollapsed ? "开" : "关"} · 唤醒 ${wake.enabled ? "开" : "关"}(预算 ${budgetText},冷却 ${cooldownText},轮询 ${Math.round(wake.intervalMs / 1_000)} 秒)`,
"info",
);
}
};

Check notice on line 971 in .pi/extensions/nmg/index.ts

View check run for this annotation

codefactor.io / CodeFactor

.pi/extensions/nmg/index.ts#L911-L971

Complex Method
// `/nmg state [on|off]` is the read-only state view and its preview switch.
// It dispatches before the menu handler so that handler keeps its own shape.
const nmgStateCommand = async (rest: string[], ctx: ExtensionCommandContext): Promise<void> => {
Expand Down Expand Up @@ -1151,6 +1183,7 @@
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 @@
["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,3 @@
version: 2
terms: []
references: []
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

从自然使用标定参数(容量、保护比例、提升阈值)、按回合过期、跨会话提升都留在后续工作。
本次改动不包含部署、默认启用或自然质量/成本结论。
55 changes: 41 additions & 14 deletions docs/design/design/progressive-retrieval.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,47 @@ from both storage tiers and the STG/LTG lifecycle:
`nmg_search`, inspect costs, and expand the AG with exact details through
`nmg_get`.

The experimental `recall_gate` capability is a default-off, session-leased pilot on
the automatic layer. It caches assistant-proposed intent hints rather than
memories, relevance judgments or claim outcomes, and a main-model supplemental
search may include a literal cue from the current input. Admission requires an
otherwise skipped entry plus a current session/project generation and proposal
fence. Native Pi captures that observation outside model parameters; explicit
clients supply a preparation revision. Invalid, stale or disabled proposals do
not fail or alter the ordinary search, and a consumed fence certifies neither
intent nor search success. Only an otherwise skipped query reuses a hint: static
and task-anchor recalls win, and a hit still takes the ordinary `cue` decision,
bounded by host configuration, retrieval filters and output budgets. Confidence
constants remain heuristics, not calibrated probabilities. The
[recall cue decision](../../decisions/implemented/2026-10-15-session-recall-cues.md)
owns the protocol.
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 experimental `recall_gate` capability is a default-off, session-leased pilot on the
automatic layer. It stores assistant-proposed intent hints rather than memories,
relevance judgments or claim outcomes, and a main-model supplemental search may include
a literal cue from the current input. Admission requires an otherwise skipped entry plus
a current session/project generation and proposal fence. Native Pi captures that
observation outside model parameters; explicit clients supply a preparation revision.
Invalid, stale or disabled proposals do not fail or alter the ordinary search, and a
consumed fence certifies neither intent nor search success. Only an otherwise skipped
query reuses a hint: static and task-anchor recalls win, and a hit still takes the
ordinary `cue` decision, bounded by host configuration, retrieval filters and output
budgets. Confidence constants remain heuristics, not calibrated probabilities.

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 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, so reintroduction means only that a recent retirement is still in that ring, not
independently verified need or a complete relearning history. The
[recall cue decision](../../decisions/implemented/2026-10-15-session-recall-cues.md) and the
[unified vocabulary decision](../../decisions/implemented/2026-10-20-unified-recall-vocabulary.md)
own the protocols.

Automatic recall and Agent-directed recall share the same indexed records.
Explicit recall triggers may improve candidate nomination in either layer, but
Expand Down
Loading
Loading