@yandy0725/memory-core 1.0.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +77 -11
  2. package/README.zh.md +66 -10
  3. package/dist/config.d.ts +40 -22
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +36 -36
  6. package/dist/config.js.map +1 -1
  7. package/dist/index.d.ts +10 -9
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +5 -5
  10. package/dist/index.js.map +1 -1
  11. package/dist/injection.d.ts +9 -1
  12. package/dist/injection.d.ts.map +1 -1
  13. package/dist/injection.js +10 -3
  14. package/dist/injection.js.map +1 -1
  15. package/dist/orchestration/conversation.d.ts +1 -1
  16. package/dist/orchestration/dream.d.ts +3 -30
  17. package/dist/orchestration/dream.d.ts.map +1 -1
  18. package/dist/orchestration/dream.js +32 -57
  19. package/dist/orchestration/dream.js.map +1 -1
  20. package/dist/orchestration/extract.d.ts +8 -38
  21. package/dist/orchestration/extract.d.ts.map +1 -1
  22. package/dist/orchestration/extract.js +43 -31
  23. package/dist/orchestration/extract.js.map +1 -1
  24. package/dist/orchestration/ports.d.ts +41 -4
  25. package/dist/orchestration/ports.d.ts.map +1 -1
  26. package/dist/orchestration/status.d.ts +19 -0
  27. package/dist/orchestration/status.d.ts.map +1 -0
  28. package/dist/orchestration/status.js +23 -0
  29. package/dist/orchestration/status.js.map +1 -0
  30. package/dist/orchestration/surfacing.d.ts +9 -21
  31. package/dist/orchestration/surfacing.d.ts.map +1 -1
  32. package/dist/orchestration/surfacing.js +31 -35
  33. package/dist/orchestration/surfacing.js.map +1 -1
  34. package/dist/orchestration/task.d.ts +84 -0
  35. package/dist/orchestration/task.d.ts.map +1 -0
  36. package/dist/orchestration/task.js +145 -0
  37. package/dist/orchestration/task.js.map +1 -0
  38. package/dist/orchestration/tasks.d.ts +18 -0
  39. package/dist/orchestration/tasks.d.ts.map +1 -0
  40. package/dist/orchestration/tasks.js +16 -0
  41. package/dist/orchestration/tasks.js.map +1 -0
  42. package/package.json +3 -1
  43. package/src/config.ts +58 -46
  44. package/src/index.ts +19 -9
  45. package/src/injection.ts +18 -4
  46. package/src/orchestration/conversation.ts +1 -1
  47. package/src/orchestration/dream.ts +33 -75
  48. package/src/orchestration/extract.ts +50 -65
  49. package/src/orchestration/ports.ts +29 -4
  50. package/src/orchestration/status.ts +37 -0
  51. package/src/orchestration/surfacing.ts +45 -45
  52. package/src/orchestration/task.ts +236 -0
  53. package/src/orchestration/tasks.ts +22 -0
package/src/injection.ts CHANGED
@@ -217,13 +217,14 @@ export async function scanEntries(store: MemoryStore): Promise<EntryManifest[]>
217
217
  .slice(0, SIDE_QUERY_MAX_ENTRIES);
218
218
  }
219
219
 
220
- export async function renderSurfacedBlock(
220
+ export async function renderSurfacedEntries(
221
221
  store: MemoryStore,
222
222
  selectedFiles: string[],
223
223
  maxEntryBytes: number,
224
224
  maxInjectionBytes: number,
225
- ): Promise<string> {
225
+ ): Promise<{ files: string[]; content: string }> {
226
226
  const blocks: string[] = [];
227
+ const files: string[] = [];
227
228
  let totalBytes = 0;
228
229
 
229
230
  for (const file of selectedFiles) {
@@ -239,13 +240,26 @@ export async function renderSurfacedBlock(
239
240
  const blockBytes = Buffer.byteLength(block, "utf8");
240
241
  if (totalBytes + blockBytes > maxInjectionBytes) break;
241
242
  blocks.push(block);
243
+ files.push(file);
242
244
  totalBytes += blockBytes;
243
245
  }
244
246
 
245
- if (blocks.length === 0) return "";
246
- return `<relevant_memories>\n${blocks.join("\n\n")}\n</relevant_memories>`;
247
+ if (blocks.length === 0) return { files: [], content: "" };
248
+ return { files, content: `<relevant_memories>\n${blocks.join("\n\n")}\n</relevant_memories>` };
247
249
  }
248
250
 
251
+ /**
252
+ * 单块渲染的兼容入口:只返回渲染文本。
253
+ * 该签名是既有公开契约,保留为 `renderSurfacedEntries` 的薄壳。
254
+ */
255
+ export const renderSurfacedBlock = (
256
+ store: MemoryStore,
257
+ selectedFiles: string[],
258
+ maxEntryBytes: number,
259
+ maxInjectionBytes: number,
260
+ ): Promise<string> =>
261
+ renderSurfacedEntries(store, selectedFiles, maxEntryBytes, maxInjectionBytes).then((r) => r.content);
262
+
249
263
  /** Build the side-query task prompt. */
250
264
  export function buildSideQueryTask(manifest: EntryManifest[], userPrompt: string, maxFiles: number): string {
251
265
  // `Math.max(1, …)`:空清单不会走到这里(侧查询先返回 []),但除零会得到 Infinity。
@@ -4,7 +4,7 @@ import type { ExtractMessage } from "./ports.js";
4
4
  * 对话渲染:把中立的 `ExtractMessage` 序列压成 extract prompt 里的那一段文本,
5
5
  * 并在超出预算时做「首尾优先、中段裁减」。
6
6
  *
7
- * 与 `prompts.ts` 分开是为了不打环:prompt 文本依赖渲染结果,而 `runExtract` 依赖 prompt 文本。
7
+ * 与 `prompts.ts` 分开是为了不打环:prompt 文本依赖渲染结果,而 extract 描述符依赖 prompt 文本。
8
8
  */
9
9
 
10
10
  export interface ConversationLimits {
@@ -1,82 +1,40 @@
1
- import { readdir } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import type { SessionPersistenceConfig } from "../config.js";
4
- import { BACKUP_DIR, type MemoryStore } from "../store/memory-store.js";
5
- import { createSnapshot } from "../store/snapshot.js";
6
- import { isReservedWindowsName } from "../store/windows-names.js";
7
1
  import { DREAM_ACTIONS } from "../tool/actions.js";
8
2
  import { buildDreamTask } from "./prompts.js";
9
- import type { HeadlessAgentPort } from "./ports.js";
3
+ import type { MemoryTask } from "./task.js";
10
4
 
11
- export interface RunDreamOpts {
12
- /** 必填:模型来自显式配置(启动校验已保证可解析),没有父模型回退。 */
13
- model: string;
14
- /** 宿主的思考/推理档位。core 不解释,原样透传给端口。 */
15
- effort?: string;
16
- memoryDir: string;
17
- /** 唯一写入通道。dream 的整轮互斥与快照都挂在它上面。 */
18
- store: MemoryStore;
19
- /** 索引行数硬上限(= `config.memIndexMaxLines`),写进 prompt 让 dream 承担容量管理。 */
20
- maxLines: number;
21
- sessionPersistence?: SessionPersistenceConfig;
5
+ /** `incomplete` / `not-run` 的短诊断:`cause`,`not-run` 带上 `detail`。 */
6
+ function describeCause(why: { cause: string; detail?: string }): string {
7
+ return why.detail ? `${why.cause} (${why.detail})` : why.cause;
22
8
  }
23
9
 
24
- async function snapshotFiles(memoryDir: string, platform: NodeJS.Platform = process.platform): Promise<string[]> {
25
- const entries = await readdir(memoryDir, { withFileTypes: true }).catch(() => []);
26
- return (
27
- entries
28
- .filter((entry) => entry.isFile() && !entry.name.startsWith("."))
29
- // win32:保留设备名文件按名打开会命中设备(CON 等),`createSnapshot` 的 cp 会失败;
30
- // dream 是 fail-closed 的,一次 cp 失败就打断整轮(与 `MemoryStore` 的 `#entryFiles` 同一契约)。
31
- .filter((entry) => !(platform === "win32" && isReservedWindowsName(entry.name)))
32
- .map((entry) => entry.name)
33
- .sort()
34
- );
35
- }
36
-
37
- /**
38
- * 跑一轮 dream:整轮持有进程内逻辑锁 → 对整目录拍一次快照 → 起一个只有 `memory` 工具的
39
- * headless 会话。
40
- *
41
- * 整轮持锁是刻意的:dream 的多次 `replace` / `remove` 必须对同进程的 `memory add` 与 extract
42
- * 呈现为**一个**原子区间,否则用户会在 dream 中途读到半合并的状态。锁在进程内(不是 `.lock`),
43
- * 所以跨进程的其它 worktree 仍能正常写入 —— 它们的每次物理写入只被挡毫秒级。
44
- *
45
- * 其内部的每个原语调用必须带 `{ skipLogicalLock: true, skipSnapshot: true }`(由 `memoryTool`
46
- * 请求字段表达,适配层据此构造子会话的工具),否则它们会去抢自己已持有的锁而自锁到超时,
47
- * 并且会为同一批变更各拍一次快照。
48
- */
49
-
50
- export async function runDream(opts: RunDreamOpts, ports: { headless: HeadlessAgentPort }): Promise<string> {
51
- return opts.store.withLogicalLock(async () => {
52
- // 启动自检:忘了包 withLogicalLock 会静默失去整轮互斥。
53
- if (!opts.store.logicalLockActive()) {
54
- throw new Error("dream must run under the memory logical lock");
10
+ /** dream 描述符:core 自读 `cfg.memIndexMaxLines`,适配层只给触发点与端口。 */
11
+ export const dream: MemoryTask<Record<string, never>, string> = {
12
+ id: "dream",
13
+ plan: {
14
+ timeoutMs: 600_000,
15
+ memoryTool: DREAM_ACTIONS,
16
+ lock: "logical+snapshot",
17
+ errorPolicy: "rethrow",
18
+ },
19
+ buildPrompt(cfg) {
20
+ return buildDreamTask(cfg.memIndexMaxLines);
21
+ },
22
+ parse(_cfg, _deps, _input, text) {
23
+ return text;
24
+ },
25
+ notCompleted(_cfg, _deps, _input, why) {
26
+ switch (why.status) {
27
+ case "no-input":
28
+ // 理论不可达(dream 的 prompt 恒有值),但用户显式触发的任务不许静默报成功。
29
+ throw new Error("dream has no input to work with");
30
+ case "lock-busy":
31
+ throw new Error("the dream round could not start: the memory logical lock is busy");
32
+ case "no-output":
33
+ throw new Error("the host ran the dream round but produced no summary");
34
+ case "incomplete":
35
+ case "not-run":
36
+ throw new Error(`the dream round did not complete (${describeCause(why)})`);
55
37
  }
38
+ },
39
+ };
56
40
 
57
- // 进入时对整目录拍一次快照;这一轮里各原语的逐文件快照被 skipSnapshot 跳过。
58
- const files = await snapshotFiles(opts.memoryDir, opts.store.cfg.platform);
59
- await createSnapshot(join(opts.memoryDir, BACKUP_DIR), "dream", files, opts.memoryDir, {
60
- keep: opts.store.cfg.lock.snapshotKeep,
61
- platform: opts.store.cfg.platform,
62
- });
63
-
64
- const result = await ports.headless.run({
65
- task: buildDreamTask(opts.maxLines),
66
- cwd: opts.memoryDir,
67
- model: opts.model,
68
- effort: opts.effort,
69
- maxTurns: undefined,
70
- timeoutMs: 600_000,
71
- // 没有裸写权限:dream 的重构能力边界由 7 个原语定义,因此首次可被单测覆盖。
72
- builtinTools: "none",
73
- // dream 专属的 7 个 action;整轮持锁与进入时的全目录快照都在外面,所以两个 skip 都开。
74
- memoryTool: { actions: DREAM_ACTIONS, skipLogicalLock: true, skipSnapshot: true },
75
- sessionPersistence: opts.sessionPersistence,
76
- });
77
- // `null` = 宿主没跑这一轮。dream 是用户显式触发的(`/dream` 或 nudge 确认后),
78
- // 静默返回空摘要会让用户以为合并成功了 —— 显式抛错,由适配层转成失败通知。
79
- if (result === null) throw new Error("the host did not run the dream round");
80
- return result;
81
- });
82
- }
@@ -1,75 +1,60 @@
1
- import type { SessionPersistenceConfig } from "../config.js";
2
- import type { MemoryStore } from "../store/memory-store.js";
3
1
  import { MAIN_AGENT_ACTIONS, type MemoryAction } from "../tool/actions.js";
4
2
  import { buildExtractTask } from "./prompts.js";
5
- import type { HeadlessAgentPort, HostTranscriptPort } from "./ports.js";
3
+ import type { MemoryTask } from "./task.js";
6
4
 
7
- export interface RunExtractOpts {
8
- /** 必填:模型来自显式配置(启动校验已保证可解析),没有父模型回退。 */
9
- model: string;
10
- /** 宿主的思考/推理档位。core 不解释,原样透传给端口。 */
11
- effort?: string;
12
- memoryDir: string;
13
- /** 唯一写入通道:extract 的整轮互斥挂在它的 `tryWithLogicalLock` 上。 */
14
- store: MemoryStore;
15
- /** 宿主的原始消息序列(pi 的 `AgentMessage[]` 等),经 `ports.transcript` 转成中立形状。 */
5
+ /** `runTask(TASKS.extractMemories, …)` 的输入:宿主只给数据,预算由 core 从 `cfg` 自读。 */
6
+ export interface ExtractInput {
16
7
  messages: unknown[];
17
- maxContextTokens: number;
18
- maxToolResultChars: number;
19
- maxAssistantChars: number;
20
- sessionPersistence?: SessionPersistenceConfig;
21
- agentsMdBlocks?: string[];
22
- /**
23
- * 子会话里的 `memory` 工具每成功写入一条就回调一次。
24
- *
25
- * extract 跑在 headless 子会话里,适配层看不到它写了几条,只能靠这个回调计数(用来决定
26
- * 要不要弹「Extracted N memories」)。core 不解释它,只负责透到工具规格里。
27
- */
8
+ agentsMdBlocks: string[];
28
9
  onWrite?: (info: { action: MemoryAction; name?: string }) => void;
29
10
  }
30
11
 
31
- export interface ExtractPorts {
32
- headless: HeadlessAgentPort;
33
- transcript: HostTranscriptPort;
12
+ export interface ExtractResult {
13
+ skipped: boolean;
14
+ result?: string;
34
15
  }
35
16
 
36
- /**
37
- * 跑一轮 extract。
38
- *
39
- * **不等待逻辑锁**:拿不到就跳过本轮并返回 `{ skipped: true }`。extract 在每轮结束都触发,
40
- * 让它排队等 dream(分钟级)只会把一批批早已过时的对话堆在队列里,等 dream 结束后依次重放。
41
- *
42
- * 错误**不吞**:吞掉会让 extract 的失败对用户完全不可见。由调用方决定如何呈现(适配层通常
43
- * 做限流通知)。
44
- */
45
- export async function runExtract(
46
- opts: RunExtractOpts,
47
- ports: ExtractPorts,
48
- ): Promise<{ skipped: boolean; result?: string }> {
49
- const messages = ports.transcript.toExtractMessages(opts.messages);
50
- if (messages.length === 0) return { skipped: true };
51
-
52
- const task = buildExtractTask(messages, opts.maxContextTokens, opts.agentsMdBlocks ?? [], {
53
- maxToolResultChars: opts.maxToolResultChars,
54
- maxAssistantChars: opts.maxAssistantChars,
55
- });
56
-
57
- const result = await opts.store.tryWithLogicalLock(() =>
58
- ports.headless.run({
59
- task,
60
- cwd: opts.memoryDir,
61
- model: opts.model,
62
- effort: opts.effort,
63
- maxTurns: 5,
64
- timeoutMs: 120_000,
65
- // 没有文件工具:extract 只能通过 memory 原语写。
66
- builtinTools: "none",
67
- memoryTool: { actions: MAIN_AGENT_ACTIONS, skipLogicalLock: true, onWrite: opts.onWrite },
68
- sessionPersistence: opts.sessionPersistence,
69
- }),
70
- );
71
-
72
- if (result === null) return { skipped: true };
73
- return { skipped: false, result };
74
- }
17
+ /** extractMemories 描述符:core 自读 `cfg.extractMemories` 的预算,适配层只给宿主数据。 */
18
+ export const extractMemories: MemoryTask<ExtractInput, ExtractResult> = {
19
+ id: "extractMemories",
20
+ plan: {
21
+ maxTurns: 5,
22
+ timeoutMs: 120_000,
23
+ memoryTool: MAIN_AGENT_ACTIONS,
24
+ lock: "logical",
25
+ errorPolicy: "rethrow",
26
+ },
27
+ buildPrompt(cfg, deps, input) {
28
+ if (!deps.transcript) throw new Error("the extract task requires a transcript port");
29
+ const messages = deps.transcript.toExtractMessages(input.messages);
30
+ // 「判空在锁之前」由 `runTask` 的顺序契约保证(buildPrompt 先于 plan.lock 执行),
31
+ // 这里只做纯计算,不碰 store / lock。
32
+ if (messages.length === 0) return null;
33
+ return buildExtractTask(messages, cfg.extractMemories.maxContextTokens, input.agentsMdBlocks, {
34
+ maxToolResultChars: cfg.extractMemories.maxToolResultChars,
35
+ maxAssistantChars: cfg.extractMemories.maxAssistantChars,
36
+ });
37
+ },
38
+ parse(_cfg, _deps, _input, text) {
39
+ return { skipped: false, result: text };
40
+ },
41
+ notCompleted(_cfg, _deps, _input, why) {
42
+ switch (why.status) {
43
+ case "no-input":
44
+ case "lock-busy":
45
+ case "not-run":
46
+ return { skipped: true };
47
+ case "no-output":
48
+ // pi 的空回复一直是 `{ skipped: false, result: "" }`:这一轮确实跑完、只是没写出文本,
49
+ // 得让「Extracted N memories」通知的既有语义活下来。
50
+ return { skipped: false, result: "" };
51
+ case "incomplete":
52
+ // 写入可能已经通过子会话工具发生过:报失败会歪曲一个确实落盘了的轮次。
53
+ return { skipped: false };
54
+ }
55
+ },
56
+ memoryToolHooks(input) {
57
+ return { onWrite: input.onWrite };
58
+ },
59
+ };
75
60
 
@@ -8,11 +8,30 @@ import type { MemoryAction } from "../tool/actions.js";
8
8
  */
9
9
 
10
10
  /**
11
- * 跑一轮受限工具的 headless 会话,返回 assistant 文本。
11
+ * 一轮 headless 会话的结果。
12
12
  *
13
- * `null` = 本轮**未执行**(宿主拒绝、超时、或适配层判定不该跑)。调用方必须能区分
14
- * 「跑了但没写出东西」与「根本没跑」:`runExtract` 靠它决定要不要报 `{ skipped: true }`。
13
+ * **判别规则**:请求**发出之前**就结束 → `not-run`(没跑);**发出之后**没拿到完整文本 →
14
+ * `incomplete`(跑过一部分,写入可能已发生)。`no-output` 是「跑完但终止事件没有任何 assistant
15
+ * 文本」—— 它是 `incomplete` 的极端形态,单独留一个 arm 是因为调用方对「跑了、没写出东西」与
16
+ * 「写了半截」的处置不同。
15
17
  */
18
+ export type HeadlessOutcome =
19
+ /** 跑完并拿到了最终文本。空回复是 `text: ""`,**不是** `no-output`。 */
20
+ | { status: "completed"; text: string }
21
+ /** 跑过但拿不到最终文本(终止事件无 assistant 输出)。 */
22
+ | { status: "no-output" }
23
+ /** 跑过一部分:写入可能已发生。`aborted` 指请求已发出、中途被取消。 */
24
+ | { status: "incomplete"; cause: "max-tokens" | "turn-limit" | "aborted" }
25
+ /**
26
+ * 请求从未真正开始:宿主拒绝、模型不可用、适配层判定不该发、请求前就超时。
27
+ * `aborted` 指发出前取消。`detail` 是可选的一句人读诊断(宿主原始报错等)。
28
+ */
29
+ | { status: "not-run"; cause: "host-refused" | "unavailable" | "timeout" | "aborted"; detail?: string };
30
+
31
+ /** 描述符的 `incomplete` 收的那一半:除 `completed` 之外的一切。 */
32
+ export type IncompleteOutcome = Exclude<HeadlessOutcome, { status: "completed" }>;
33
+
34
+ /** 跑一轮受限工具的 headless 会话的请求:core 构造,适配层执行。 */
16
35
  export interface HeadlessRunRequest {
17
36
  /** prompt 全文,由 core 的 `orchestration/prompts.ts` 生成。 */
18
37
  task: string;
@@ -52,7 +71,13 @@ export interface HeadlessRunRequest {
52
71
  }
53
72
 
54
73
  export interface HeadlessAgentPort {
55
- run(req: HeadlessRunRequest): Promise<string | null>;
74
+ /**
75
+ * 跑一轮受限工具的 headless 会话。
76
+ *
77
+ * 结果必须是 `HeadlessOutcome`:调用方要靠 `status` 区分「跑了但没写出东西」与「根本没跑」
78
+ * (extract 描述符靠它决定要不要报 `{ skipped: true }`)。传输级异常仍可抛。
79
+ */
80
+ run(req: HeadlessRunRequest): Promise<HeadlessOutcome>;
56
81
  }
57
82
 
58
83
  /**
@@ -0,0 +1,37 @@
1
+ import { taskModel } from "../config.js";
2
+ import type { MemoryConfig, ModelTask } from "../config.js";
3
+
4
+ /**
5
+ * 一行模块状态:core 只给**数据**,不给文案。怎么措辞、按什么顺序展示,是适配层的事。
6
+ */
7
+ export interface ModuleStatusRow {
8
+ id: ModelTask;
9
+ /** 适配层是否声明实现了该任务(`tasks` 参数里有没有它)。 */
10
+ implemented: boolean;
11
+ /** 该任务是否可用:`dream` 无包级开关恒 `true`,其余取各自的 `enabled`。 */
12
+ enabled: boolean;
13
+ /** 生效模型值(per-task 优先,其次 defaults.model);仅当 `enabled && implemented` 时给出。 */
14
+ model?: string;
15
+ }
16
+
17
+ /** 顺序恒为 dream → extractMemories → autoSurfacing(与 `requiredModels` 同一口径)。 */
18
+ const ORDER: readonly ModelTask[] = ["dream", "extractMemories", "autoSurfacing"];
19
+
20
+ /**
21
+ * 每个任务的模块状态行数据。适配层声明自己实现了哪些任务(`tasks`),core 补上
22
+ * `implemented` / `enabled` / `model` 三样事实;展示顺序与措辞仍是适配层自己的事。
23
+ */
24
+ export function moduleStatusRows(cfg: MemoryConfig, tasks: readonly ModelTask[]): ModuleStatusRow[] {
25
+ return ORDER.map((id) => {
26
+ const implemented = tasks.includes(id);
27
+ // dream 没有包级开关:恒启用。其余取各自的 `enabled`(`deepMerge` 可能把用户写的
28
+ // `null` 子树原样带进来,`?.` 让那类配置按「关闭」读,而不是抛 TypeError)。
29
+ const enabled = id === "dream" ? true : cfg[id]?.enabled === true;
30
+ const row: ModuleStatusRow = { id, implemented, enabled };
31
+ if (enabled && implemented) {
32
+ const model = taskModel(cfg, id);
33
+ if (model !== undefined) row.model = model;
34
+ }
35
+ return row;
36
+ });
37
+ }
@@ -1,22 +1,25 @@
1
- import type { SessionPersistenceConfig } from "../config.js";
2
1
  import type { EntryManifest } from "../injection.js";
3
- import { buildSideQueryTask } from "../injection.js";
4
- import type { HeadlessAgentPort } from "./ports.js";
2
+ import { buildSideQueryTask, renderSurfacedEntries } from "../injection.js";
3
+ import type { MemoryTask } from "./task.js";
5
4
 
6
- export interface RunSideQueryOpts {
5
+ export interface SurfacingInput {
7
6
  /** 本轮的候选清单(`scanEntries` 的产出)。 */
8
7
  manifest: EntryManifest[];
9
8
  /** 用户这一轮的输入(调用方负责先截到预算内)。 */
10
9
  userPrompt: string;
11
10
  /** 本 session 已经注入过的文件,不再重复选。 */
12
11
  injectedFiles: Set<string>;
13
- maxFiles: number;
14
- /** 宿主的思考/推理档位,core 原样透传。 */
15
- effort?: string;
16
- model: string;
17
- /** 子会话 cwd;传 memoryDir。 */
18
- cwd: string;
19
- sessionPersistence?: SessionPersistenceConfig;
12
+ }
13
+
14
+ export interface SurfacingResult {
15
+ /** 本轮真正渲染成功的文件;渲染不出内容时恒为 `[]`(不标记已注入)。 */
16
+ files: string[];
17
+ content: string;
18
+ }
19
+
20
+ /** 去掉已注入的文件,得到真正进入侧查询的候选清单。 */
21
+ function candidates(input: SurfacingInput): EntryManifest[] {
22
+ return input.manifest.filter((entry) => !input.injectedFiles.has(entry.file));
20
23
  }
21
24
 
22
25
  /** 从侧查询的回复里解析 `selected_files`,只保留确实在候选清单里的文件。 */
@@ -32,37 +35,34 @@ function parseSelectedFiles(result: string, candidates: EntryManifest[], maxFile
32
35
  }
33
36
  }
34
37
 
35
- /**
36
- * 跑一轮轻量侧查询,选出与用户输入相关的 entry 文件名。
37
- *
38
- * 超时/失败一律返回 `[]`,**没有回退**:auto-surfacing 是纯增益,选不出来就不注入,
39
- * 绝不因为侧查询挂了而打断用户这一轮。
40
- *
41
- * 返回的是**文件名**,不是注入内容 —— 「注入到哪个通道、本 session 已注入过哪些」是适配层的事。
42
- */
43
- export async function runSideQuery(
44
- opts: RunSideQueryOpts,
45
- ports: { headless: HeadlessAgentPort },
46
- ): Promise<string[]> {
47
- const candidates = opts.manifest.filter((entry) => !opts.injectedFiles.has(entry.file));
48
- if (candidates.length === 0) return [];
49
- const task = buildSideQueryTask(candidates, opts.userPrompt, opts.maxFiles);
50
- try {
51
- const result = await ports.headless.run({
52
- task,
53
- cwd: opts.cwd,
54
- model: opts.model,
55
- effort: opts.effort,
56
- maxTurns: 1,
57
- timeoutMs: 30_000,
58
- // 零工具就是意图:侧查询只需要回一个 JSON,给它任何工具都是白烧 token。
59
- builtinTools: "none",
60
- memoryTool: null,
61
- sessionPersistence: opts.sessionPersistence,
62
- });
63
- if (result === null) return [];
64
- return parseSelectedFiles(result, candidates, opts.maxFiles);
65
- } catch {
66
- return [];
67
- }
68
- }
38
+ /** autoSurfacing 描述符:core 自读 `cfg.autoSurfacing` 的预算,适配层只给宿主数据。 */
39
+ export const autoSurfacing: MemoryTask<SurfacingInput, SurfacingResult> = {
40
+ id: "autoSurfacing",
41
+ plan: {
42
+ maxTurns: 1,
43
+ timeoutMs: 30_000,
44
+ memoryTool: null,
45
+ lock: "none",
46
+ errorPolicy: "swallow",
47
+ },
48
+ buildPrompt(cfg, _deps, input) {
49
+ const remaining = candidates(input);
50
+ if (remaining.length === 0) return null;
51
+ return buildSideQueryTask(remaining, input.userPrompt, cfg.autoSurfacing.maxFiles);
52
+ },
53
+ async parse(cfg, deps, input, text) {
54
+ const selected = parseSelectedFiles(text, candidates(input), cfg.autoSurfacing.maxFiles);
55
+ // 只返回真正渲染出来的文件:预算被 maxInjectionBytes 截断、或 readEntry 读不到时,
56
+ // 未渲染的文件不能进 `files`,否则适配层会把它们标记成已注入、整个 session 不再浮现。
57
+ return renderSurfacedEntries(
58
+ deps.store,
59
+ selected,
60
+ cfg.autoSurfacing.maxEntryBytes,
61
+ cfg.autoSurfacing.maxInjectionBytes,
62
+ );
63
+ },
64
+ notCompleted() {
65
+ return { files: [], content: "" };
66
+ },
67
+ };
68
+