mixdog 0.9.19 → 0.9.21

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -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')
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { __applyStandaloneToolDefaultsForTest, __renderToolSearchForTest, compactToolSearchDescription, defaultDeferredToolNames, SKILL_TOOL, TOOL_SEARCH_TOOL } from '../src/mixdog-session-runtime.mjs';
7
+ import { applyInitialDeferredToolManifestToBp1, buildDeferredToolManifest } from '../src/runtime/agent/orchestrator/context/collect.mjs';
7
8
  import { buildExplorerPrompt, EXPLORE_TOOL, MAX_FANOUT_QUERIES, normalizeExploreQueries } from '../src/standalone/explore-tool.mjs';
8
9
  import { AGENT_TOOL, createStandaloneAgent } from '../src/standalone/agent-tool.mjs';
9
10
  import { parseHeadlessRoleCommand } from '../src/app.mjs';
@@ -644,6 +645,72 @@ if (!/^Error[\s:[]/.test(String(stalePatchOut)) || !/apply_patch/i.test(String(s
644
645
  throw new Error(`apply_patch stale context must return an Error result, not throw or pass:\n${stalePatchOut}`);
645
646
  }
646
647
 
648
+ // Malformed-but-unambiguous patch openings must be absorbed (dry-run, so no
649
+ // write). Each targets the same known-good smoke.mjs line the cases above use.
650
+ const smokeBody = `@@
651
+ -process.stdout.write('smoke passed ✓\\n');
652
+ +process.stdout.write('smoke passed ok\\n');
653
+ *** End Patch
654
+ `;
655
+ const absorbCases = [
656
+ ['leading blank lines', `\n\n*** Begin Patch\n*** Update File: scripts/smoke.mjs\n${smokeBody}`],
657
+ ['decorated begin header', `*** Begin Patch (V4A) ***\n*** Update File: scripts/smoke.mjs\n${smokeBody}`],
658
+ ['bare file path opening', `*** Begin Patch\nscripts/smoke.mjs\n${smokeBody}`],
659
+ ['File: prefixed opening', `*** Begin Patch\nFile: scripts/smoke.mjs\n${smokeBody}`],
660
+ ['unified body in envelope', `*** Begin Patch\n--- scripts/smoke.mjs\n+++ scripts/smoke.mjs\n${smokeBody}`],
661
+ ];
662
+ for (const [label, patch] of absorbCases) {
663
+ const out = await executePatchTool('apply_patch', { base_path: root, dry_run: true, fuzzy: false, patch }, root);
664
+ assertOk(`apply_patch absorbs ${label}`, out, /checked|validated|dry|OK/i);
665
+ }
666
+
667
+ const ambiguousPatchOut = await executePatchTool('apply_patch', {
668
+ base_path: root,
669
+ dry_run: true,
670
+ fuzzy: false,
671
+ patch: `*** Begin Patch\nthis line is not a valid opening\n${smokeBody}`,
672
+ }, root);
673
+ if (!/^Error[\s:[]/.test(String(ambiguousPatchOut)) || !/before a file header|V4A/i.test(String(ambiguousPatchOut))) {
674
+ throw new Error(`apply_patch must keep erroring on genuinely ambiguous openings:\n${ambiguousPatchOut}`);
675
+ }
676
+
677
+ // Unified-looking first body line but real V4A file sections appear later: the
678
+ // envelope must NOT be stripped to unified — it stays ambiguous and errors.
679
+ const mixedPatchOut = await executePatchTool('apply_patch', {
680
+ base_path: root,
681
+ dry_run: true,
682
+ fuzzy: false,
683
+ patch: `*** Begin Patch\n--- scripts/smoke.mjs\n*** Update File: scripts/smoke.mjs\n${smokeBody}`,
684
+ }, root);
685
+ if (!/^Error[\s:[]/.test(String(mixedPatchOut)) || !/before a file header|V4A/i.test(String(mixedPatchOut))) {
686
+ throw new Error(`apply_patch must keep erroring on mixed unified/V4A openings:\n${mixedPatchOut}`);
687
+ }
688
+
689
+ // Compacted-history placeholder guard: EVERY [mixdog compacted …] variant must
690
+ // be rejected with the corrective message BEFORE format dispatch/salvage, both
691
+ // as the first line and standalone mid-body (after a *** Begin Patch header).
692
+ const compactedGuardCases = [
693
+ ['legacy key: prefix', '[mixdog compacted patch: 4096 chars, sha256:deadbeefdeadbeef]\n*** Begin Patch\n*** Update File: a.txt\n+x\n*** End Patch\n'],
694
+ ['variant key form', '[mixdog compacted patch v4a, sha256:deadbeefdeadbeef]\n*** Begin Patch\n*** Update File: a.txt\n+x\n*** End Patch\n'],
695
+ ['no chars/sha detail', '[mixdog compacted old_string]\n'],
696
+ ['mid-body standalone', '*** Begin Patch\n*** Update File: a.txt\n[mixdog compacted patch v4a, sha256:deadbeefdeadbeef]\n*** End Patch\n'],
697
+ ];
698
+ for (const [label, patch] of compactedGuardCases) {
699
+ const out = await executePatchTool('apply_patch', { base_path: root, dry_run: true, fuzzy: false, patch }, root);
700
+ if (!/^Error[\s:[]/.test(String(out)) || !/compacted-history placeholder/i.test(String(out))) {
701
+ throw new Error(`apply_patch must reject compacted placeholder (${label}):\n${out}`);
702
+ }
703
+ }
704
+ // A legit unified edit whose body content mentions the literal text on a diff
705
+ // line (+/-/space) must still parse — the guard only trips on non-diff lines.
706
+ const compactedFalsePositiveOut = await executePatchTool('apply_patch', {
707
+ base_path: root,
708
+ dry_run: true,
709
+ fuzzy: false,
710
+ patch: `*** Begin Patch\n*** Add File: compacted-note.txt\n+[mixdog compacted patch: 10 chars, sha256:abc]\n*** End Patch\n`,
711
+ }, root);
712
+ assertOk('apply_patch keeps diff-line placeholder text', compactedFalsePositiveOut, /checked|validated|dry|OK/i);
713
+
647
714
  const shellOutPromise = executeBuiltinTool('shell', {
648
715
  command: 'node --version',
649
716
  cwd: root,
@@ -1485,7 +1552,10 @@ setInternalToolsProvider({
1485
1552
  const resumed = await resumeSession(session.id, 'full');
1486
1553
  const resumedTools = (resumed?.tools || []).map((tool) => tool?.name).filter(Boolean);
1487
1554
  const expected = expectedForHiddenAgent(permission, schemaAllowedTools);
1488
- if (expected && (JSON.stringify(tools) !== JSON.stringify(expected) || JSON.stringify(resumedTools) !== JSON.stringify(expected))) {
1555
+ // Order-insensitive: the session tool surface follows catalog order, while
1556
+ // schemaAllowedTools declares an allow-set; only set equality is contractual.
1557
+ const asSet = (list) => JSON.stringify(list.slice().sort());
1558
+ if (expected && (asSet(tools) !== asSet(expected) || asSet(resumedTools) !== asSet(expected))) {
1489
1559
  throw new Error(`hidden agent ${agent} schema mismatch: expected=${expected.join(', ')} tools=${tools.join(', ')} resumed=${resumedTools.join(', ')}`);
1490
1560
  }
1491
1561
  const leaked = tools.filter((name) => hiddenBadTools.has(name) && !(expected || []).includes(name));
@@ -1644,15 +1714,25 @@ if (/line\/context/i.test(JSON.stringify(readTool?.inputSchema || {}))) {
1644
1714
  // session's provider/model in place, or a mid-conversation model/provider
1645
1715
  // switch silently forces a full prompt-cache rewrite (seen as a
1646
1716
  // promptΔ spike + cache_ratio=0% turn in session-bench).
1647
- const runtimeSrc = readFileSync(resolve(root, 'src/mixdog-session-runtime.mjs'), 'utf8');
1717
+ // God-file splits move implementation into module dirs; scan facade + all
1718
+ // split modules so these source-text guards survive refactors.
1719
+ const readMjsSources = (rel) => {
1720
+ const abs = resolve(root, rel);
1721
+ if (rel.endsWith('.mjs')) return readFileSync(abs, 'utf8');
1722
+ return readdirSync(abs, { recursive: true })
1723
+ .filter((f) => String(f).endsWith('.mjs'))
1724
+ .map((f) => readFileSync(resolve(abs, String(f)), 'utf8'))
1725
+ .join('\n');
1726
+ };
1727
+ const runtimeSrc = [readMjsSources('src/mixdog-session-runtime.mjs'), readMjsSources('src/session-runtime')].join('\n');
1648
1728
  const setRouteBlock = runtimeSrc.match(/async setRoute\(next, options = \{\}\) \{[\s\S]*?\n \},\n/)?.[0] || '';
1649
1729
  if (!/applyToCurrentSession = options\?\.applyToCurrentSession === true/.test(setRouteBlock)) {
1650
1730
  throw new Error('setRoute must default applyToCurrentSession to false (model changes apply to the next session only)');
1651
1731
  }
1652
- if (!/if \(!applyToCurrentSession\)/.test(setRouteBlock) || !/return route;/.test(setRouteBlock)) {
1732
+ if (!/if \(!applyToCurrentSession\)/.test(setRouteBlock) || !/return (?:route|getRoute\(\));/.test(setRouteBlock)) {
1653
1733
  throw new Error('setRoute must early-return before touching the live session when applyToCurrentSession is false');
1654
1734
  }
1655
- const engineSrc = readFileSync(resolve(root, 'src/tui/engine.mjs'), 'utf8');
1735
+ const engineSrc = [readMjsSources('src/tui/engine.mjs'), readMjsSources('src/tui/engine')].join('\n');
1656
1736
  if (/setRoute\(\{ model: m \}, \{ applyToCurrentSession: true \}\)/.test(engineSrc)) {
1657
1737
  throw new Error('TUI setModel must not force applyToCurrentSession:true (model changes must apply to the next session only)');
1658
1738
  }
@@ -1774,16 +1854,23 @@ const toolSearchSession = {
1774
1854
  deferredToolCatalog: smokeCatalog.slice(),
1775
1855
  deferredSelectedTools: [...fullDefaults],
1776
1856
  };
1777
- const searchOnlyResult = JSON.parse(__renderToolSearchForTest({ query: 'shell' }, toolSearchSession, 'full'));
1778
- for (const name of ['shell', 'task']) {
1779
- if (!searchOnlyResult.selected?.tools?.added?.includes(name)) {
1780
- throw new Error(`tool_search high-confidence query should auto-load ${name}: ${JSON.stringify(searchOnlyResult.selected)}`);
1781
- }
1857
+ // A plain query is a case-insensitive substring filter over name+description.
1858
+ // It lists matches only it never auto-loads or ranks.
1859
+ const listQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'shell' }, toolSearchSession, 'full'));
1860
+ if (listQueryResult.selected) {
1861
+ throw new Error(`tool_search plain query must not auto-load: ${JSON.stringify(listQueryResult.selected)}`);
1862
+ }
1863
+ if (!listQueryResult.matches.some((row) => row.name === 'shell')) {
1864
+ throw new Error(`tool_search plain query should list substring matches: ${JSON.stringify(listQueryResult.matches)}`);
1782
1865
  }
1783
- if (!searchOnlyResult.activeTools.includes('shell') || !searchOnlyResult.activeTools.includes('task')) {
1784
- throw new Error(`tool_search query should activate legacy selected tools: ${searchOnlyResult.activeTools.join(',')}`);
1866
+ if (listQueryResult.activeTools.includes('shell') || (Array.isArray(listQueryResult.discoveredTools) && listQueryResult.discoveredTools.includes('shell'))) {
1867
+ throw new Error(`tool_search plain query must not activate/discover tools: ${JSON.stringify(listQueryResult)}`);
1785
1868
  }
1869
+ // query "select:a,b" is the explicit query-side loader (aliases expand).
1786
1870
  const bulkSelectResult = JSON.parse(__renderToolSearchForTest({ query: 'select:shell,recall' }, toolSearchSession, 'full'));
1871
+ if (bulkSelectResult.selected?.mode !== 'select') {
1872
+ throw new Error(`tool_search query-select must report select mode: ${JSON.stringify(bulkSelectResult.selected)}`);
1873
+ }
1787
1874
  for (const name of ['shell', 'task', 'recall']) {
1788
1875
  if (!bulkSelectResult.activeTools.includes(name)) {
1789
1876
  throw new Error(`tool_search bulk select missing ${name}: ${JSON.stringify(bulkSelectResult)}`);
@@ -1858,7 +1945,8 @@ if (nativeGrokPatchTool?.type !== 'function' || nativeGrokPatchTool?.format || n
1858
1945
  if (nativeGrokPatchTool.parameters?.properties?.patch?.type !== 'string') {
1859
1946
  throw new Error(`Grok native tool_search apply_patch must preserve patch JSON schema: ${JSON.stringify(nativeGrokPatchTool)}`);
1860
1947
  }
1861
- const nativeRunQuerySession = {
1948
+ // Native query-select discovers (without mutating active schemas); aliases expand.
1949
+ const nativeSelectQuerySession = {
1862
1950
  tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1863
1951
  deferredToolCatalog: smokeCatalog.slice(),
1864
1952
  deferredSelectedTools: [...fullDefaults],
@@ -1866,16 +1954,20 @@ const nativeRunQuerySession = {
1866
1954
  deferredProviderMode: 'native',
1867
1955
  deferredNativeTools: true,
1868
1956
  };
1869
- const nativeRunQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'run tests' }, nativeRunQuerySession, 'full'));
1870
- for (const name of ['shell', 'task']) {
1871
- if (!nativeRunQueryResult.discoveredTools.includes(name)) {
1872
- throw new Error(`native tool_search run/tests query should discover ${name}: ${JSON.stringify(nativeRunQueryResult)}`);
1957
+ const nativeSelectQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'select:search' }, nativeSelectQuerySession, 'full'));
1958
+ for (const name of ['search', 'web_fetch']) {
1959
+ if (!nativeSelectQueryResult.discoveredTools.includes(name)) {
1960
+ throw new Error(`native tool_search query-select should discover ${name}: ${JSON.stringify(nativeSelectQueryResult)}`);
1873
1961
  }
1874
1962
  }
1875
- if (nativeRunQueryResult.activeTools.includes('shell') || nativeRunQueryResult.activeTools.includes('task')) {
1876
- throw new Error(`native tool_search query must not mutate active schemas: ${JSON.stringify(nativeRunQueryResult)}`);
1963
+ if (nativeSelectQueryResult.activeTools.includes('search') || nativeSelectQueryResult.activeTools.includes('web_fetch')) {
1964
+ throw new Error(`native tool_search must not mutate active schemas: ${JSON.stringify(nativeSelectQueryResult)}`);
1965
+ }
1966
+ if (!nativeSelectQueryResult.nativeToolSearch?.toolReferences?.includes('search')) {
1967
+ throw new Error(`native query-select must return nativeToolSearch payload: ${JSON.stringify(nativeSelectQueryResult.nativeToolSearch)}`);
1877
1968
  }
1878
- const nativeWebQuerySession = {
1969
+ // A plain query never auto-loads/discovers, even on native providers.
1970
+ const nativePlainQuerySession = {
1879
1971
  tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1880
1972
  deferredToolCatalog: smokeCatalog.slice(),
1881
1973
  deferredSelectedTools: [...fullDefaults],
@@ -1883,35 +1975,48 @@ const nativeWebQuerySession = {
1883
1975
  deferredProviderMode: 'native',
1884
1976
  deferredNativeTools: true,
1885
1977
  };
1886
- const nativeWebQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'web docs' }, nativeWebQuerySession, 'full'));
1887
- for (const name of ['search', 'web_fetch']) {
1888
- if (!nativeWebQueryResult.discoveredTools.includes(name)) {
1889
- throw new Error(`native tool_search web/docs query should discover ${name}: ${JSON.stringify(nativeWebQueryResult)}`);
1978
+ for (const q of ['run tests', 'web docs', 'memory previous', 'status']) {
1979
+ const r = JSON.parse(__renderToolSearchForTest({ query: q }, nativePlainQuerySession, 'full'));
1980
+ if (r.selected || r.discoveredTools.length) {
1981
+ throw new Error(`native tool_search plain query "${q}" must not auto-load/discover: ${JSON.stringify(r)}`);
1890
1982
  }
1891
1983
  }
1892
- const nativeRecallQuerySession = {
1893
- tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1894
- deferredToolCatalog: smokeCatalog.slice(),
1895
- deferredSelectedTools: [...fullDefaults],
1896
- deferredDiscoveredTools: [],
1897
- deferredProviderMode: 'native',
1898
- deferredNativeTools: true,
1984
+ // Skill-style deferred manifest: `- name: description` lines, `<`/`>` sanitized,
1985
+ // bare names allowed, header instructs direct calls, empty pool → ''.
1986
+ const manifestText = buildDeferredToolManifest([
1987
+ { name: 'shell', description: 'Run commands.' },
1988
+ { name: 'search', description: 'Web <search> now.' },
1989
+ 'recall',
1990
+ ]);
1991
+ if (!/<available-deferred-tools>/.test(manifestText) || !/- shell: Run commands\./.test(manifestText)) {
1992
+ throw new Error(`deferred manifest must render "- name: description" lines: ${manifestText}`);
1993
+ }
1994
+ if (!/call any tool listed below directly/i.test(manifestText)) {
1995
+ throw new Error(`deferred manifest must tell the model it can call listed tools directly: ${manifestText}`);
1996
+ }
1997
+ if (!/^- recall$/m.test(manifestText)) {
1998
+ throw new Error(`deferred manifest must allow bare names without descriptions: ${manifestText}`);
1999
+ }
2000
+ if (/[<>]/.test(manifestText.replace(/<\/?available-deferred-tools>/g, ''))) {
2001
+ throw new Error(`deferred manifest must sanitize angle brackets in descriptions: ${manifestText}`);
2002
+ }
2003
+ if (buildDeferredToolManifest([]) !== '') {
2004
+ throw new Error('empty deferred pool must yield an empty manifest');
2005
+ }
2006
+ const bp1ManifestSession = {
2007
+ messages: [{ role: 'system', content: 'BASE PROMPT' }],
2008
+ deferredToolCatalog: [
2009
+ { name: 'shell', description: 'Run commands.' },
2010
+ { name: 'recall', description: 'Recall prior work.' },
2011
+ ],
1899
2012
  };
1900
- const nativeRecallQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'memory previous' }, nativeRecallQuerySession, 'full'));
1901
- if (!nativeRecallQueryResult.discoveredTools.includes('recall') || nativeRecallQueryResult.discoveredTools.includes('memory')) {
1902
- throw new Error(`native tool_search memory previous should discover recall only: ${JSON.stringify(nativeRecallQueryResult)}`);
2013
+ applyInitialDeferredToolManifestToBp1(bp1ManifestSession, ['shell', 'recall']);
2014
+ const bp1ManifestText = bp1ManifestSession.messages[0].content;
2015
+ if (!/- shell: Run commands\./.test(bp1ManifestText) || !/- recall: Recall prior work\./.test(bp1ManifestText)) {
2016
+ throw new Error(`BP1 deferred manifest must carry catalog descriptions: ${bp1ManifestText}`);
1903
2017
  }
1904
- const ambiguousStatusSession = {
1905
- tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1906
- deferredToolCatalog: smokeCatalog.slice(),
1907
- deferredSelectedTools: [...fullDefaults],
1908
- deferredDiscoveredTools: [],
1909
- deferredProviderMode: 'native',
1910
- deferredNativeTools: true,
1911
- };
1912
- const ambiguousStatusResult = JSON.parse(__renderToolSearchForTest({ query: 'status' }, ambiguousStatusSession, 'full'));
1913
- if (ambiguousStatusResult.selected || ambiguousStatusResult.discoveredTools.length) {
1914
- throw new Error(`tool_search ambiguous status query must not auto-load: ${JSON.stringify(ambiguousStatusResult)}`);
2018
+ if (bp1ManifestSession.deferredToolBp1Applied !== true) {
2019
+ throw new Error('BP1 deferred manifest injection must mark deferredToolBp1Applied');
1915
2020
  }
1916
2021
  const replyTool = CHANNEL_TOOL_DEFS.find((tool) => tool.name === 'reply');
1917
2022
  if (!/configured channel/i.test(replyTool?.description || '') || !/local .*paths/i.test(replyTool?.inputSchema?.properties?.files?.description || '')) {