dsh-memu-memory 1.2.0 → 1.2.2

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 (100) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +19 -8
  3. package/README.zh.md +12 -6
  4. package/docs/contributing.md +16 -3
  5. package/docs/env-and-commands.md +8 -0
  6. package/docs/host-api.md +3 -1
  7. package/docs/memory-semantics.md +21 -2
  8. package/docs/troubleshooting.md +42 -3
  9. package/lib/activity-log.js +22 -5
  10. package/lib/backup.js +24 -2
  11. package/lib/binding-diagnostics.js +3 -3
  12. package/lib/client.js +1610 -117
  13. package/lib/consolidation-worker.js +68 -17
  14. package/lib/curator-evidence.js +131 -0
  15. package/lib/curator-policy.js +81 -0
  16. package/lib/curator.js +1299 -83
  17. package/lib/cycle.js +3 -0
  18. package/lib/embedding-download.js +39 -4
  19. package/lib/engine.js +6 -3
  20. package/lib/global-graph.js +282 -0
  21. package/lib/graph.js +190 -14
  22. package/lib/inbound.js +19 -0
  23. package/lib/index.js +33 -1
  24. package/lib/inject.js +277 -17
  25. package/lib/lexicon.js +1115 -0
  26. package/lib/local-vector-service.js +8 -6
  27. package/lib/managed-runtime.js +33 -2
  28. package/lib/memory.js +585 -76
  29. package/lib/memu.js +2 -2
  30. package/lib/migrate.js +83 -2
  31. package/lib/private-links.js +587 -0
  32. package/lib/private.js +128 -7
  33. package/lib/prompts/global-curator.md +76 -19
  34. package/lib/prompts/private-consolidation.coding.md +4 -1
  35. package/lib/prompts/private-consolidation.md +3 -1
  36. package/lib/prompts/private-consolidation.mixed.md +3 -1
  37. package/lib/prompts/private-consolidation.rp.md +3 -1
  38. package/lib/prompts.js +1 -1
  39. package/lib/rpc.js +622 -13
  40. package/lib/state.js +4 -1
  41. package/lib/text-retrieval.js +661 -0
  42. package/lib/text-similarity.js +124 -0
  43. package/lib/tools.js +230 -16
  44. package/lib/vector-migration.js +28 -3
  45. package/lib/vector-profile.js +24 -0
  46. package/lib/webui/standalone.css +17 -0
  47. package/lib/webui/standalone.js +2 -0
  48. package/package.json +3 -2
  49. package/scripts/acceptance-local.mjs +8 -5
  50. package/scripts/curator-pair-calibrate.mjs +259 -0
  51. package/scripts/lexicon-calibrate.mjs +230 -0
  52. package/scripts/lexicon-dedupe.mjs +318 -0
  53. package/scripts/replay.mjs +573 -0
  54. package/tests/absorb-review.test.mjs +353 -0
  55. package/tests/appendix-b.test.mjs +6 -2
  56. package/tests/appendix-b2.test.mjs +2 -2
  57. package/tests/client-export.test.mjs +520 -0
  58. package/tests/client-render.test.mjs +19 -3
  59. package/tests/consolidation-credential.test.mjs +16 -5
  60. package/tests/consolidation-feed.test.mjs +96 -0
  61. package/tests/consolidation-fork.test.mjs +144 -10
  62. package/tests/consolidation-snapshot.test.mjs +82 -4
  63. package/tests/curator-absorb.test.mjs +443 -0
  64. package/tests/curator-calibrate.test.mjs +173 -0
  65. package/tests/curator-clusters.test.mjs +281 -0
  66. package/tests/curator-commit.test.mjs +324 -0
  67. package/tests/curator-evidence.test.mjs +102 -0
  68. package/tests/curator-link.test.mjs +316 -0
  69. package/tests/curator-pairing.test.mjs +252 -0
  70. package/tests/curator-probe.test.mjs +15 -6
  71. package/tests/curator-review-mode.test.mjs +314 -0
  72. package/tests/disclosure.test.mjs +2 -1
  73. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  74. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  75. package/tests/fixtures/lexicon-stub.mjs +284 -0
  76. package/tests/graph-cascade.test.mjs +223 -0
  77. package/tests/graph.test.mjs +113 -0
  78. package/tests/help-page.test.mjs +415 -0
  79. package/tests/hybrid-recall.test.mjs +32 -2
  80. package/tests/inbound.test.mjs +6 -3
  81. package/tests/inject-guide.test.mjs +28 -0
  82. package/tests/inject.test.mjs +157 -5
  83. package/tests/lexicon-calibrate.test.mjs +48 -0
  84. package/tests/lexicon-dedupe.test.mjs +171 -0
  85. package/tests/lexicon-frequency.test.mjs +179 -0
  86. package/tests/lexicon-inject.test.mjs +410 -0
  87. package/tests/lexicon-perf.test.mjs +436 -0
  88. package/tests/lexicon-ui.test.mjs +547 -0
  89. package/tests/lexicon.test.mjs +922 -0
  90. package/tests/managed-memory.test.mjs +55 -0
  91. package/tests/managed-runtime.test.mjs +32 -0
  92. package/tests/private-links.test.mjs +445 -0
  93. package/tests/prompts.test.mjs +35 -0
  94. package/tests/recall-dedup.test.mjs +446 -0
  95. package/tests/sidebar-beat.test.mjs +5 -1
  96. package/tests/tool-schema.test.mjs +1 -1
  97. package/tests/tools.test.mjs +112 -3
  98. package/tests/vector-migration.test.mjs +58 -1
  99. package/tests/webui-auth.test.mjs +348 -2
  100. package/tests/webui.test.mjs +191 -2
@@ -0,0 +1,96 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-consolidation-feed-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+ process.env.DSH_MEMU_MEMORY_HOME = home
10
+
11
+ const state = await import('../lib/state.js')
12
+ const { record, resetActivityLogCache, listEvents, clearEvents } = await import('../lib/activity-log.js')
13
+ const { dispatch } = await import('../lib/rpc.js')
14
+
15
+ test.after(() => {
16
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
17
+ })
18
+
19
+ // 侧边栏"整理提醒"与日志页的数据链路:跨会话取整理事件,服务端把会话标题与文件夹名
20
+ // 拼进每一行(客户端只回退到裸 id)。标题走宿主 readTitleSnapshots(批量一次)。
21
+ const ctx = {
22
+ get: (name) => {
23
+ if (name === 'sessionQuery') {
24
+ return {
25
+ readTitleSnapshots: async (ids) => ids.map((id) => ({
26
+ sessionId: id,
27
+ status: id === 'session-titled' ? 'fulfilled' : 'rejected',
28
+ ...(id === 'session-titled' ? { value: { title: { title: ' princess 的深夜备份会话 ' } } } : {}),
29
+ })),
30
+ }
31
+ }
32
+ return undefined
33
+ },
34
+ }
35
+
36
+ test('getConsolidationFeed splits failures and successes, decorates titles, caps each column', async () => {
37
+ const library = state.createLibrary({ cwd: path.join(home, 'feed-proj'), name: '夜备份工作区' })
38
+ resetActivityLogCache()
39
+ // 8 条失败 + 8 条成功,交错落盘;另有别的 kind 的失败(不得混进来)。
40
+ for (let index = 0; index < 8; index += 1) {
41
+ record({ kind: 'consolidate', scope: 'private', ok: false, level: 'error', sessionId: index === 7 ? 'session-titled' : 'session-plain-' + index, libraryId: library.id, message: '整理失败 · 空闲 · case-' + index, detail: { reasonCode: 'fork-turn-failed' } })
42
+ record({ kind: 'consolidate', scope: 'private', ok: true, sessionId: 'session-titled', libraryId: library.id, message: '整理完成 · 空闲', detail: { count: index } })
43
+ }
44
+ record({ kind: 'recall', scope: 'private', ok: false, sessionId: 'session-titled', message: '召回失败不进整理提醒' })
45
+
46
+ const feed = await dispatch(ctx, 'getConsolidationFeed', { limit: 6 })
47
+ assert.equal(feed.failures.length, 6, 'failures capped at the requested column size')
48
+ assert.equal(feed.successes.length, 6, 'successes capped at the requested column size')
49
+ assert.ok(feed.failures.every(row => row.ok === false), 'the failure column holds only failures')
50
+ assert.ok(feed.successes.every(row => row.ok !== false), 'the success column holds only successes')
51
+ // 最新在前:失败列第一条是最后落盘的那条失败。
52
+ assert.match(feed.failures[0].message, /case-7/)
53
+ // 会话标题 + 文件夹名由服务端拼好;查不到标题的行回退 null(客户端用短 id)。
54
+ assert.equal(feed.failures[0].sessionTitle, 'princess 的深夜备份会话')
55
+ assert.equal(feed.failures[0].libraryName, '夜备份工作区')
56
+ const plain = feed.failures.find(row => row.sessionId === 'session-plain-6')
57
+ assert.equal(plain.sessionTitle, null)
58
+ // 行已瘦身:回执 detail 不整包透传,只留提醒需要的字段。
59
+ assert.equal(feed.failures[0].reasonCode, 'fork-turn-failed')
60
+ assert.equal(feed.failures[0].detail, undefined)
61
+ })
62
+
63
+ test('getActivityLog decorates consolidation rows with session title and library name', async () => {
64
+ const library = state.createLibrary({ cwd: path.join(home, 'feed-proj-2'), name: '第二个工作区' })
65
+ resetActivityLogCache()
66
+ record({ kind: 'consolidate', scope: 'private', ok: false, level: 'error', sessionId: 'session-titled', libraryId: library.id, message: '整理失败 · 空闲 · worker-tools-ptc-only', detail: { reasonCode: 'worker-tools-ptc-only', toolSurface: ['run_code'] } })
67
+
68
+ const data = await dispatch(ctx, 'getActivityLog', { kind: 'consolidate', limit: 10 })
69
+ const row = data.events.find(item => item.message.includes('worker-tools-ptc-only'))
70
+ assert.ok(row, 'the consolidation row is returned')
71
+ assert.equal(row.sessionTitle, 'princess 的深夜备份会话')
72
+ assert.equal(row.libraryName, '第二个工作区')
73
+ })
74
+
75
+ test('clearConsolidationFailures deletes only failed consolidate rows, everything else stays', async () => {
76
+ const library = state.createLibrary({ cwd: path.join(home, 'feed-clear'), name: '清除测试区' })
77
+ resetActivityLogCache()
78
+ // 同一个临时 home 累积了前面用例的日志:先全清,种进去的才是本用例的全部世界。
79
+ clearEvents()
80
+ record({ kind: 'consolidate', scope: 'private', ok: false, level: 'error', sessionId: 'session-titled', libraryId: library.id, message: '整理失败 · 其一', detail: { reasonCode: 'timeout' } })
81
+ record({ kind: 'consolidate', scope: 'private', ok: false, level: 'error', sessionId: 'session-titled', libraryId: library.id, message: '整理失败 · 其二', detail: { reasonCode: 'timeout' } })
82
+ record({ kind: 'consolidate', scope: 'private', ok: true, sessionId: 'session-titled', libraryId: library.id, message: '整理完成' })
83
+ record({ kind: 'recall', scope: 'private', ok: false, sessionId: 'session-titled', message: '召回失败不是整理失败' })
84
+
85
+ const result = await dispatch(ctx, 'clearConsolidationFailures', { limit: 6 })
86
+ assert.equal(result.cleared, 2, 'exactly the two failed consolidate rows are removed')
87
+ assert.equal((result.feed.failures || []).length, 0, 'the failure column comes back empty')
88
+ assert.equal((result.feed.successes || []).length, 1, 'the success flow survives')
89
+ // 其它 kind 的失败行与成功行都不受影响。
90
+ const kinds = listEvents({ limit: 50 })
91
+ assert.ok(kinds.some(r => r.kind === 'recall' && r.ok === false), 'non-consolidate failures stay')
92
+ assert.ok(kinds.some(r => r.kind === 'consolidate' && r.ok !== false), 'consolidate successes stay')
93
+ // 落盘一致:重读缓存后计数不变(rewrite 是权威,不是只在内存里删)。
94
+ resetActivityLogCache()
95
+ assert.equal(listEvents({ kind: 'consolidate', level: 'error', limit: 50 }).length, 0, 'the rewrite reached disk')
96
+ })
@@ -8,6 +8,7 @@ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-fork-'))
8
8
  process.env.DSH_MEMU_HOME = home
9
9
 
10
10
  const state = await import('../lib/state.js')
11
+ const activity = await import('../lib/activity-log.js')
11
12
  const { requestPrivateConsolidation, isConsolidating } = await import('../lib/private.js')
12
13
 
13
14
  test.after(() => {
@@ -52,8 +53,9 @@ function assertHostCreateAccepts(spec = {}) {
52
53
  }
53
54
  }
54
55
 
55
- function host({ live = false, whenIdle = null, parentOptions = { provider: 'test-provider', model: 'test-model' }, requestConfig = null, extraEvents = [], transcriptSize = 0, turnReason = { kind: 'completed' }, emitTurnEnd = true } = {}) {
56
- const rec = { liveFollowups: [], created: [], forkFollowups: [], disposed: [], sessionHandlers: [] }
56
+ function host({ live = false, whenIdle = null, parentOptions = { provider: 'test-provider', model: 'test-model' }, requestConfig = null, extraEvents = [], transcriptSize = 0, turnReason = { kind: 'completed' }, emitTurnEnd = true, headerTools = null, childCtxFactory = null } = {}) {
57
+ const rec = { liveFollowups: [], created: [], forkFollowups: [], disposed: [], sessionHandlers: [], workerToolRegistrations: [], workerToolDisposals: [] }
58
+ const childContexts = new Map()
57
59
  const tick = String.fromCharCode(96)
58
60
  const receiptFence = (range, identity) => [tick + tick + tick + 'memu-consolidation-receipt', JSON.stringify({ version: 1, ...identity, status: 'completed', outcome: 'no_value', checkedRange: range, createdIds: [], updatedIds: [], heldIds: [], reasonCode: null, retryable: false }), tick + tick + tick].join('\n')
59
61
  // 真实长会话的规模:补齐不产生证据的事件,只影响 fork 成本判定。
@@ -72,16 +74,24 @@ function host({ live = false, whenIdle = null, parentOptions = { provider: 'test
72
74
  get: (name) => {
73
75
  if (name === 'agents') {
74
76
  return {
75
- get: (id) => (live
76
- ? {
77
- followup: (message) => rec.liveFollowups.push({ id, message }),
78
- ...(parentOptions ? { options: parentOptions } : {}),
79
- ...(requestConfig ? { session: { requestHeader: () => ({ config: requestConfig }) } } : {}),
80
- }
81
- : undefined),
77
+ get: (id) => {
78
+ // attachWorkerTools 用 childContexts 模拟"宿主在 create 后暴露的 agent ctx"。
79
+ if (childContexts.has(id)) return { ctx: childContexts.get(id) }
80
+ return (live
81
+ ? {
82
+ followup: (message) => rec.liveFollowups.push({ id, message }),
83
+ ...(parentOptions ? { options: parentOptions } : {}),
84
+ ...(requestConfig || headerTools ? { session: { requestHeader: () => ({
85
+ ...(requestConfig ? { config: requestConfig } : {}),
86
+ ...(headerTools ? { tools: headerTools.map((toolName) => ({ name: toolName })) } : {}),
87
+ }) } } : {}),
88
+ }
89
+ : undefined)
90
+ },
82
91
  create: async (spec) => {
83
92
  assertHostCreateAccepts(spec)
84
93
  rec.created.push(spec)
94
+ if (childCtxFactory) childContexts.set(spec.sessionId, childCtxFactory())
85
95
  const childSession = { id: spec.sessionId }
86
96
  return {
87
97
  agent: {
@@ -354,4 +364,128 @@ test('a large transcript that was never consolidated does fork, because the back
354
364
  const result = await requestPrivateConsolidation(ctx, 'fork-large-owed')
355
365
  assert.equal(result.ok, true, 'the backlog bound lets this one through')
356
366
  assert.equal(ctx.rec.created.length, 1, 'a large unconsolidated backlog is worth exactly one fork')
357
- })
367
+ })
368
+ // ---------------------------------------------------------------- PTC 兼容
369
+ // 现场(2026-10-31,yuike-ptc 会话):PTC 模式下 request/header 的工具表只有 run_code,
370
+ // probe 曾据此判 unavailable(worker-tools-ptc-only),PTC 会话永远不被整理。宿主的
371
+ // PTC executor 明确 "only run_code is directly callable",其余工具走 run_code 程序里的
372
+ // SDK 绑定(tools.schemas(agent, toolSet) 枚举可见面)—— header 名单在 PTC 会话里
373
+ // 不承载"能不能写记忆"。修复后:PTC 不构成否证 + fork 时把 worker 的读写面
374
+ // (WORKER_WRITE_TOOLS + WORKER_READ_TOOLS)挂进 worker 自己的
375
+ // scope(注册表按 scope 链取可见面,不扩散到其它会话)。
376
+
377
+ test('a PTC session (run_code only header) is consolidated and the worker gets write tools on its own scope', async () => {
378
+ const registered = []
379
+ const disposals = []
380
+ state.ensureSession('fork-ptc', { cwd: path.join(home, 'proj-ptc'), agentPreset: 'yuike-ptc' })
381
+ const ctx = host({
382
+ live: true,
383
+ headerTools: ['run_code'],
384
+ extraEvents: [
385
+ { seq: 3, type: 'tool/ptc-dispatch-start', data: { rootCallId: 'r1', parentCallId: 'r1', subCallId: 'r1:ptc:1', name: 'read', arguments: {} } },
386
+ { seq: 4, type: 'tool/ptc-dispatch', data: { rootCallId: 'r1', parentCallId: 'r1', subCallId: 'r1:ptc:1', name: 'read', arguments: {}, isError: false, content: [] } },
387
+ ],
388
+ childCtxFactory: () => ({
389
+ tools: {
390
+ register: (tool) => {
391
+ registered.push(tool?.name)
392
+ return () => { disposals.push(tool?.name) }
393
+ },
394
+ },
395
+ }),
396
+ })
397
+ const result = await requestPrivateConsolidation(ctx, 'fork-ptc')
398
+ assert.equal(result.ok, true, 'a run_code header must not block consolidation: ' + String(result.error || ''))
399
+ assert.equal(result.settled, true)
400
+ assert.equal(ctx.rec.created.length, 1, 'the fork is created for the PTC conversation')
401
+ // 只挂 worker 的读写面(记忆读写 + 关联裁决/候选 + 词典检索/写入);
402
+ // archive/delete/unpin 这些破坏性工具不得进 worker 的工具面。
403
+ assert.deepEqual([...registered].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
404
+ // 整理结束(finally)必须卸载挂载,不能给这条一次性 worker 留常驻注册。
405
+ assert.deepEqual([...disposals].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
406
+ // 全量挂载的活动元数据:attached=实际子集(此处=完整 wanted)、requested=完整 wanted、
407
+ // partial=false。三件一起钉住"成功路径不再含糊"。
408
+ const started = startedEntryFor(ctx, 'fork-ptc')
409
+ assert.deepEqual([...started.detail.workerToolsAttached].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
410
+ assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
411
+ assert.equal(started.detail.workerToolsPartial, false, 'a full mount is not partial')
412
+ const followup = ctx.rec.forkFollowups[0]?.message
413
+ const brief = Array.isArray(followup?.content)
414
+ ? followup.content.map((block) => block?.text || '').join('\n')
415
+ : String(followup || '')
416
+ assert.match(brief, /run_code/, 'the brief names the PTC transport')
417
+ assert.match(brief, /tools\.memu_private_remember/, 'the brief shows the SDK invocation shape')
418
+ })
419
+
420
+ // "整理已开始"那条活动日志:按 sessionId + phase=started 取,childId 对上这次的 fork。
421
+ function startedEntryFor(ctx, sessionId) {
422
+ const childId = ctx.rec.created[0]?.sessionId
423
+ const entry = activity.listEvents({ kind: 'consolidate', sessionId })
424
+ .find((row) => row.detail?.phase === 'started' && row.detail?.childId === childId)
425
+ assert.ok(entry, 'the consolidation-started activity entry exists')
426
+ return entry
427
+ }
428
+
429
+ test('a host registry refusing one write tool leaves a structured partial mount in the activity log', async () => {
430
+ // 宿主注册表对单件工具说"不"(重名、scope 锁定)是真实的部分挂载路径:registerTools
431
+ // 逐件 fail-open,其余工具照常挂上。活动日志必须记实际子集 + requested + partial,
432
+ // 而不是把 wanted 全量误报成已挂载(旧口径的缺陷)。
433
+ const registered = []
434
+ const disposals = []
435
+ state.ensureSession('fork-ptc-partial', { cwd: path.join(home, 'proj-ptc-partial'), agentPreset: 'yuike-ptc' })
436
+ const ctx = host({
437
+ live: true,
438
+ headerTools: ['run_code'],
439
+ childCtxFactory: () => ({
440
+ tools: {
441
+ register: (tool) => {
442
+ if (tool?.name === 'memu_private_remember') throw new Error('registry refused: name already bound')
443
+ registered.push(tool?.name)
444
+ return () => { disposals.push(tool?.name) }
445
+ },
446
+ },
447
+ }),
448
+ })
449
+ const result = await requestPrivateConsolidation(ctx, 'fork-ptc-partial')
450
+ assert.equal(result.ok, true, String(result.error || ''))
451
+ assert.equal(result.settled, true)
452
+ assert.deepEqual([...registered].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_update'],
453
+ 'exactly the tools that actually registered are mounted')
454
+ assert.deepEqual([...disposals].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_update'],
455
+ 'dispose unmounts exactly what was mounted')
456
+ const started = startedEntryFor(ctx, 'fork-ptc-partial')
457
+ assert.deepEqual([...started.detail.workerToolsAttached].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_update'],
458
+ 'the log records the actual subset, not the full wanted list')
459
+ assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
460
+ assert.equal(started.detail.workerToolsPartial, true)
461
+ // 挂载为部分子集时任务书仍带 PTC 调用形态:worker 知道写记忆走 run_code 的 SDK 绑定。
462
+ const followup = ctx.rec.forkFollowups[0]?.message
463
+ const brief = Array.isArray(followup?.content)
464
+ ? followup.content.map((block) => block?.text || '').join('\n')
465
+ : String(followup || '')
466
+ assert.match(brief, /await tools\.memu_private_remember/, 'the brief keeps the PTC invocation shape on a partial mount')
467
+ })
468
+
469
+ test('a fork context without a tools service records a null mount, the request and the partial flag', async () => {
470
+ // 宿主不暴露 agent ctx(或 ctx 上没有 tools 服务)→ attachWorkerTools 的现有 warn 路径。
471
+ // 语义:workerToolsAttached=null = 挂载没有完成(与"挂全了"严格区分);requested 仍记
472
+ // 完整 wanted;workerToolsPartial=true —— "未证明拿到完整 wanted 集",一件都没有
473
+ // 是它最极端的形态。若这里标 false,false 会被一眼读成"全量挂载",元数据又不准了。
474
+ state.ensureSession('fork-ptc-notools', { cwd: path.join(home, 'proj-ptc-notools'), agentPreset: 'yuike-ptc' })
475
+ const ctx = host({ live: true, headerTools: ['run_code'] })
476
+ const result = await requestPrivateConsolidation(ctx, 'fork-ptc-notools')
477
+ assert.equal(result.ok, true, String(result.error || ''))
478
+ const started = startedEntryFor(ctx, 'fork-ptc-notools')
479
+ assert.equal(started.detail.workerToolsAttached, null, 'no mount completed, so the log must not claim one')
480
+ assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
481
+ assert.equal(started.detail.workerToolsPartial, true)
482
+ })
483
+
484
+ test('a non-PTC header without write tools still refuses to fork', async () => {
485
+ state.ensureSession('fork-notools', { cwd: path.join(home, 'proj-notools'), agentPreset: 'coding' })
486
+ const ctx = host({ live: true, headerTools: ['read', 'shell', 'edit'] })
487
+ const result = await requestPrivateConsolidation(ctx, 'fork-notools')
488
+ assert.equal(result.ok, false)
489
+ assert.equal(result.reasonCode, 'worker-tools-missing')
490
+ assert.equal(ctx.rec.created.length, 0, 'no fork when the model surface provably lacks the write tools')
491
+ })
@@ -262,17 +262,95 @@ test('a header without memory tools is unavailable rather than a silent wasted f
262
262
  assert.equal(probe.reasonCode, 'worker-tools-missing')
263
263
  })
264
264
 
265
- test('a PTC-only turn is reported as PTC, not as a generic missing tool', () => {
265
+ test('auxiliary link and lexicon tools alone do not prove the worker can write memory', () => {
266
+ // 判据只看 CORE_WRITE_TOOLS:link / link_suggest / lexicon_propose 是辅助面,
267
+ // 一份只列它们、没有 remember/update 的名单仍然是"不能写记忆",不能据此 fork。
268
+ const events = [{
269
+ seq: 0,
270
+ type: 'request/header',
271
+ data: { header: { config: { provider: 'p', model: 'm' }, tools: [
272
+ { name: 'memu_private_link' },
273
+ { name: 'memu_private_link_suggest' },
274
+ { name: 'memu_lexicon_propose' },
275
+ { name: 'memu_lexicon_search' },
276
+ ] } },
277
+ }]
278
+ const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
279
+ assert.equal(probe.ok, false)
280
+ assert.equal(probe.reasonCode, 'worker-tools-missing')
281
+ assert.equal(probe.hasWrite, false)
282
+ })
283
+
284
+ test('the brief teaches the suggest-then-adjudicate loop and lexicon extraction', () => {
285
+ const brief = buildWorkerBrief({
286
+ snapshot: { fromSeq: 0, throughSeq: 5, eventCount: 6, hash: 'brief01' },
287
+ probe: { tools: ['memu_private_remember', 'memu_private_update', 'memu_private_link_suggest', 'memu_lexicon_propose'] },
288
+ contract: 'BRIEF-CONTRACT',
289
+ })
290
+ assert.match(brief, /memu_private_link_suggest\(\{ids:\[\.\.\.\]\}\)/, 'the call shape is spelled out')
291
+ assert.match(brief, /已经自动连好/, 'high-confidence links are applied before the worker sees candidates')
292
+ assert.match(brief, /每轮最多确认 5 条/)
293
+ assert.match(brief, /noop/)
294
+ assert.match(brief, /conflict/)
295
+ assert.match(brief, /memu_lexicon_search/)
296
+ assert.match(brief, /memu_lexicon_propose/)
297
+ assert.match(brief, /term 是索引键/)
298
+ assert.match(brief, /sensitive trigger/)
299
+ assert.match(brief, /tools-unavailable/, 'the tools-unavailable contract survives')
300
+ assert.match(brief, /没有回执视为本次整理失败/, 'the receipt requirement survives')
301
+ assert.match(brief, /BRIEF-CONTRACT/)
302
+ })
303
+
304
+ test('a PTC-only turn is not a refusal: the probe reports ptc and the SDK surface is checked at fork time', () => {
305
+ // 2026-10-31 口径变更:PTC 模式下 header 只列 transport(这里 ptc_exec,真实宿主为
306
+ // run_code),其余工具走 run_code 程序里的 SDK 绑定 —— header 名单不承载"能不能写",
307
+ // 不再产生 worker-tools-ptc-only 拒绝;fork 后由 attachWorkerTools 挂载读写工具。
266
308
  const events = [
267
309
  { seq: 0, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'ptc_exec' }] } } },
268
- { seq: 1, type: 'tool/ptc-dispatch', data: {} },
310
+ { seq: 1, type: 'tool/ptc-dispatch', data: { name: 'ptc_exec' } },
269
311
  ]
270
312
  const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
271
- assert.equal(probe.ok, false)
272
- assert.equal(probe.reasonCode, 'worker-tools-ptc-only')
313
+ assert.equal(probe.ok, true, 'a PTC surface can never disprove write ability')
314
+ assert.equal(probe.source, 'ptc-header')
315
+ assert.equal(probe.ptcObserved, true)
316
+ assert.equal(probe.hasWrite, null)
317
+ })
318
+
319
+ test('a run_code header alone (no dispatch events yet) is still recognized as PTC', () => {
320
+ // 新 PTC 会话一次 run_code 都没跑过时也要认得出来:run_code 出现在名单里就是证据。
321
+ const events = [{ seq: 0, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'run_code' }] } } }]
322
+ const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
323
+ assert.equal(probe.ok, true)
324
+ assert.equal(probe.source, 'ptc-header')
273
325
  assert.equal(probe.ptcObserved, true)
274
326
  })
275
327
 
328
+ test('a mixed surface (run_code beside native memu_*) proves write ability and keeps the PTC shape', () => {
329
+ // 混合面:PTC transport 与原生 memu_* 同时出现在 header 名单(preset 部分列出本插件、
330
+ // 或 PTC 与原生工具共存的会话)。probe 既不能当纯 PTC(hasWrite:null 会丢掉"名单里
331
+ // 明摆着有写工具"的证据),也不能当普通名单(ptcObserved:false 会丢掉 PTC transport)。
332
+ // hasWrite:true + ptcObserved:true 是钉住的口径;任务书(buildWorkerBrief)在混合面下
333
+ // 既要列原生名单、又要带 run_code 的 SDK 调用形态——两种 transport 都覆盖。
334
+ const events = [{
335
+ seq: 0,
336
+ type: 'request/header',
337
+ data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'run_code' }, ...MEMU_TOOLS] } },
338
+ }]
339
+ const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
340
+ assert.equal(probe.ok, true)
341
+ assert.equal(probe.hasWrite, true, 'a native write tool in the list still proves write ability')
342
+ assert.equal(probe.ptcObserved, true, 'run_code in the list is PTC evidence even beside native tools')
343
+ assert.equal(probe.source, 'header-event')
344
+ const brief = buildWorkerBrief({
345
+ snapshot: { fromSeq: 0, throughSeq: 5, eventCount: 6, hash: 'mix001' },
346
+ probe,
347
+ contract: 'MIXED-CONTRACT',
348
+ })
349
+ assert.match(brief, /run_code, memu_private_remember, memu_private_update/, 'the brief lists the native names')
350
+ assert.match(brief, /await tools\.memu_private_remember/, 'the brief keeps the PTC SDK invocation shape on a mixed surface')
351
+ assert.match(brief, /MIXED-CONTRACT/)
352
+ })
353
+
276
354
  test('a missing tool list is unknown, not a refusal', () => {
277
355
  const probe = probeToolSurface({ ctx: null, sessionId: 's', events: [{ seq: 0, type: 'request/header', data: { header: { config: {} } } }] })
278
356
  assert.equal(probe.ok, true)