@jjh32456/dsh-longlive 1.6.8 → 1.8.0

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 (288) hide show
  1. package/CHANGELOG.md +69 -1539
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +33 -12
  4. package/README.en.md +17 -7
  5. package/README.md +29 -10
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +17 -12
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +3 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +143 -37
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +66 -29
  16. package/docs/MIGRATION-1.6.0.md +2 -2
  17. package/docs/OPERATIONS.md +6 -6
  18. package/docs/PANEL-INTEGRATION.md +6 -0
  19. package/docs/PRIVACY.md +88 -0
  20. package/docs/QUICKSTART.md +5 -5
  21. package/docs/README.en.md +3 -2
  22. package/docs/RESIDENT-WATCHER.md +1 -1
  23. package/docs/SESSION-WATCH.md +6 -6
  24. package/docs/TROUBLESHOOTING.md +4 -4
  25. package/package.json +42 -117
  26. package/runbook.md +2 -2
  27. package/scripts/backfill-auto-cards.mjs +98 -0
  28. package/scripts/install-surgery-task.ps1 +5 -2
  29. package/scripts/lib/session-watch-fixture.mjs +1 -1
  30. package/scripts/longlive-doctor.mjs +6 -0
  31. package/scripts/migrate-180.mjs +163 -0
  32. package/scripts/recall-index-build.mjs +128 -0
  33. package/scripts/session-watch-drill.mjs +1 -1
  34. package/scripts/surgery-run.cmd +1 -1
  35. package/scripts/unattended-surgery.ps1 +5 -2
  36. package/scripts/verify-card-anchors.mjs +138 -0
  37. package/src/actions/audit.js +125 -0
  38. package/src/actions/evidence.js +122 -0
  39. package/src/actions/fs.js +162 -0
  40. package/src/activity/index.js +129 -18
  41. package/src/activity/model.js +11 -1
  42. package/src/activity/store.js +22 -3
  43. package/src/activity/thinking.js +176 -0
  44. package/src/adapters/constants.js +11 -0
  45. package/src/adapters/engram/data/aliases.json +51 -0
  46. package/src/adapters/engram/data/policy.json +8 -0
  47. package/src/adapters/engram/data/routing-rules.json +546 -0
  48. package/src/adapters/engram/index.js +235 -0
  49. package/src/adapters/registry.js +315 -0
  50. package/src/answer/audit.js +193 -0
  51. package/src/answer/evidence.js +30 -0
  52. package/src/answer/guard.js +1370 -0
  53. package/src/answer/index.js +1067 -0
  54. package/src/answer/query.js +111 -0
  55. package/src/answer/second-hop.mjs +101 -0
  56. package/src/auto-truncate.js +1 -1
  57. package/src/config.js +145 -48
  58. package/src/deps.js +6 -4
  59. package/src/features.js +158 -8
  60. package/src/identity/index.js +13 -5
  61. package/src/identity/inject-position.js +2 -2
  62. package/src/index.js +97 -7
  63. package/src/lifecycle/gap-clocks.js +328 -0
  64. package/src/lifecycle/index.js +269 -0
  65. package/src/memory/activity-capture.js +229 -0
  66. package/src/memory/anchors.js +194 -0
  67. package/src/memory/audit-records.js +222 -0
  68. package/src/memory/auto-cards.js +401 -0
  69. package/src/memory/capture.js +442 -0
  70. package/src/memory/card-anchors.js +203 -0
  71. package/src/memory/conflicts.js +106 -0
  72. package/src/memory/decay.js +311 -0
  73. package/src/memory/distill.js +471 -0
  74. package/src/memory/event-index.js +917 -0
  75. package/src/memory/evidence-chain.js +295 -0
  76. package/src/memory/evidence.js +145 -0
  77. package/src/memory/filter-runner.js +1 -1
  78. package/src/memory/filter.js +5 -1
  79. package/src/memory/forget-v2.js +410 -0
  80. package/src/memory/forget.js +71 -0
  81. package/src/memory/frames-activation.js +247 -0
  82. package/src/memory/fts-query.js +174 -0
  83. package/src/memory/governance.js +604 -0
  84. package/src/memory/index.js +1 -0
  85. package/src/memory/l2-gate.js +169 -0
  86. package/src/memory/lock.js +121 -0
  87. package/src/memory/migrate.js +139 -0
  88. package/src/memory/recall-v2.js +223 -0
  89. package/src/memory/recall.js +65 -2
  90. package/src/memory/records.js +425 -0
  91. package/src/memory/retention.js +128 -0
  92. package/src/memory/review.js +124 -0
  93. package/src/memory/trust.js +88 -0
  94. package/src/memory/version-chain.js +264 -0
  95. package/src/memory/wal.js +137 -0
  96. package/src/memory/working-frame.js +516 -0
  97. package/src/metrics/classify.mjs +5 -3
  98. package/src/metrics/index.mjs +21 -1
  99. package/src/metrics/prestep-latency.mjs +300 -0
  100. package/src/metrics/store.mjs +25 -0
  101. package/src/policy-profile.js +18 -3
  102. package/src/recall/anaphora.mjs +87 -0
  103. package/src/recall/cards.mjs +7 -0
  104. package/src/recall/embedding.mjs +8 -8
  105. package/src/recall/entities.mjs +112 -0
  106. package/src/recall/index.js +235 -19
  107. package/src/recall/inject-order.mjs +10 -8
  108. package/src/recall/privacy.mjs +1 -1
  109. package/src/recall/recall-core.mjs +116 -10
  110. package/src/recall/shared-visibility.js +302 -0
  111. package/src/recall/time-normalizer.mjs +196 -0
  112. package/src/role/activation.js +191 -0
  113. package/src/role/attention.js +318 -0
  114. package/src/role/base.js +36 -0
  115. package/src/role/channels.js +423 -0
  116. package/src/role/index.js +425 -0
  117. package/src/role/interfaces.js +115 -0
  118. package/src/role/personality.js +370 -0
  119. package/src/role/readers.js +151 -0
  120. package/src/role/relationships.js +477 -0
  121. package/src/role/store.js +202 -0
  122. package/src/role/writeprotect.js +373 -0
  123. package/src/router/aliases.json +176 -25
  124. package/src/router/background/agent-runner.mjs +1 -1
  125. package/src/router/background/anchor.mjs +1 -1
  126. package/src/router/background/audit.mjs +2 -2
  127. package/src/router/background/budget-ledger.mjs +2 -2
  128. package/src/router/background/host-sim.mjs +1 -1
  129. package/src/router/background/memory.mjs +4 -4
  130. package/src/router/background/queue.mjs +1 -1
  131. package/src/router/background/retry.mjs +1 -1
  132. package/src/router/background/router.mjs +1 -1
  133. package/src/router/background/stream.mjs +1 -1
  134. package/src/router/background/tool-policy.mjs +1 -1
  135. package/src/router/design-headings.json +5 -5
  136. package/src/router/face/config.mjs +1 -1
  137. package/src/router/face/identity.mjs +3 -3
  138. package/src/router/judge.mjs +8 -7
  139. package/src/router/policies/generic.json +21 -4
  140. package/src/router/route.js +109 -22
  141. package/src/router/router.mjs +10 -6
  142. package/src/router/routing-rules.json +260 -445
  143. package/src/router/run-sim.mjs +8 -8
  144. package/src/router/runtime.js +23 -12
  145. package/src/router/service/config.mjs +5 -5
  146. package/src/router/service/context.mjs +1 -1
  147. package/src/router/service/queue.mjs +1 -1
  148. package/src/router/shadow.mjs +4 -3
  149. package/src/surgery-recovery.js +2 -2
  150. package/src/tool-output.js +2 -2
  151. package/src/tools.js +39 -7
  152. package/src/truncate-visible.js +2 -2
  153. package/src/ui/actions.js +1312 -0
  154. package/src/ui/panel-api.js +367 -0
  155. package/src/ui/panel-auth.js +251 -0
  156. package/src/ui/panel-integrity.js +243 -0
  157. package/src/ui/routes-readonly.js +695 -0
  158. package/src/ui/routes.js +3 -0
  159. package/docs/DEV.md +0 -279
  160. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  161. package/docs/PRESENCE-BUS.md +0 -694
  162. package/docs/SURGERY.md +0 -186
  163. package/scripts/mark-core-cards.mjs +0 -289
  164. package/scripts/pack.mjs +0 -392
  165. package/scripts/router-proposals.mjs +0 -386
  166. package/src/router/fixtures/M1.json +0 -16
  167. package/src/router/fixtures/M2.json +0 -16
  168. package/src/router/fixtures/M3.json +0 -20
  169. package/src/router/fixtures/M4.json +0 -20
  170. package/src/router/fixtures/M5.json +0 -45
  171. package/src/router/fixtures/M6.json +0 -53
  172. package/src/router/fixtures/M7.json +0 -46
  173. package/src/router/fixtures/S1.json +0 -20
  174. package/src/router/fixtures/S2.json +0 -23
  175. package/src/router/fixtures/S3.json +0 -20
  176. package/src/router/fixtures/S4.json +0 -34
  177. package/src/router/fixtures/S5.json +0 -43
  178. package/src/router/fixtures/S6.json +0 -47
  179. package/src/router/fixtures/T1.json +0 -23
  180. package/src/router/fixtures/T2.json +0 -16
  181. package/src/router/fixtures/T3.json +0 -19
  182. package/src/router/fixtures/T4.json +0 -16
  183. package/src/router/fixtures/X1.json +0 -17
  184. package/src/router/fixtures/X2.json +0 -20
  185. package/src/router/gen-design-headings.mjs +0 -107
  186. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  187. package/test/activity-realtime.test.mjs +0 -959
  188. package/test/activity-stream.test.mjs +0 -512
  189. package/test/auto-recall.test.mjs +0 -474
  190. package/test/auto-truncate.test.mjs +0 -931
  191. package/test/autobind.test.mjs +0 -399
  192. package/test/bus.test.mjs +0 -1572
  193. package/test/cli-guard.test.mjs +0 -198
  194. package/test/conclusion.test.mjs +0 -408
  195. package/test/config-schema.test.mjs +0 -210
  196. package/test/d6-output.test.mjs +0 -194
  197. package/test/d6.test.mjs +0 -395
  198. package/test/deps.test.mjs +0 -342
  199. package/test/doctor-setup.test.mjs +0 -297
  200. package/test/embedding-recall.test.mjs +0 -347
  201. package/test/feature-flags.test.mjs +0 -585
  202. package/test/filter-shadow.test.mjs +0 -170
  203. package/test/fixtures/activity-append-child.mjs +0 -29
  204. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  205. package/test/fixtures/d6-header-tools.json +0 -1
  206. package/test/fixtures/lock-child.mjs +0 -67
  207. package/test/fixtures/pre-step-container.mjs +0 -100
  208. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  209. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  210. package/test/fixtures/profile-session-longlive-config.json +0 -20
  211. package/test/fixtures/proposal-cas-child.mjs +0 -66
  212. package/test/host-engine-status.test.mjs +0 -307
  213. package/test/hot-truncate.test.mjs +0 -1349
  214. package/test/identity-card-marker.test.mjs +0 -307
  215. package/test/identity-router/run.mjs +0 -721
  216. package/test/integration-pre-step.test.mjs +0 -553
  217. package/test/live.test.mjs +0 -1023
  218. package/test/maintenance-schedule.test.mjs +0 -538
  219. package/test/memory.test.mjs +0 -2828
  220. package/test/metrics.test.mjs +0 -478
  221. package/test/orchestrator.test.mjs +0 -266
  222. package/test/p1_probe.mjs +0 -97
  223. package/test/packaging.test.mjs +0 -184
  224. package/test/portability.test.mjs +0 -343
  225. package/test/rc4-regressions.test.mjs +0 -562
  226. package/test/rc5-regressions.test.mjs +0 -197
  227. package/test/rc6-chinese.test.mjs +0 -394
  228. package/test/router-1100/apply-off.test.mjs +0 -175
  229. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  230. package/test/router-1100/config-context.test.mjs +0 -211
  231. package/test/router-1100/helpers.mjs +0 -129
  232. package/test/router-1100/hint-envelope.test.mjs +0 -150
  233. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  234. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  235. package/test/router-1100/router-apply.test.mjs +0 -372
  236. package/test/router-1100/router-fixb.test.mjs +0 -602
  237. package/test/router-1100/router-proposals.test.mjs +0 -405
  238. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  239. package/test/router-1100/router-security-cr.test.mjs +0 -333
  240. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  241. package/test/router-1100/tools-contract.test.mjs +0 -164
  242. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  243. package/test/router-1100/v6-contract.test.mjs +0 -158
  244. package/test/router-1100/v6b-contract.test.mjs +0 -293
  245. package/test/router-1100/v6c-contract.test.mjs +0 -249
  246. package/test/router-1100/wal-rotation.test.mjs +0 -375
  247. package/test/router-1100/wp1-config.test.mjs +0 -170
  248. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  249. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  250. package/test/router-1100/wp1-status.test.mjs +0 -220
  251. package/test/router-1100/wp1-tools.test.mjs +0 -146
  252. package/test/router-1100/wp5-cli.test.mjs +0 -240
  253. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  254. package/test/router-1100/wp6-merge.test.mjs +0 -386
  255. package/test/router-1100/wp6-wake.test.mjs +0 -502
  256. package/test/router-background/audit.test.mjs +0 -37
  257. package/test/router-background/budget-ledger.test.mjs +0 -330
  258. package/test/router-background/cross-process.test.mjs +0 -222
  259. package/test/router-background/fixtures/cross-child.mjs +0 -85
  260. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  261. package/test/router-background/router.test.mjs +0 -1076
  262. package/test/router-background/stream-retry.test.mjs +0 -358
  263. package/test/router-background/tool-agent.test.mjs +0 -384
  264. package/test/router-face/bridge.test.mjs +0 -169
  265. package/test/router-face/face.test.mjs +0 -316
  266. package/test/router-failclosed.test.mjs +0 -466
  267. package/test/router-identity.test.mjs +0 -384
  268. package/test/router-l1-l2.contract.test.mjs +0 -1075
  269. package/test/router-memory-tools.test.mjs +0 -262
  270. package/test/router-policy.test.mjs +0 -225
  271. package/test/router-runtime.test.mjs +0 -438
  272. package/test/router-shadow.test.mjs +0 -287
  273. package/test/router.test.mjs +0 -903
  274. package/test/run-rc.mjs +0 -315
  275. package/test/run.mjs +0 -2469
  276. package/test/safety-defaults.test.mjs +0 -181
  277. package/test/seeded-load.test.mjs +0 -436
  278. package/test/session-original.test.mjs +0 -338
  279. package/test/state-truth.test.mjs +0 -937
  280. package/test/surgery-recovery.test.mjs +0 -936
  281. package/test/t7_drive.mjs +0 -151
  282. package/test/tool-output.test.mjs +0 -393
  283. package/test/tool-schema.test.mjs +0 -177
  284. package/test/tools_contract.test.mjs +0 -716
  285. package/test/truncate-visible.test.mjs +0 -1150
  286. package/test/ui-flags.test.mjs +0 -477
  287. package/test/v4-hardening.test.mjs +0 -427
  288. package/test/version-consistency.test.mjs +0 -286
package/test/t7_drive.mjs DELETED
@@ -1,151 +0,0 @@
1
- // T7 driver: boot the real host (sdk profile) against an isolated DSH_HOME,
2
- // resume a persisted session, send one prompt and wait for the assistant reply.
3
- // Usage: T7_DSH_HOME=... T7_SESSION_FILE=... node test/t7_drive.mjs <sessionId> [prompt]
4
- import { spawn } from 'node:child_process';
5
- import fs from 'node:fs';
6
- import { createHash } from 'node:crypto';
7
- import { parseSession } from '../src/codec.js';
8
-
9
- // No install path is hard-coded: a machine that does not opt in gets a clean
10
- // skip (exit 2) instead of a driver that happens to work only on one drive.
11
- const EXE = process.env.DSH_EXE;
12
- const CLI = process.env.DSH_CLI_JS;
13
- if (!EXE || !CLI) {
14
- console.error(JSON.stringify({
15
- ok: false,
16
- skipped: true,
17
- error: 'DSH_EXE and DSH_CLI_JS are required; no default install path is hard-coded',
18
- }));
19
- process.exit(2);
20
- }
21
- const HOME = process.env.T7_DSH_HOME;
22
- if (!HOME || !process.env.T7_SESSION_FILE) {
23
- console.error(JSON.stringify({ ok: false, error: 'T7_DSH_HOME and T7_SESSION_FILE are required' }));
24
- process.exit(2);
25
- }
26
- const SESSION = process.argv[2];
27
- const PROMPT = process.argv[3] ?? '只回两个字:收到';
28
- const CWD = process.env.T7_CWD ?? process.cwd();
29
- const SESSION_FILE = process.env.T7_SESSION_FILE;
30
- const WAIT_MS = Number(process.env.T7_WAIT_MS ?? 120000);
31
- const POLL_MS = 500;
32
-
33
- const sha = (f) => createHash('sha256').update(fs.readFileSync(f)).digest('hex');
34
- const readEvents = () => parseSession(fs.readFileSync(SESSION_FILE)).events;
35
- const before = { bytes: fs.statSync(SESSION_FILE).size, sha256: sha(SESSION_FILE), events: readEvents().length };
36
-
37
- const child = spawn(EXE, ['--expose-internals', CLI, 'sdk'], {
38
- env: { ...process.env, DSH_HOME: HOME, ELECTRON_RUN_AS_NODE: '1' },
39
- stdio: ['pipe', 'pipe', 'pipe'],
40
- });
41
-
42
- let buffer = '';
43
- const pending = new Map();
44
- let stderr = '';
45
- child.stderr.on('data', (d) => { stderr += d.toString(); });
46
- child.stdout.on('data', (chunk) => {
47
- buffer += chunk.toString();
48
- let nl;
49
- while ((nl = buffer.indexOf('\n')) >= 0) {
50
- const line = buffer.slice(0, nl).trim();
51
- buffer = buffer.slice(nl + 1);
52
- if (line) onLine(line);
53
- }
54
- });
55
-
56
- function onLine(line) {
57
- let msg;
58
- try { msg = JSON.parse(line); } catch { return; }
59
- if (msg.id && !msg.method) {
60
- const waiter = pending.get(msg.id);
61
- if (!waiter) return;
62
- pending.delete(msg.id);
63
- if (msg.error) waiter.reject(new Error(msg.error.message ?? JSON.stringify(msg.error)));
64
- else waiter.resolve(msg.result);
65
- }
66
- // notifications (session/event etc.) are observed through the session file
67
- }
68
-
69
- let nextId = 0;
70
- function request(method, params) {
71
- const id = `t7_${nextId += 1}`;
72
- return new Promise((resolve, reject) => {
73
- pending.set(id, { resolve, reject });
74
- child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
75
- });
76
- }
77
-
78
- function assistantTexts(events, from) {
79
- const out = [];
80
- for (const e of events.slice(from)) {
81
- if (e.type !== 'assistant/message') continue;
82
- const content = e.data?.message?.content;
83
- const text = Array.isArray(content)
84
- ? content.filter((x) => x.type === 'text').map((x) => x.text).join('')
85
- : '';
86
- if (text.trim()) out.push(text.trim());
87
- }
88
- return out;
89
- }
90
-
91
- function seqContiguous(events) {
92
- for (let i = 0; i < events.length; i += 1) if (events[i].seq !== i) return false;
93
- return true;
94
- }
95
-
96
- const driverTimeout = setTimeout(() => {
97
- console.error(JSON.stringify({ ok: false, error: 'driver timeout', stderrTail: stderr.slice(-800) }));
98
- child.kill();
99
- process.exit(1);
100
- }, 240000);
101
-
102
- try {
103
- await request('initialize', { cwd: CWD, provider: 'deepseek-official', model: 'deepseek-chat' });
104
- await request('session/resume', { sessionId: SESSION });
105
- await request('session/prompt', { sessionId: SESSION, contentBlocks: [{ type: 'text', text: PROMPT }] });
106
-
107
- const deadline = Date.now() + WAIT_MS;
108
- let events = readEvents();
109
- let texts = assistantTexts(events, before.events);
110
- while (Date.now() < deadline && texts.length === 0) {
111
- const turnEnded = events.slice(before.events).some((e) => e.type === 'turn/end');
112
- if (turnEnded) break;
113
- await new Promise((r) => setTimeout(r, POLL_MS));
114
- events = readEvents();
115
- texts = assistantTexts(events, before.events);
116
- }
117
-
118
- const after = { bytes: fs.statSync(SESSION_FILE).size, sha256: sha(SESSION_FILE), events: events.length };
119
- const result = {
120
- ok: texts.length > 0,
121
- assistantTexts: texts.slice(-3),
122
- seqContiguous: seqContiguous(events),
123
- eventsBefore: before.events,
124
- eventsAfter: after.events,
125
- bytesBefore: before.bytes,
126
- bytesAfter: after.bytes,
127
- sha256Before: before.sha256,
128
- sha256After: after.sha256,
129
- lastTypes: events.slice(-3).map((e) => e.type),
130
- stderrTail: stderr.slice(-300),
131
- };
132
-
133
- try {
134
- await Promise.race([request('shutdown', {}), new Promise((r) => setTimeout(r, 3000))]);
135
- } catch { /* ignore shutdown errors */ }
136
- clearTimeout(driverTimeout);
137
- child.stdin.end();
138
- await new Promise((resolve) => {
139
- if (child.exitCode !== null) return resolve();
140
- child.once('exit', resolve);
141
- setTimeout(resolve, 5000);
142
- });
143
- result.exitCode = child.exitCode;
144
- console.log(JSON.stringify(result, null, 2));
145
- process.exit(result.ok && result.seqContiguous && child.exitCode === 0 ? 0 : 3);
146
- } catch (error) {
147
- clearTimeout(driverTimeout);
148
- console.log(JSON.stringify({ ok: false, error: String(error.message ?? error), stderrTail: stderr.slice(-1500) }, null, 2));
149
- child.kill();
150
- process.exit(2);
151
- }
@@ -1,393 +0,0 @@
1
- // D2 — trim oversized tool results at `tools/post-execute`.
2
- //
3
- // Every case runs against a fake host: a fake `exec`, a fake tool `result`, a
4
- // fake waterfall `next()` and an injected archive writer where the failure
5
- // path matters. No host package is imported; the seam is a plain async
6
- // function, which is what the Cordis waterfall calls.
7
- //
8
- // Acceptance cases (the five D2 asks):
9
- // - a small output is untouched;
10
- // - an oversized output is replaced by head + tail + one notice line;
11
- // - the archived file is byte-identical to the original text;
12
- // - a privacy hit writes nothing and leaves a placeholder;
13
- // - a write failure keeps the original content (fail-open).
14
- //
15
- // Extras that make the seam safe: default off, skip read/error/PTC children,
16
- // a `value` decision is never rewritten, the turn comes from the agents
17
- // projection or the session log, and the 70 KB acceptance probe holds the
18
- // context delta under 5 K while the path still reads the full text back.
19
-
20
- import fs from 'node:fs';
21
- import os from 'node:os';
22
- import path from 'node:path';
23
- import assert from 'node:assert/strict';
24
- import test, { after } from 'node:test';
25
-
26
- import {
27
- D2_DEFAULTS,
28
- createTrimHandler,
29
- currentTurnOf,
30
- decideToolOutputTrim,
31
- formatD2Notice,
32
- normalizeD2Config,
33
- registerToolOutputTrim,
34
- replaceTextParts,
35
- splitHeadTail,
36
- toolOutputDir,
37
- toolOutputRoot,
38
- } from '../src/tool-output.js';
39
- import { archiveFileName } from '../src/original.js';
40
-
41
- const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'longlive-d2-'));
42
-
43
- after(() => {
44
- fs.rmSync(TMP, { recursive: true, force: true });
45
- });
46
-
47
- function makeRoot(label) {
48
- const root = path.join(TMP, label);
49
- fs.mkdirSync(root, { recursive: true });
50
- return root;
51
- }
52
-
53
- /** A fake tool execution: only the fields D2 reads, no host types. */
54
- function makeExec({ name = 'demo_tool', callId = 'call-1', turn = 3, log = null } = {}) {
55
- const events = log ?? [
56
- { type: 'turn/start', data: { turn } },
57
- { type: 'user/message', data: {} },
58
- ];
59
- return {
60
- name,
61
- callId,
62
- agent: {
63
- session: {
64
- seq: events.length,
65
- eventAt: (seq) => events[seq],
66
- },
67
- },
68
- };
69
- }
70
-
71
- function makeResult(text, { isError = false, extra = [] } = {}) {
72
- return { content: [{ type: 'text', text }, ...extra], isError };
73
- }
74
-
75
- /** Run the real handler; the turn is resolved from the fake session log. */
76
- function handlerFor(options = {}) {
77
- return createTrimHandler({
78
- settings: normalizeD2Config({ enabled: true, ...(options.settings ?? {}) }),
79
- memoryRoot: options.memoryRoot,
80
- currentTurn: options.currentTurn ?? ((exec) => currentTurnOf(null, exec)),
81
- ...(options.overrides ?? {}),
82
- });
83
- }
84
-
85
- function call(handler, exec, result) {
86
- return handler(exec, result, async () => ({ kind: 'accept' }));
87
- }
88
-
89
- test('D2 defaults: disabled, 4096 chars, read skipped, errors kept whole', () => {
90
- const settings = normalizeD2Config(undefined);
91
- assert.equal(settings.enabled, false);
92
- assert.equal(settings.maxInlineChars, D2_DEFAULTS.maxInlineChars);
93
- assert.deepEqual(settings.skipTools, ['read']);
94
- assert.equal(settings.trimErrors, false);
95
-
96
- // A typo must not create a nonsense budget.
97
- const clamped = normalizeD2Config({ enabled: true, maxInlineChars: 5, headRatio: 9 });
98
- assert.equal(clamped.maxInlineChars, 64);
99
- assert.equal(clamped.headRatio, 0.8);
100
- assert.equal(normalizeD2Config({ enabled: 'yes' }).enabled, false);
101
- });
102
-
103
- test('D2 off by default: registration mounts nothing', () => {
104
- const hooked = [];
105
- const ctx = {
106
- on: (event, handler) => { hooked.push({ event, handler }); return () => {}; },
107
- get: () => null,
108
- logger: { warn() {}, info() {} },
109
- };
110
- const status = registerToolOutputTrim(ctx, {}, { memoryRoot: makeRoot('off') });
111
- assert.equal(status.registered, false);
112
- assert.equal(hooked.length, 0);
113
- });
114
-
115
- test('D2 small output is untouched (identity, no archive)', async () => {
116
- const root = makeRoot('small');
117
- const decision = { kind: 'accept' };
118
- const handler = handlerFor({ memoryRoot: root });
119
- const out = await handler(makeExec({ turn: 1 }), makeResult('a short tool answer'), async () => decision);
120
- assert.equal(out, decision);
121
- assert.equal(fs.existsSync(path.join(root, '.longlive')), false);
122
-
123
- // Exactly at the budget: still inline, still the same decision object.
124
- const tight = await handlerFor({ memoryRoot: root, settings: { maxInlineChars: 64 } })(
125
- makeExec({ turn: 1 }),
126
- makeResult('x'.repeat(64)),
127
- async () => decision,
128
- );
129
- assert.equal(tight, decision);
130
- });
131
-
132
- test('D2 oversized output: head + tail + one notice line, archive written', async () => {
133
- const root = makeRoot('trim');
134
- const text = `HEAD-MARKER-${'A'.repeat(20000)}-TAIL-MARKER`;
135
- const out = await call(handlerFor({ memoryRoot: root }), makeExec({ turn: 3 }), makeResult(text));
136
-
137
- assert.equal(out.kind, 'accept');
138
- assert.notEqual(out, undefined);
139
- const inline = out.content[0].text;
140
- assert.ok(inline.length <= 4096 + 500, `inline ${inline.length} should stay near the budget`);
141
- assert.ok(inline.length < text.length, 'the inline text is much smaller than the original');
142
- assert.ok(inline.startsWith('HEAD-MARKER-'), 'head is kept first');
143
- assert.ok(inline.endsWith('-TAIL-MARKER'), 'tail is kept last');
144
- assert.match(inline, new RegExp(`\\[D2\\] tool output \\(demo_tool\\) trimmed: ${text.length} chars → \\d+ inline\\.`));
145
- assert.ok(inline.includes(`Full output (${Buffer.byteLength(text, 'utf8')} bytes) saved at `));
146
-
147
- const file = path.join(toolOutputDir(root, 3), 'call-1.txt');
148
- assert.ok(fs.existsSync(file), 'archive path is the D3 convention');
149
- assert.ok(inline.includes(file), 'the notice prints the path');
150
- assert.ok(inline.includes('session_original(turn=3, toolCallId="call-1")'));
151
- });
152
-
153
- test('D2 archive is byte-identical to the original text', async () => {
154
- const root = makeRoot('bytes');
155
- const text = '日本語テキスト\n'.repeat(4000) + '尾部';
156
- await call(handlerFor({ memoryRoot: root }), makeExec({ name: 'grep', callId: 'call-9', turn: 2 }), makeResult(text));
157
- const file = path.join(toolOutputDir(root, 2), archiveFileName({ kind: 'tool', toolCallId: 'call-9' }));
158
- assert.equal(fs.readFileSync(file, 'utf8'), text);
159
- assert.equal(fs.statSync(file).size, Buffer.byteLength(text, 'utf8'));
160
- });
161
-
162
- test('D2 privacy hit writes nothing and leaves a placeholder', async () => {
163
- const root = makeRoot('privacy');
164
- const text = 'customer record: phone 13800138000\n' + 'x'.repeat(9000);
165
- const out = await call(
166
- handlerFor({
167
- memoryRoot: root,
168
- overrides: { scan: () => ({ verdict: 'local_only', reasons: ['phone'], spans: [] }) },
169
- }),
170
- makeExec({ turn: 4 }),
171
- makeResult(text),
172
- );
173
- const inline = out.content[0].text;
174
- assert.match(inline, new RegExp(`withheld: ${text.length} chars matched the privacy gate \\(phone\\)`));
175
- assert.ok(!inline.includes('13800138000'), 'a flagged value is never echoed');
176
- assert.equal(fs.existsSync(path.join(root, '.longlive')), false, 'nothing is written');
177
- assert.ok(!inline.includes('.longlive'), 'no path is advertised');
178
- });
179
-
180
- test('D2 write failure is fail-open: original decision survives', async () => {
181
- const root = makeRoot('eacces');
182
- const text = 'y'.repeat(9000);
183
- const decision = { kind: 'accept' };
184
- const handler = handlerFor({
185
- memoryRoot: root,
186
- overrides: {
187
- writeArchive: () => {
188
- const error = new Error('EACCES: permission denied, open archive');
189
- error.code = 'EACCES';
190
- throw error;
191
- },
192
- },
193
- });
194
- const out = await handler(makeExec({ turn: 5 }), makeResult(text), async () => decision);
195
- assert.equal(out, decision, 'the untouched decision is returned');
196
- assert.equal(fs.readdirSync(root).filter((name) => name.includes('tmp-')).length, 0, 'no tmp file is left behind');
197
- });
198
-
199
- test('D2 next() failure propagates untouched', async () => {
200
- const handler = handlerFor({ memoryRoot: makeRoot('next-fail') });
201
- const boom = new Error('below-seam failure');
202
- await assert.rejects(
203
- handler(makeExec({ turn: 1 }), makeResult('z'.repeat(9000)), async () => { throw boom; }),
204
- boom,
205
- );
206
- });
207
-
208
- test('D2 skips read, errors, PTC children and value decisions', async () => {
209
- const root = makeRoot('skips');
210
- const big = () => makeResult('q'.repeat(9000));
211
- const cases = [
212
- ['read', makeExec({ name: 'read', turn: 1 }), big(), { kind: 'accept' }],
213
- ['error result', makeExec({ name: 'demo', turn: 1 }), makeResult('e'.repeat(9000), { isError: true }), { kind: 'accept' }],
214
- ['ptc child', { ...makeExec({ turn: 1 }), parent: {} }, big(), { kind: 'accept' }],
215
- ['value decision', makeExec({ name: 'demo', turn: 1 }), big(), { kind: 'accept', value: { ok: true } }],
216
- ['blocked decision', makeExec({ name: 'demo', turn: 1 }), big(), { kind: 'block', feedback: 'nope' }],
217
- ];
218
- for (const [label, exec, result, decision] of cases) {
219
- const out = await handlerFor({ memoryRoot: root })(exec, result, async () => decision);
220
- assert.equal(out, decision, `${label} must be untouched`);
221
- }
222
- assert.equal(fs.existsSync(path.join(root, '.longlive')), false);
223
-
224
- // trimErrors is the documented opt-out from the error exemption.
225
- const out = await call(
226
- handlerFor({ memoryRoot: root, settings: { skipTools: [], trimErrors: true, maxInlineChars: 64 } }),
227
- makeExec({ name: 'demo', turn: 1 }),
228
- makeResult('e'.repeat(5000), { isError: true }),
229
- );
230
- assert.match(out.content[0].text, /\[D2\] tool output/);
231
- });
232
-
233
- test('D2 turn resolution: agents projection first, session log as fallback, null keeps content', () => {
234
- const events = [
235
- { type: 'turn/start', data: { turn: 7 } },
236
- { type: 'tool/result', data: {} },
237
- { type: 'turn/start', data: { turn: 8 } },
238
- ];
239
- const exec = makeExec({ log: events });
240
-
241
- // 1. the agents service's turnBoundary projection wins;
242
- const projectionCtx = { get: (name) => (name === 'agents' ? { turnBoundaryOf: () => ({ lastTurn: 42 }) } : null) };
243
- assert.equal(currentTurnOf(projectionCtx, exec), 42);
244
-
245
- // 2. without it, the newest turn/start in the session log answers;
246
- assert.equal(currentTurnOf(null, exec), 8);
247
-
248
- // 3. a session with no turn/start yields null, and null keeps the content inline.
249
- const orphan = makeExec({ log: [{ type: 'user/message', data: {} }] });
250
- assert.equal(currentTurnOf(null, orphan), null);
251
- const decision = { kind: 'accept' };
252
- const outcome = decideToolOutputTrim({
253
- exec: orphan,
254
- result: makeResult('w'.repeat(9000)),
255
- decision,
256
- settings: normalizeD2Config({ enabled: true }),
257
- memoryRoot: makeRoot('turn'),
258
- turn: currentTurnOf(null, orphan),
259
- });
260
- assert.equal(outcome.action, 'keep');
261
- assert.equal(outcome.decision, decision);
262
- });
263
-
264
- test('D2 keeps non-text parts and never splits a surrogate pair', async () => {
265
- const root = makeRoot('parts');
266
- const image = { type: 'image', mediaType: 'image/png', data: 'AAAA' };
267
- const out = await call(
268
- handlerFor({ memoryRoot: root }),
269
- makeExec({ name: 'screenshot', callId: 'call-img', turn: 6 }),
270
- makeResult('s'.repeat(9000), { extra: [image] }),
271
- );
272
- assert.equal(out.content.length, 2);
273
- assert.equal(out.content[1], image);
274
- assert.match(out.content[0].text, /\[D2\]/);
275
-
276
- const split = splitHeadTail('😀'.repeat(5000), 100, 0.5);
277
- assert.equal(split.trimmed, true);
278
- assert.ok(split.omittedChars > 0);
279
- assert.doesNotThrow(() => Buffer.from(split.head + split.tail, 'utf8'));
280
- assert.ok(!/[\uD800-\uDBFF]$/.test(split.head), 'head does not end on a lone high surrogate');
281
- assert.ok(!/^[\uDC00-\uDFFF]/.test(split.tail), 'tail does not start on a lone low surrogate');
282
- });
283
-
284
- test('D2 acceptance probe: 70 KB JSON output costs ≤5 K inline and reads back whole', async () => {
285
- const root = makeRoot('probe-70k');
286
- const payload = JSON.stringify({
287
- items: Array.from({ length: 1800 }, (_, index) => ({ id: index, name: `row-${index}`, tags: ['alpha', 'beta'] })),
288
- });
289
- assert.ok(payload.length >= 70000, `probe payload is ${payload.length} chars`);
290
-
291
- const decision = { kind: 'accept' };
292
- const out = await handlerFor({ memoryRoot: root })(
293
- makeExec({ name: 'query_db', callId: 'call-70k', turn: 11 }),
294
- makeResult(payload),
295
- async () => decision,
296
- );
297
-
298
- const inline = out.content[0].text;
299
- assert.ok(inline.length <= 5120, `context delta ${inline.length} must be ≤ 5120 chars`);
300
- assert.ok(inline.length < payload.length / 10, 'the context grows by an order of magnitude less');
301
-
302
- const file = path.join(toolOutputDir(root, 11), 'call-70k.txt');
303
- assert.equal(fs.readFileSync(file, 'utf8'), payload, 'the whole 70 KB reads back from the path');
304
- assert.ok(inline.includes(file));
305
- });
306
-
307
- test('D2 registration wires the seam and disposes with the plugin scope', () => {
308
- const hooked = [];
309
- const disposed = [];
310
- const ctx = {
311
- on: (event, handler) => {
312
- hooked.push({ event, handler });
313
- return () => disposed.push(`dispose:${event}`);
314
- },
315
- get: () => null,
316
- effect: (factory) => {
317
- const disposer = factory();
318
- disposed.push(`effect:${typeof disposer}`);
319
- },
320
- logger: { warn() {}, info() {} },
321
- };
322
- const root = makeRoot('register');
323
- const status = registerToolOutputTrim(ctx, { d2: { enabled: true, maxInlineChars: 512 } }, { memoryRoot: root });
324
- assert.equal(status.registered, true);
325
- assert.equal(status.settings.maxInlineChars, 512);
326
- assert.equal(hooked.length, 1);
327
- assert.equal(hooked[0].event, 'tools/post-execute');
328
- assert.equal(status.archiveRoot, toolOutputRoot(root));
329
- assert.deepEqual(disposed, ['effect:function']);
330
- });
331
-
332
- test('D2 applies through the plugin entry: d2.enabled mounts on tools/post-execute', async () => {
333
- const home = makeRoot('apply-home');
334
- const memoryRoot = path.join(home, 'memory');
335
- fs.mkdirSync(memoryRoot, { recursive: true });
336
- const hooked = [];
337
- const ctx = {
338
- tools: { register: () => () => {} },
339
- on: (event, handler) => {
340
- hooked.push({ event, handler });
341
- return () => {};
342
- },
343
- get: () => null,
344
- effect: () => {},
345
- logger: { warn() {}, info() {} },
346
- };
347
- const { apply } = await import('../src/index.js');
348
- await apply(ctx, {
349
- home,
350
- memoryRoot,
351
- env: {},
352
- heartbeatMs: 0,
353
- recoverOnStart: false,
354
- defineTool: (definition) => definition,
355
- routerRuntime: 'off',
356
- d2: { enabled: true, maxInlineChars: 256 },
357
- });
358
- const seam = hooked.find((entry) => entry.event === 'tools/post-execute');
359
- assert.ok(seam, 'd2.enabled must register a tools/post-execute listener');
360
-
361
- const events = [{ type: 'turn/start', data: { turn: 4 } }];
362
- const exec = {
363
- name: 'query_db',
364
- callId: 'call-apply',
365
- agent: { session: { seq: 1, eventAt: (seq) => events[seq] } },
366
- };
367
- const payload = 'P'.repeat(9000);
368
- const out = await seam.handler(exec, makeResult(payload), async () => ({ kind: 'accept' }));
369
- const inline = out.content[0].text;
370
- assert.ok(inline.length <= 256 + 500, `inline ${inline.length} near the configured budget`);
371
- assert.ok(fs.existsSync(path.join(toolOutputDir(memoryRoot, 4), 'call-apply.txt')));
372
- assert.equal(fs.readFileSync(path.join(toolOutputDir(memoryRoot, 4), 'call-apply.txt'), 'utf8'), payload);
373
- });
374
-
375
- test('D2 notice and text replacement keep the no-call-id shape readable', () => {
376
- const notice = formatD2Notice({
377
- chars: 9000,
378
- inlineChars: 4096,
379
- bytes: 9000,
380
- file: 'C:\\m\\.longlive\\tool-output\\2\\c.txt',
381
- turn: null,
382
- callId: '',
383
- toolName: 'demo',
384
- });
385
- assert.ok(notice.startsWith('[D2] tool output (demo) trimmed: 9000 chars → 4096 inline.'));
386
- assert.ok(!notice.includes('session_original'), 'no call id, no read-back hint');
387
-
388
- const content = replaceTextParts(
389
- [{ type: 'text', text: 'a' }, { type: 'image' }, { type: 'text', text: 'b' }],
390
- 'merged',
391
- );
392
- assert.deepEqual(content, [{ type: 'text', text: 'merged' }, { type: 'image' }]);
393
- });