mixdog 0.9.18 → 0.9.20

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 (214) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -29
  3. package/package.json +5 -3
  4. package/scripts/build-runtime-windows.ps1 +242 -242
  5. package/scripts/build-tui.mjs +6 -0
  6. package/scripts/hook-bus-test.mjs +8 -0
  7. package/scripts/log-writer-guard-smoke.mjs +131 -0
  8. package/scripts/output-style-smoke.mjs +2 -2
  9. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  10. package/scripts/recall-bench-cases.json +10 -0
  11. package/scripts/recall-bench.mjs +91 -2
  12. package/scripts/recall-quality-cases.json +1 -2
  13. package/scripts/recall-usecase-cases.json +1 -1
  14. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  15. package/scripts/smoke-runtime-negative.ps1 +106 -106
  16. package/scripts/tool-efficiency-diag.mjs +5 -2
  17. package/scripts/tool-smoke.mjs +251 -72
  18. package/src/agents/debugger/AGENT.md +3 -3
  19. package/src/agents/heavy-worker/AGENT.md +7 -10
  20. package/src/agents/maintainer/AGENT.md +1 -2
  21. package/src/agents/reviewer/AGENT.md +1 -2
  22. package/src/agents/worker/AGENT.md +5 -8
  23. package/src/defaults/agents.json +3 -0
  24. package/src/help.mjs +2 -5
  25. package/src/lib/mixdog-debug.cjs +13 -0
  26. package/src/mixdog-session-runtime.mjs +7 -3311
  27. package/src/rules/agent/00-core.md +4 -3
  28. package/src/rules/agent/30-explorer.md +53 -22
  29. package/src/rules/lead/02-channels.md +3 -3
  30. package/src/rules/lead/lead-tool.md +3 -2
  31. package/src/rules/shared/01-tool.md +24 -29
  32. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  36. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  37. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  38. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  39. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  42. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  43. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  44. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  45. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  46. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  47. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  48. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  49. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  50. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  51. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  52. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  53. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  54. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  55. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  57. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  58. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  59. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  60. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  61. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  62. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  63. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  64. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  66. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  67. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  68. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  72. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  74. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  76. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  77. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  78. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  80. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  82. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  83. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  84. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  85. package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
  86. package/src/runtime/channels/backends/discord.mjs +43 -1
  87. package/src/runtime/channels/index.mjs +6 -2096
  88. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  89. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  90. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  91. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  92. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  93. package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
  94. package/src/runtime/channels/lib/owned-runtime.mjs +547 -0
  95. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  96. package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
  97. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  98. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  99. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  100. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  101. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  102. package/src/runtime/channels/lib/worker-main.mjs +771 -0
  103. package/src/runtime/memory/index.mjs +73 -1725
  104. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  105. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  106. package/src/runtime/memory/lib/http-router.mjs +772 -0
  107. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  108. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  109. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  110. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  111. package/src/runtime/memory/lib/query-handlers.mjs +38 -6
  112. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  113. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  114. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  115. package/src/runtime/shared/atomic-file.mjs +10 -4
  116. package/src/runtime/shared/background-tasks.mjs +4 -2
  117. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  118. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  119. package/src/runtime/shared/tool-surface.mjs +30 -1
  120. package/src/session-runtime/boot-profile.mjs +36 -0
  121. package/src/session-runtime/channel-config-api.mjs +70 -0
  122. package/src/session-runtime/config-lifecycle.mjs +1 -1
  123. package/src/session-runtime/context-status.mjs +181 -0
  124. package/src/session-runtime/cwd-plugins.mjs +46 -3
  125. package/src/session-runtime/env.mjs +17 -0
  126. package/src/session-runtime/lifecycle-api.mjs +242 -0
  127. package/src/session-runtime/mcp-glue.mjs +24 -3
  128. package/src/session-runtime/model-route-api.mjs +198 -0
  129. package/src/session-runtime/output-styles.mjs +44 -10
  130. package/src/session-runtime/provider-auth-api.mjs +135 -0
  131. package/src/session-runtime/resource-api.mjs +282 -0
  132. package/src/session-runtime/runtime-core.mjs +2046 -0
  133. package/src/session-runtime/session-turn-api.mjs +274 -0
  134. package/src/session-runtime/tool-catalog.mjs +18 -264
  135. package/src/session-runtime/tool-defs.mjs +2 -2
  136. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  137. package/src/session-runtime/workflow.mjs +16 -1
  138. package/src/standalone/agent-tool.mjs +2 -2
  139. package/src/standalone/channel-worker.mjs +78 -5
  140. package/src/standalone/explore-tool.mjs +1 -1
  141. package/src/standalone/memory-runtime-proxy.mjs +56 -6
  142. package/src/tui/App.jsx +88 -97
  143. package/src/tui/app/channel-pickers.mjs +45 -0
  144. package/src/tui/app/core-memory-picker.mjs +1 -1
  145. package/src/tui/app/slash-commands.mjs +0 -1
  146. package/src/tui/app/slash-dispatch.mjs +0 -16
  147. package/src/tui/app/transcript-window.mjs +44 -1
  148. package/src/tui/app/use-mouse-input.mjs +15 -2
  149. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  150. package/src/tui/app/use-transcript-scroll.mjs +82 -4
  151. package/src/tui/app/use-transcript-window.mjs +65 -5
  152. package/src/tui/components/PromptInput.jsx +33 -64
  153. package/src/tui/components/ToolExecution.jsx +2 -2
  154. package/src/tui/dist/index.mjs +7908 -7558
  155. package/src/tui/engine/context-state.mjs +145 -0
  156. package/src/tui/engine/prompt-history.mjs +27 -0
  157. package/src/tui/engine/session-api-ext.mjs +478 -0
  158. package/src/tui/engine/session-api.mjs +545 -0
  159. package/src/tui/engine/session-flow.mjs +485 -0
  160. package/src/tui/engine/turn.mjs +1078 -0
  161. package/src/tui/engine.mjs +69 -2582
  162. package/src/tui/index.jsx +7 -0
  163. package/src/tui/lib/voice-setup.mjs +166 -0
  164. package/src/tui/paste-attachments.mjs +12 -5
  165. package/src/tui/prompt-history-store.mjs +125 -12
  166. package/vendor/ink/build/ink.js +16 -1
  167. package/vendor/ink/build/output.js +30 -4
  168. package/vendor/ink/build/render.js +5 -0
  169. package/scripts/bench/cache-probe-tasks.json +0 -8
  170. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  171. package/scripts/bench/lead-review-tasks.json +0 -20
  172. package/scripts/bench/r4-mixed-tasks.json +0 -20
  173. package/scripts/bench/r5-orchestrated-task.json +0 -7
  174. package/scripts/bench/review-tasks.json +0 -20
  175. package/scripts/bench/round-codex.json +0 -114
  176. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  177. package/scripts/bench/round-mixdog-lead.json +0 -269
  178. package/scripts/bench/round-mixdog.json +0 -126
  179. package/scripts/bench/round-r10-bigsample.json +0 -679
  180. package/scripts/bench/round-r11-codexalign.json +0 -257
  181. package/scripts/bench/round-r13-clientmeta.json +0 -464
  182. package/scripts/bench/round-r14-betafeatures.json +0 -466
  183. package/scripts/bench/round-r15-fulldefault.json +0 -462
  184. package/scripts/bench/round-r16-sessionid.json +0 -466
  185. package/scripts/bench/round-r17-wirebytes.json +0 -456
  186. package/scripts/bench/round-r18-prewarm.json +0 -468
  187. package/scripts/bench/round-r19-clean.json +0 -472
  188. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  189. package/scripts/bench/round-r21-delta-retry.json +0 -473
  190. package/scripts/bench/round-r22-full-probe.json +0 -693
  191. package/scripts/bench/round-r23-itemprobe.json +0 -701
  192. package/scripts/bench/round-r24-shapefix.json +0 -677
  193. package/scripts/bench/round-r25-serial.json +0 -464
  194. package/scripts/bench/round-r26-parallel3.json +0 -671
  195. package/scripts/bench/round-r27-parallel10.json +0 -894
  196. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  197. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  198. package/scripts/bench/round-r30-instid.json +0 -253
  199. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  200. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  201. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  202. package/scripts/bench/round-r34-orchestrated.json +0 -120
  203. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  204. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  205. package/scripts/bench/round-r4-codex.json +0 -114
  206. package/scripts/bench/round-r4-mixed.json +0 -225
  207. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  208. package/scripts/bench/round-r6-codex.json +0 -114
  209. package/scripts/bench/round-r6-solo.json +0 -257
  210. package/scripts/bench/round-r7-full.json +0 -254
  211. package/scripts/bench/round-r8-fulldefault.json +0 -255
  212. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  213. package/src/tui/lib/voice-recorder.mjs +0 -469
  214. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -97,7 +97,7 @@ try {
97
97
 
98
98
  mkdirSync(join(dataDir, 'output-styles'), { recursive: true });
99
99
  writeFileSync(join(dataDir, 'mixdog-config.json'), JSON.stringify({
100
- agent: { profile: { title: '재영님', language: 'system' } },
100
+ agent: { profile: { title: '홍길동님', language: 'system' } },
101
101
  outputStyle: 'custom-smoke',
102
102
  }));
103
103
  writeFileSync(join(dataDir, 'output-styles', 'custom-smoke.md'), '---\nname: custom-smoke\n---\n\n# Custom Output Style\n\ncustom smoke style\n');
@@ -105,7 +105,7 @@ try {
105
105
  assert(customRules.includes('# Custom Output Style'), 'configured outputStyle must select custom style');
106
106
  assert(!customRules.includes('Mixdog default — the most detailed of the three styles'), 'custom outputStyle should not append default style');
107
107
  const profileMeta = rulesBuilder.buildLeadMetaContent({ PLUGIN_ROOT: join(root, 'src'), DATA_DIR: dataDir });
108
- assert(profileMeta.includes('Use "재영님" when directly addressing the user'), 'profile title must inject into Lead BP3 meta');
108
+ assert(profileMeta.includes('Use "홍길동님" when directly addressing the user'), 'profile title must inject into Lead BP3 meta');
109
109
  assert(profileMeta.includes('do not repeat it in routine progress updates or pre-tool preambles'), 'profile title must not encourage title in preambles');
110
110
  assert(/Default user-facing response language from system locale/.test(profileMeta), 'system profile language must resolve from system locale');
111
111
  assert(profileMeta.includes('pre-tool preambles (even single-line)'), 'profile language must cover pre-tool preambles');
@@ -102,23 +102,39 @@ try {
102
102
  thrownCode = err?.code || null;
103
103
  }
104
104
 
105
+ // Non-agent sessions hard-lock to recall-fasttrack (7/3 commit). This smoke has
106
+ // no memory subsystem registered, so ingest_session + search both fail — the
107
+ // exact "memory pipeline broken" condition the fail-safe must cover. Assert the
108
+ // fail-safe: recall-fasttrack aborts rather than dropping head behind a false
109
+ // "Full history is in memory" notice, so no context is silently lost and the
110
+ // overflow is surfaced instead of being masked by an empty summary shell.
105
111
  assert(threw, 'askSession should surface overflow error');
106
112
  assert(thrownCode === 'AGENT_CONTEXT_OVERFLOW', `expected AGENT_CONTEXT_OVERFLOW, got ${thrownCode}`);
107
- assert(mainSendCount === 2, `expected 2 main sends after reactive retry, got ${mainSendCount}`);
108
- assert(compactSendCount === 1, `expected 1 compact send, got ${compactSendCount}`);
113
+ // Recall-fasttrack aborted (memory failed), so the reactive retry never produced
114
+ // a smaller transcript to re-send: exactly one failing main send, no LLM compact.
115
+ assert(mainSendCount === 1, `expected 1 main send (reactive retry aborted by fail-safe), got ${mainSendCount}`);
116
+ assert(compactSendCount === 0, `expected 0 compact sends on recall-fasttrack path, got ${compactSendCount}`);
109
117
 
110
118
  const reloaded = loadSession(sessionId);
111
119
  assert(reloaded, 'session should reload from store after overflow');
112
- assert((reloaded.messages || []).length < beforeCount + 1, 'persisted transcript should reflect compaction shrink');
120
+ // Fail-safe keeps full history for the cycle: current turn appended, nothing dropped.
121
+ assert(
122
+ (reloaded.messages || []).length === beforeCount + 1,
123
+ `full history should be preserved on fail-safe abort (expected ${beforeCount + 1}, got ${(reloaded.messages || []).length})`,
124
+ );
113
125
  const summary = (reloaded.messages || []).find(
114
126
  (m) => m?.role === 'user' && typeof m.content === 'string' && m.content.startsWith(SUMMARY_PREFIX),
115
127
  );
116
- assert(summary, 'compacted transcript with SUMMARY_PREFIX should persist after overflow error');
128
+ assert(!summary, 'no empty summary shell should be injected when the memory pipeline fails');
129
+ assert(
130
+ !(reloaded.messages || []).some((m) => typeof m?.content === 'string' && m.content.includes('Full history is in memory')),
131
+ 'no false "Full history is in memory" recall notice should be injected on fail-safe abort',
132
+ );
117
133
  assert(
118
134
  (reloaded.messages || []).some((m) => m?.role === 'user' && String(m.content).includes('current overflow task must stay verbatim')),
119
135
  'current user turn should remain in persisted transcript',
120
136
  );
121
- assert(reloaded.compaction?.lastStage !== 'compacting', 'compaction lastStage should not remain stuck on compacting');
122
- assert(reloaded.providerState === undefined, 'providerState should clear after reactive compact persist');
137
+ assert(reloaded.compaction?.lastStage === 'overflow_failed', `compaction lastStage should be overflow_failed, got ${reloaded.compaction?.lastStage}`);
138
+ assert(reloaded.providerState === undefined, 'providerState should clear after reactive compact abort');
123
139
 
124
140
  process.stdout.write('reactive-compact-persist smoke passed ✓\n');
@@ -0,0 +1,10 @@
1
+ [
2
+ { "id": "browse-last", "label": "query-less recent browse (period=last)", "args": { "period": "last", "limit": 10 }, "expect": "browse" },
3
+ { "id": "period-24h", "label": "period window 24h", "args": { "period": "24h", "limit": 10 }, "expect": "browse" },
4
+ { "id": "period-7d", "label": "period window 7d", "args": { "period": "7d", "limit": 10 }, "expect": "browse" },
5
+ { "id": "scope-all", "label": "all-scope query", "args": { "query": "recall", "projectScope": "all", "limit": 10 }, "expect": "results" },
6
+ { "id": "recent-dryrun-delta", "label": "recent-work topical: dryrun delta sum", "args": { "query": "드라이런 델타 합산", "limit": 10 }, "expect": { "kind": "results", "topNContains": ["드라이런"], "topN": 5 } },
7
+ { "id": "recent-remote-singleton", "label": "recent-work topical: remote singleton seat", "args": { "query": "remote 싱글톤 좌석", "limit": 10 }, "expect": { "kind": "results", "topNContains": ["싱글톤"], "topN": 5 } },
8
+ { "id": "recency-today", "label": "query+period recency: today's work (24h)", "args": { "query": "오늘 진행한 작업", "period": "24h", "limit": 10 }, "expect": { "kind": "results", "recencyOrdered": true } },
9
+ { "id": "xlang-embedding-crash", "label": "cross-language: embedding worker crash (strict topN)", "args": { "query": "embedding worker crash", "limit": 10 }, "expect": { "kind": "results", "topNContains": ["embedding"], "topN": 3 } }
10
+ ]
@@ -113,13 +113,87 @@ function scoreTopNContains(lines, substrings, n) {
113
113
  return { perSubstring, hitAtN, mrr, n };
114
114
  }
115
115
 
116
- function evaluateCase(kase, { count, ms, isError }, quality) {
116
+ // Parse leading "[YYYY-MM-DD HH:MM]" timestamps from result lines and check
117
+ // they are non-increasing (newest-first). Returns null when fewer than 2
118
+ // lines carry a parseable timestamp (nothing to order).
119
+ function scoreRecencyOrdered(lines) {
120
+ const tsRe = /^\s*\[(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2})/;
121
+ const headerRe = /^\s*##\s/;
122
+ const parse = (raw) => Date.parse(raw.replace(' ', 'T'));
123
+ // Non-increasing check within a single ordered list of {raw,ts}. Returns
124
+ // the first {prev,cur} pair where cur is newer than prev, else null.
125
+ const firstBreak = (stamps) => {
126
+ for (let i = 1; i < stamps.length; i++) {
127
+ if (Number.isFinite(stamps[i].ts) && Number.isFinite(stamps[i - 1].ts) && stamps[i].ts > stamps[i - 1].ts) {
128
+ return { prev: stamps[i - 1].raw, cur: stamps[i].raw };
129
+ }
130
+ }
131
+ return null;
132
+ };
133
+ const hasSessions = lines.some((l) => headerRe.test(l));
134
+ if (!hasSessions) {
135
+ // Ungrouped: single global non-increasing check over all timestamped lines.
136
+ const stamps = [];
137
+ for (const line of lines) {
138
+ const m = tsRe.exec(line);
139
+ if (m) stamps.push({ raw: m[1], ts: parse(m[1]) });
140
+ }
141
+ if (stamps.length < 2) return { parsed: stamps.length, groups: 0, ordered: true, firstViolation: null };
142
+ const b = firstBreak(stamps);
143
+ return { parsed: stamps.length, groups: 0, ordered: b === null, firstViolation: b };
144
+ }
145
+ // Session-grouped: partition timestamped lines by their "## session" header.
146
+ // Timestamps only compared WITHIN a group; groups themselves must descend by
147
+ // their first line's timestamp.
148
+ const groups = [];
149
+ let cur = null;
150
+ for (const line of lines) {
151
+ if (headerRe.test(line)) { cur = { stamps: [] }; groups.push(cur); continue; }
152
+ const m = tsRe.exec(line);
153
+ if (m && cur) cur.stamps.push({ raw: m[1], ts: parse(m[1]) });
154
+ }
155
+ const nonEmpty = groups.filter((g) => g.stamps.length);
156
+ const parsed = nonEmpty.reduce((s, g) => s + g.stamps.length, 0);
157
+ let firstViolation = null;
158
+ for (const g of nonEmpty) {
159
+ const b = firstBreak(g.stamps);
160
+ if (b) { firstViolation = { ...b, scope: 'within-session' }; break; }
161
+ }
162
+ if (!firstViolation) {
163
+ const heads = nonEmpty.map((g) => g.stamps[0]);
164
+ const b = firstBreak(heads);
165
+ if (b) firstViolation = { ...b, scope: 'across-sessions' };
166
+ }
167
+ return { parsed, groups: nonEmpty.length, ordered: firstViolation === null, firstViolation };
168
+ }
169
+
170
+ // Score expect.allContain: every result line must contain at least one of the
171
+ // given substrings (case-insensitive). Returns offending lines (matching none).
172
+ // Use for negative cases where rows legitimately mention the term.
173
+ function scoreAllContain(lines, substrings) {
174
+ const needles = substrings.map((s) => String(s || '').toLowerCase()).filter(Boolean);
175
+ const offenders = lines.filter((line) => {
176
+ const l = line.toLowerCase();
177
+ return !needles.some((n) => l.includes(n));
178
+ });
179
+ return { needles: substrings, offenders, ok: offenders.length === 0 };
180
+ }
181
+
182
+ function evaluateCase(kase, { count, ms, isError }, quality, recency, allContain) {
117
183
  const warnings = [];
118
184
  if (isError) warnings.push('error result');
119
185
  if (ms > WARN_LATENCY_MS) warnings.push(`latency ${ms}ms > ${WARN_LATENCY_MS}ms`);
120
186
  if ((kase.expect === 'browse' || kase.expect === 'idlookup') && count === 0) {
121
187
  warnings.push('0 results for a browse/id-lookup case (expected data present)');
122
188
  }
189
+ if (kase.expect === 'empty' && count > 0) {
190
+ warnings.push(`expected empty but got ${count} result(s) — possible filler/unrelated match`);
191
+ }
192
+ if (allContain) {
193
+ for (const line of allContain.offenders) {
194
+ warnings.push(`allContain miss: result line contains none of [${allContain.needles.join(', ')}]: "${line}"`);
195
+ }
196
+ }
123
197
  if (quality) {
124
198
  for (const p of quality.perSubstring) {
125
199
  if (!p.hit) {
@@ -129,6 +203,10 @@ function evaluateCase(kase, { count, ms, isError }, quality) {
129
203
  }
130
204
  }
131
205
  }
206
+ if (recency && !recency.ordered && recency.firstViolation) {
207
+ const v = recency.firstViolation;
208
+ warnings.push(`recencyOrdered violation (${v.scope || 'global'}): ${v.cur} newer than prior ${v.prev}`);
209
+ }
132
210
  const status = warnings.length ? 'WARN' : 'PASS';
133
211
  return { status, warnings };
134
212
  }
@@ -156,7 +234,10 @@ async function runCase(memoryModule, kase) {
156
234
  const topNContains = expectObj && Array.isArray(expectObj.topNContains) ? expectObj.topNContains : null;
157
235
  const cutoffN = expectObj && Number.isInteger(expectObj.topN) ? expectObj.topN : 5;
158
236
  const quality = topNContains ? scoreTopNContains(resultLines(text), topNContains, cutoffN) : null;
159
- const evalResult = evaluateCase({ ...kase, expect: expectKind }, { count, ms, isError }, quality);
237
+ const recency = expectObj && expectObj.recencyOrdered ? scoreRecencyOrdered(resultLines(text)) : null;
238
+ const allContainNeedles = expectObj && Array.isArray(expectObj.allContain) ? expectObj.allContain : null;
239
+ const allContain = allContainNeedles ? scoreAllContain(resultLines(text), allContainNeedles) : null;
240
+ const evalResult = evaluateCase({ ...kase, expect: expectKind }, { count, ms, isError }, quality, recency, allContain);
160
241
  return {
161
242
  id: kase.id,
162
243
  label: kase.label,
@@ -167,6 +248,8 @@ async function runCase(memoryModule, kase) {
167
248
  errMsg,
168
249
  top3: topN(text, 3),
169
250
  quality,
251
+ recency,
252
+ allContain,
170
253
  status: evalResult.status,
171
254
  warnings: evalResult.warnings,
172
255
  };
@@ -182,6 +265,9 @@ function printCase(row) {
182
265
  process.stdout.write(` substr "${p.needle}" -> rank ${p.rank ?? 'none'}${p.hit ? '' : ' (miss)'}\n`);
183
266
  }
184
267
  }
268
+ if (row.recency) {
269
+ process.stdout.write(` recency: ${row.recency.ordered ? 'ordered' : 'OUT-OF-ORDER'} (${row.recency.parsed} timestamped lines)\n`);
270
+ }
185
271
  if (row.top3.length) {
186
272
  for (const line of row.top3) process.stdout.write(` - ${line}\n`);
187
273
  } else {
@@ -218,6 +304,7 @@ function printSummary(rows) {
218
304
  async function main() {
219
305
  const casesPath = argValue('cases', DEFAULT_CASES_PATH);
220
306
  const jsonMode = hasFlag('json');
307
+ const strict = hasFlag('strict');
221
308
  const cases = loadCases(casesPath);
222
309
 
223
310
  let memoryModule;
@@ -262,6 +349,8 @@ async function main() {
262
349
 
263
350
  const hardErrors = rows.filter((r) => r.isError);
264
351
  if (hardErrors.length) process.exitCode = 1;
352
+ // --strict: any WARN row fails the run. Default behavior (errors-only) unchanged.
353
+ if (strict && rows.some((r) => r.status === 'WARN')) process.exitCode = 1;
265
354
  }
266
355
 
267
356
  main().catch((e) => {
@@ -5,8 +5,7 @@
5
5
  { "id": "q-recall-general", "label": "bare recall keyword", "args": { "query": "recall" }, "expect": { "kind": "results", "topNContains": ["recall"], "topN": 5 } },
6
6
  { "id": "q-uc4-topic-kr", "label": "UC4 topic query with Korean qualifier", "args": { "query": "recall period last 개선", "limit": 8 }, "expect": { "kind": "results", "topNContains": ["개선"], "topN": 5 } },
7
7
  { "id": "q-recall-improve-kr", "label": "cache improvement topic", "args": { "query": "캐시 개선" }, "expect": { "kind": "results", "topNContains": ["개선"], "topN": 5 } },
8
- { "id": "q-battle-balance", "label": "ProjectAA topic", "args": { "query": "ProjectAA" }, "expect": { "kind": "results", "topNContains": ["ProjectAA"], "topN": 5 } },
9
- { "id": "q-fanout-recall-battle", "label": "fan-out array query cache+ProjectAA", "args": { "query": ["캐시 개선", "ProjectAA"], "limit": 5 }, "expect": { "kind": "results", "topNContains": ["개선", "ProjectAA"], "topN": 10 } },
8
+ { "id": "q-fanout-recall-cache", "label": "fan-out array query cache+recall", "args": { "query": ["캐시 개선", "recall"], "limit": 5 }, "expect": { "kind": "results", "topNContains": ["개선", "recall"], "topN": 10 } },
10
9
  { "id": "q-short-2tok-cycle", "label": "short 2-token cycle1 query", "args": { "query": "cycle1 drain", "limit": 8 }, "expect": { "kind": "results", "topNContains": ["cycle1"], "topN": 3 } },
11
10
  { "id": "q-recall-scope-project", "label": "project-scoped recall query", "args": { "query": "recall", "cwd": "C:\\Project\\mixdog" , "limit": 10 }, "expect": { "kind": "results", "topNContains": ["recall"], "topN": 5 } }
12
11
  ]
@@ -15,4 +15,4 @@
15
15
  { "id": "uc5-yesterday", "label": "UC5 calendar yesterday", "args": { "period": "yesterday", "limit": 10 }, "expect": "browse" },
16
16
  { "id": "uc5-thisweek", "label": "UC5 calendar this_week", "args": { "period": "this_week", "limit": 10 }, "expect": "browse" },
17
17
  { "id": "uc6-category", "label": "UC6 category decision 7d", "args": { "period": "7d", "category": "decision", "limit": 10 }, "expect": "browse" }
18
- ]
18
+ ]
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+ // Compaction-append collision smoke for the session-ingest RUNTIME (not just
3
+ // the pure helpers). Reproduces the silent-drop bug: after compaction removes an
4
+ // earlier identical untimestamped message from the in-memory array, a newly
5
+ // APPENDED identical message used to reproduce an already-persisted ordinal →
6
+ // same source_ref → INSERT ... ON CONFLICT DO NOTHING silently dropped it.
7
+ //
8
+ // Proves, against an in-memory fake DB that enforces the source_ref uniqueness
9
+ // the real ON CONFLICT relies on:
10
+ // (1) post-compaction appended untimestamped identical msg → NEW row;
11
+ // (2) full identical re-ingest in a FRESH runtime (rows already in DB) → 0 dups;
12
+ // (3) subset re-ingest reproduces the same source_refs as a full re-ingest.
13
+ import { createSessionIngestRuntime } from '../src/runtime/memory/lib/session-ingest-runtime.mjs'
14
+
15
+ function assert(condition, message) {
16
+ if (!condition) throw new Error(message)
17
+ }
18
+
19
+ // Minimal fake DB. Only the two statements the ingest loop issues are modelled:
20
+ // • SELECT COALESCE(MAX(source_turn) ...) → per-session max
21
+ // • INSERT INTO entries(...) ON CONFLICT DO NOTHING → dedupe on source_ref
22
+ // Any other query (the post-ingest raw-embedding flush's schema calls) returns
23
+ // an empty result; flushRawEmbeddings then hits `db._pool` (undefined) and its
24
+ // error is swallowed by the runtime, so the smoke never blocks on embeddings.
25
+ function createFakeDb() {
26
+ const rows = [] // { ts, role, content, source_ref, session_id, source_turn, project_id }
27
+ const bySourceRef = new Set()
28
+ let nextId = 1
29
+ return {
30
+ rows,
31
+ async query(sql, params = []) {
32
+ if (/MAX\(source_turn\)/.test(sql)) {
33
+ const sessionId = params[0]
34
+ let max = 0
35
+ for (const r of rows) if (r.session_id === sessionId && r.source_turn > max) max = r.source_turn
36
+ return { rows: [{ max_turn: max }] }
37
+ }
38
+ if (/INSERT INTO entries/.test(sql)) {
39
+ const [ts, role, content, source_ref, session_id, source_turn, project_id] = params
40
+ if (bySourceRef.has(source_ref)) return { rowCount: 0 } // ON CONFLICT DO NOTHING
41
+ bySourceRef.add(source_ref)
42
+ rows.push({ id: nextId++, ts, role, content, source_ref, session_id, source_turn, project_id })
43
+ return { rowCount: 1 }
44
+ }
45
+ return { rows: [] }
46
+ },
47
+ }
48
+ }
49
+
50
+ // Durable high-water store fake (mirrors the DB `meta` kv the facade wires). A
51
+ // SHARED store passed to two runtime instances simulates the state file
52
+ // surviving a process restart; an absent store simulates a deleted state file.
53
+ function makeRuntime(db, durable = new Map()) {
54
+ return createSessionIngestRuntime({
55
+ getDb: () => db,
56
+ log: () => {},
57
+ parseTsToMs: (v) => (typeof v === 'number' ? v : Number(v) || 0),
58
+ loadOrdinalHighWater: async (sessionId) => durable.get(sessionId) ?? null,
59
+ saveOrdinalHighWater: (sessionId, obj) => { durable.set(sessionId, obj); },
60
+ })
61
+ }
62
+
63
+ const SESSION = 'compaction-smoke-sess'
64
+ // Untimestamped identical turns (no ts/timestamp → ordinal is what disambiguates).
65
+ const mk = (role, content) => ({ role, content })
66
+
67
+ // ── Scenario: live process ingests, compaction drops an earlier copy, append ──
68
+ const db = createFakeDb()
69
+ const rt = makeRuntime(db)
70
+
71
+ // Distinct object identities so compaction survival is by-reference (mirrors the
72
+ // runtime's immutable-transcript assumption).
73
+ const other1 = mk('assistant', 'sure, working on it')
74
+ const dupA = mk('user', 'run it again') // 1st identical untimestamped turn
75
+ const other2 = mk('assistant', 'done')
76
+ const dupB = mk('user', 'run it again') // 2nd identical untimestamped turn (distinct object)
77
+
78
+ // Call 1 (COLD): full array with two identical untimestamped user turns.
79
+ const arr1 = [other1, dupA, other2, dupB]
80
+ let r1 = await rt.ingestSessionMessages({ sessionId: SESSION, messages: arr1, limit: 5000 })
81
+ const userRows1 = db.rows.filter(r => r.role === 'user' && r.content === 'run it again')
82
+ assert(userRows1.length === 2, `cold ingest must persist BOTH identical untimestamped turns (got ${userRows1.length})`)
83
+ const refDupA = userRows1[0].source_ref
84
+ const refDupB = userRows1[1].source_ref
85
+ assert(refDupA !== refDupB, 'the two identical untimestamped turns must persist under distinct source_refs')
86
+
87
+ // Compaction: drop the FIRST identical copy (dupA) and some other rows; KEEP the
88
+ // second identical copy object (dupB) — this is what makes the array position of
89
+ // the surviving/new identical turn shift down onto an already-persisted ordinal.
90
+ // Then APPEND a genuinely new identical turn (distinct object).
91
+ const dupC = mk('user', 'run it again') // NEW appended identical turn
92
+ const arr2 = [dupB, mk('assistant', 'ok next'), dupC]
93
+
94
+ // Call 2 (WARM, same runtime/process): the appended dupC must persist as a NEW row.
95
+ let r2 = await rt.ingestSessionMessages({ sessionId: SESSION, messages: arr2, limit: 5000 })
96
+ const userRows2 = db.rows.filter(r => r.role === 'user' && r.content === 'run it again')
97
+ assert(
98
+ userRows2.length === 3,
99
+ `INVARIANT 1: post-compaction appended identical turn must persist as a NEW row (expected 3 total, got ${userRows2.length}) — this is the silent-drop bug`,
100
+ )
101
+ const refDupC = userRows2.find(r => r.source_ref !== refDupA && r.source_ref !== refDupB)?.source_ref
102
+ assert(refDupC, 'appended identical turn must have a fresh source_ref above the persisted high-water')
103
+ // dupB survived: it must have deduped (reused its recorded ordinal), NOT minted a new row.
104
+ assert(db.rows.filter(r => r.source_ref === refDupB).length === 1, 'survived identical turn must dedupe, not duplicate')
105
+
106
+ // ── INVARIANT 2: fresh runtime (new process), full identical re-ingest ────────
107
+ // Rows already in DB; re-ingesting the CURRENT array from start=0 must create
108
+ // ZERO new rows.
109
+ const rowCountBefore = db.rows.length
110
+ const rtFresh = makeRuntime(db) // cold state for this "process"
111
+ await rtFresh.ingestSessionMessages({ sessionId: SESSION, messages: arr2, limit: 5000 })
112
+ assert(
113
+ db.rows.length === rowCountBefore,
114
+ `INVARIANT 2: full identical re-ingest in a fresh process must create 0 duplicates (before=${rowCountBefore} after=${db.rows.length})`,
115
+ )
116
+
117
+ // ── INVARIANT 3: subset re-ingest reproduces the same source_refs as full ─────
118
+ // Fresh DB + fresh runtime; ingest the full array, capture refs; then a NEW
119
+ // fresh DB/runtime ingesting the same array via a small window (subset, seeded
120
+ // from the prefix) must reproduce the identical source_refs.
121
+ function refsFor(messages, opts) {
122
+ const d = createFakeDb()
123
+ const r = makeRuntime(d)
124
+ return { d, r }
125
+ }
126
+ {
127
+ const fullMsgs = [mk('user', 'x'), mk('assistant', 'y'), mk('user', 'x'), mk('user', 'x'), mk('assistant', 'y')]
128
+ const { d: dFull, r: rFull } = refsFor()
129
+ await rFull.ingestSessionMessages({ sessionId: 'subset-sess', messages: fullMsgs, limit: 5000 })
130
+ const fullRefs = dFull.rows.filter(r => r.session_id === 'subset-sess').map(r => r.source_ref).sort()
131
+
132
+ // Subset: fresh process, window = last 2 messages, prefix seeded internally.
133
+ const { d: dSub, r: rSub } = refsFor()
134
+ await rSub.ingestSessionMessages({ sessionId: 'subset-sess', messages: fullMsgs, limit: 2 })
135
+ const subRefs = dSub.rows.filter(r => r.session_id === 'subset-sess').map(r => r.source_ref)
136
+ for (const ref of subRefs) {
137
+ assert(fullRefs.includes(ref), `INVARIANT 3: subset re-ingest source_ref ${ref} must match a full re-ingest ref`)
138
+ }
139
+ // The subset window's last two messages are `x`(3rd occurrence) and `y`(2nd);
140
+ // their refs must equal the full re-ingest's 3rd `x` and 2nd `y` refs.
141
+ assert(subRefs.length === 2, `subset window should ingest exactly its 2 messages (got ${subRefs.length})`)
142
+ }
143
+
144
+ // ── INVARIANT 1 across RESTART: fresh runtime (new process), same DB ──────────
145
+ // A restart = a NEW runtime instance (empty in-memory ordinal state AND empty
146
+ // WeakMap) ingesting a reloaded transcript against the SAME DB that already
147
+ // holds prior rows. When the reloaded (compacted) array still RETAINS the
148
+ // identical copies, positional ordinals reproduce each survivor's OWN live row,
149
+ // and a freshly appended identical turn takes a free ordinal and persists NEW.
150
+ {
151
+ const rdb = createFakeDb()
152
+ const S = 'restart-sess'
153
+ const rtA = makeRuntime(rdb)
154
+ await rtA.ingestSessionMessages({ sessionId: S, messages: [mk('user', 'ping'), mk('assistant', 'pong'), mk('user', 'ping')], limit: 5000 })
155
+ const pingBefore = rdb.rows.filter(r => r.content === 'ping')
156
+ assert(pingBefore.length === 2, `pre-restart should persist 2 identical pings (got ${pingBefore.length})`)
157
+ const beforeIds = new Set(pingBefore.map(r => r.id))
158
+
159
+ // Restart: brand-new runtime (fresh WeakMap + ordinal state), same DB. The
160
+ // reloaded compacted array RETAINS both ping copies (compaction dropped only
161
+ // the non-identical `pong`); append a genuinely new identical ping. The
162
+ // reloaded objects are DISTINCT references from the pre-restart ones.
163
+ const rtB = makeRuntime(rdb)
164
+ await rtB.ingestSessionMessages({ sessionId: S, messages: [mk('user', 'ping'), mk('user', 'ping'), mk('user', 'ping')], limit: 5000 })
165
+ const pingAfter = rdb.rows.filter(r => r.content === 'ping')
166
+ assert(pingAfter.length === 3, `RESTART: appended identical turn must persist as a NEW row (expected 3, got ${pingAfter.length})`)
167
+ // Row-identity (not ref-membership) check: the survivor re-ingest must DEDUPE
168
+ // onto the 2 pre-existing live rows (same ids, no re-insert) and add EXACTLY
169
+ // one NEW row (the append) — proving survivors mapped to live rows, not dropped.
170
+ const survivorRows = pingAfter.filter(r => beforeIds.has(r.id))
171
+ const newRows = pingAfter.filter(r => !beforeIds.has(r.id))
172
+ assert(survivorRows.length === 2, `RESTART: survivors must reuse the 2 live rows (got ${survivorRows.length})`)
173
+ assert(newRows.length === 1, `RESTART: exactly one NEW appended row expected (got ${newRows.length})`)
174
+ }
175
+
176
+ // ── INVARIANT 1 across RESTART, LATER-WARM append (durable high-water) ────────
177
+ // The path the reviewer disproved: after restart the cold replay of a COMPACTED
178
+ // array seeds occNext = survivor-count T, but the DB holds K>T copies. A NEW
179
+ // untimestamped identical turn arriving in a LATER (warm) call is distinguishable
180
+ // (it is genuinely new, not one of the cold-replay survivors) and MUST persist —
181
+ // only the durable per-identity high-water K makes that possible.
182
+ {
183
+ const durable = new Map() // survives the "restart" (shared across runtimes)
184
+ const rdb = createFakeDb() // DB rows survive the restart too
185
+ const S = 'restart-warm-sess'
186
+
187
+ // Pre-restart process: build K=3 persisted copies of an identical untimestamped
188
+ // turn (cold [X,other,X] → 2 copies; then in-process compaction+append → 3rd).
189
+ const rtA = makeRuntime(rdb, durable)
190
+ const xa1 = mk('user', 'again'); const xa2 = mk('user', 'again')
191
+ await rtA.ingestSessionMessages({ sessionId: S, messages: [xa1, mk('assistant', 'ok'), xa2], limit: 5000 })
192
+ const xa3 = mk('user', 'again') // in-process appended (compaction kept xa2)
193
+ await rtA.ingestSessionMessages({ sessionId: S, messages: [xa2, mk('assistant', 'ok2'), xa3], limit: 5000 })
194
+ assert(rdb.rows.filter(r => r.content === 'again').length === 3, 'pre-restart should hold K=3 identical copies')
195
+ assert(durable.has(S), 'durable high-water must have been persisted for the duplicate identity')
196
+
197
+ // Restart: fresh runtime (empty WeakMap + ordinal state), SAME db + durable.
198
+ // Reloaded COMPACTED array retains only ONE copy (T=1).
199
+ const rtB = makeRuntime(rdb, durable)
200
+ const rx = mk('user', 'again') // reloaded survivor (distinct object)
201
+ await rtB.ingestSessionMessages({ sessionId: S, messages: [rx], limit: 5000 }) // COLD replay (T=1)
202
+ assert(rdb.rows.filter(r => r.content === 'again').length === 3, 'cold replay of survivor must not add rows (dedupe)')
203
+
204
+ // LATER warm call on rtB appends a genuinely new identical turn.
205
+ const rxNew = mk('user', 'again')
206
+ const rowsBeforeAppend = rdb.rows.length
207
+ await rtB.ingestSessionMessages({ sessionId: S, messages: [rx, rxNew], limit: 5000 })
208
+ const againAfter = rdb.rows.filter(r => r.content === 'again')
209
+ assert(
210
+ againAfter.length === 4,
211
+ `LATER-WARM RESTART: appended identical turn must persist as a NEW row via durable K (expected 4, got ${againAfter.length})`,
212
+ )
213
+ assert(rdb.rows.length === rowsBeforeAppend + 1, 'exactly one new row from the later-warm append')
214
+ }
215
+
216
+ // ── Zero-dup guarantee when a compacted reload DROPPED an identical copy ──────
217
+ // If the reloaded array is missing an earlier identical untimestamped copy AND
218
+ // the WeakMap survivor signal is gone (restart), the appended identical turn is
219
+ // information-theoretically indistinguishable from the survivors (see the
220
+ // DURABILITY note in session-ingest-runtime.mjs). Contract in that corner: NEVER
221
+ // mint a duplicate survivor row (ON CONFLICT dedupes). The append may collapse;
222
+ // it must not duplicate.
223
+ {
224
+ const rdb = createFakeDb()
225
+ const S = 'restart-drop-sess'
226
+ const rtA = makeRuntime(rdb)
227
+ await rtA.ingestSessionMessages({ sessionId: S, messages: [mk('user', 'echo'), mk('user', 'echo')], limit: 5000 })
228
+ const before = rdb.rows.filter(r => r.content === 'echo').length
229
+ assert(before === 2, `pre-restart should persist 2 identical echoes (got ${before})`)
230
+ const rowsBefore = rdb.rows.length
231
+
232
+ // Restart with a compacted reload that DROPPED one echo, then appended one
233
+ // (net 2 copies again). Must not mint a duplicate; the append collapses.
234
+ const rtB = makeRuntime(rdb)
235
+ await rtB.ingestSessionMessages({ sessionId: S, messages: [mk('user', 'echo'), mk('user', 'echo')], limit: 5000 })
236
+ const after = rdb.rows.filter(r => r.content === 'echo').length
237
+ assert(after === before, `RESTART+drop: must not mint a duplicate survivor row (before=${before} after=${after})`)
238
+ assert(rdb.rows.length === rowsBefore, 'RESTART+drop: no duplicate rows created')
239
+ }
240
+
241
+ process.stdout.write('session ingest compaction-append smoke passed \u2713\n')