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,401 @@
1
+ // Session ingest runtime extracted from index.mjs.
2
+ //
3
+ // The stateful ingest cluster: per-session serialization chains, the WeakMap
4
+ // identity-fields cache, the post-ingest raw-embedding flush chain, and the
5
+ // ingest_session entrypoint. Pure helpers (role normalization, source-ref
6
+ // hashing, turn allocation, text cleaning) and flushRawEmbeddings are imported
7
+ // directly; the live DB handle, log sink, and parseTsToMs (owned by the
8
+ // transcript-ingest instance) are injected so this module holds only the
9
+ // ingest-local chains/caches and the facade keeps ownership of `db`.
10
+
11
+ import {
12
+ normalizeIngestRole,
13
+ stableSessionSourceRef,
14
+ createIngestTurnAllocator,
15
+ sessionMessageContentForIngest,
16
+ shouldExcludeIngestMessage,
17
+ } from './session-ingest.mjs'
18
+ import { cleanMemoryText } from './memory.mjs'
19
+ import { flushRawEmbeddings } from './memory-cycle.mjs'
20
+ import { resolveProjectScope } from './project-id-resolver.mjs'
21
+ import crypto from 'node:crypto'
22
+
23
+ // Ingest now WAITS for its embedding flush (bounded) so freshly ingested rows
24
+ // are dense-searchable the moment ingest_session returns. On a cold ONNX
25
+ // model the flush can stall on warmup; the race below caps the wait and lets
26
+ // the flush finish in the background (tick fallback still sweeps stragglers).
27
+ const INGEST_EMBED_WAIT_MS = 15_000
28
+
29
+ // Short, content-derived key for the durable untimestamped high-water map so the
30
+ // persisted blob stores a fixed-width hash per duplicate identity rather than raw
31
+ // (possibly large) message content.
32
+ function _identityHash(occKey) {
33
+ return crypto.createHash('sha256').update(occKey).digest('hex').slice(0, 24)
34
+ }
35
+
36
+ export function createSessionIngestRuntime({
37
+ getDb,
38
+ log,
39
+ parseTsToMs,
40
+ // Durable per-session untimestamped high-water store (reviewer item 1). Injected
41
+ // by the facade (DB `meta` kv). Defaults to no-op so the runtime still works
42
+ // (in-memory only) when a caller does not wire durability.
43
+ loadOrdinalHighWater = async () => null,
44
+ saveOrdinalHighWater = async () => {},
45
+ }) {
46
+ // Post-ingest raw-embedding flush chain (facade-local). The checkCycles tick
47
+ // keeps its OWN independent guard inside the scheduler; this chain serializes
48
+ // flushes kicked from ingestSessionMessages so ingest bursts never stack
49
+ // embedding work, while still guaranteeing every ingest's rows get their own
50
+ // flush pass (the old boolean guard silently SKIPPED bursts, leaving rows
51
+ // embedding-less until the ~60s tick — a recall (no results) window).
52
+ let _rawEmbedFlushChain = Promise.resolve()
53
+ // Per-session ingest serialization. Concurrent ingest_session calls for the
54
+ // SAME session raced on MAX(source_turn) → duplicate turn allocation. Chain
55
+ // same-session ingests so the MAX read + insert loop for one session never
56
+ // overlaps another for the same session. Different sessions stay parallel.
57
+ const _ingestSessionChains = new Map()
58
+ // ── Untimestamped-repeat ordinal state (per session) ──────────────────────
59
+ // `occNext[identity]` = next FREE occurrence ordinal assigned for a
60
+ // (role,content) identity in this session. It disambiguates textually
61
+ // identical UNTIMESTAMPED turns (a timestamped turn carries a durable ts and
62
+ // never needs it). Monotonic and compaction-independent so a genuine appended
63
+ // repeat lands ABOVE every already-persisted copy rather than colliding with
64
+ // one under ON CONFLICT DO NOTHING.
65
+ //
66
+ // In-memory only, LRU-bounded (_touchOrdinalState) so it cannot grow without
67
+ // bound across session ids. Correctness does NOT depend on it surviving
68
+ // eviction or a process restart: on a miss the high-water is rebuilt from the
69
+ // WeakMap of already-assigned ordinals for the messages STILL in the array
70
+ // (survivors advance occNext, so a following genuine append still lands above
71
+ // them — this is what makes LRU eviction safe mid-session), and any truly-cold
72
+ // walk falls back to POSITIONAL ordinals, which reproduce a fresh full/subset
73
+ // re-ingest exactly and therefore never mint a duplicate row. See the
74
+ // DURABILITY note at the assignment site for why a persisted high-water cannot
75
+ // recover more than this for indistinguishable untimestamped identicals.
76
+ const _sessionIngestOrdinalState = new Map()
77
+ const _ORDINAL_STATE_MAX_SESSIONS = 2048
78
+ // Touch (LRU) + create-on-miss. Re-inserting on a hit moves the entry to the
79
+ // Map tail; when the cap is exceeded the oldest (head) session is evicted.
80
+ // Eviction is safe because the state is reconstructible (see above).
81
+ function _touchOrdinalState(sessionId) {
82
+ const existing = _sessionIngestOrdinalState.get(sessionId)
83
+ if (existing) {
84
+ _sessionIngestOrdinalState.delete(sessionId)
85
+ _sessionIngestOrdinalState.set(sessionId, existing)
86
+ return existing
87
+ }
88
+ const st = { occNext: new Map(), seeded: false }
89
+ _sessionIngestOrdinalState.set(sessionId, st)
90
+ while (_sessionIngestOrdinalState.size > _ORDINAL_STATE_MAX_SESSIONS) {
91
+ const oldest = _sessionIngestOrdinalState.keys().next().value
92
+ if (oldest === undefined) break
93
+ _sessionIngestOrdinalState.delete(oldest)
94
+ }
95
+ return st
96
+ }
97
+ // Ordinal assigned to a given session-message OBJECT at first sight, reused on
98
+ // every later hydrate so its source_ref stays stable and ON CONFLICT dedupes
99
+ // it. This is the survivor-vs-new-append signal — VERIFIED valid in
100
+ // production: ingest_session runs IN-PROCESS (runtime-core executor →
101
+ // memoryMod.handleToolCall(args), session-runtime/runtime-core.mjs), NOT over
102
+ // an HTTP/JSON boundary, so the SAME message objects arrive across calls for a
103
+ // live session. WeakMap: entries vanish when a message is GC'd (compaction
104
+ // drops it from the array). Across a process restart (or LRU eviction) the
105
+ // signal is gone; the walk then falls back to positional ordinals.
106
+ const _ingestedMessageOrdinal = new WeakMap()
107
+ // Assign/reuse the occurrence ordinal for message `m` under identity `occKey`.
108
+ // A re-presented object reuses its recorded ordinal AND advances the session
109
+ // high-water past it (rebuilding occNext from survivors after an eviction);
110
+ // a first-seen object consumes the next free ordinal and records it. `floor`
111
+ // (>0 only for a WARM first-seen untimestamped turn) lifts a genuine
112
+ // post-restart/eviction append above the durable persisted high-water even when
113
+ // the cold replay only counted the survivors currently in the array.
114
+ function _assignOccurrence(occNext, occKey, m, floor = 0) {
115
+ if (_ingestedMessageOrdinal.has(m)) {
116
+ const ord = _ingestedMessageOrdinal.get(m)
117
+ if (ord + 1 > (occNext.get(occKey) ?? 0)) occNext.set(occKey, ord + 1)
118
+ return ord
119
+ }
120
+ let ord = occNext.get(occKey) ?? 0
121
+ if (floor > ord) ord = floor
122
+ occNext.set(occKey, ord + 1)
123
+ _ingestedMessageOrdinal.set(m, ord)
124
+ return ord
125
+ }
126
+ // Cache of (role, cleaned content) per session message OBJECT, keyed by
127
+ // identity (WeakMap — entries vanish once the message is GC'd, e.g. after
128
+ // compaction drops the array). The subset-reingest ordinal fix below must
129
+ // replay cleanMemoryText/shouldExcludeIngestMessage over messages[0, start)
130
+ // on every call; without caching that turned an O(limit)-bounded ingest into
131
+ // an O(full transcript) regex pass on every hydrate. Message objects are
132
+ // immutable transcript entries reused by reference across calls (recall-
133
+ // fasttrack re-ingests the same in-memory array as the session grows), so
134
+ // once a message's identity fields are computed they never change — caching
135
+ // makes every call after the first pay only for genuinely NEW messages,
136
+ // restoring the limit-bounded cost in steady state.
137
+ const _ingestIdentityFieldsCache = new WeakMap()
138
+ // Return { role, content } for a session message's dedup identity, or null if
139
+ // the message would be skipped by ingest (no role / excluded / empty content
140
+ // after cleaning). Cached per message object so repeated calls over the same
141
+ // (unchanged) transcript prefix never re-run the expensive clean/normalize
142
+ // regex pipeline.
143
+ function _ingestIdentityFields(m) {
144
+ if (_ingestIdentityFieldsCache.has(m)) return _ingestIdentityFieldsCache.get(m)
145
+ let fields = null
146
+ const role = normalizeIngestRole(m.role)
147
+ if (role && !shouldExcludeIngestMessage(m)) {
148
+ const content = cleanMemoryText(sessionMessageContentForIngest(m))
149
+ if (content && content.trim()) fields = { role, content }
150
+ }
151
+ _ingestIdentityFieldsCache.set(m, fields)
152
+ return fields
153
+ }
154
+
155
+ async function ingestSessionMessages(args = {}) {
156
+ const sessionId = String(args.sessionId || args.session_id || `session-${Date.now()}`).trim()
157
+ // Serialize per-session so the MAX(source_turn) read + insert loop for one
158
+ // session never overlaps a concurrent ingest for the SAME session (they
159
+ // otherwise race the turn allocator and double-allocate). Distinct sessions
160
+ // run in parallel.
161
+ const prev = _ingestSessionChains.get(sessionId) ?? Promise.resolve()
162
+ const run = prev.catch(() => {}).then(() => _ingestSessionMessagesImpl(sessionId, args))
163
+ _ingestSessionChains.set(sessionId, run.catch(() => {}))
164
+ const tail = _ingestSessionChains.get(sessionId)
165
+ try {
166
+ return await run
167
+ } finally {
168
+ // Best-effort GC: drop the map entry only if no later call chained after us.
169
+ if (_ingestSessionChains.get(sessionId) === tail) _ingestSessionChains.delete(sessionId)
170
+ }
171
+ }
172
+
173
+ async function _ingestSessionMessagesImpl(sessionId, args = {}) {
174
+ const db = getDb()
175
+ const messages = Array.isArray(args.messages) ? args.messages : []
176
+ // Recall fast-track hydrates the current session before compaction; allow
177
+ // callers to ingest the full in-memory transcript instead of silently
178
+ // clipping long sessions at 500 turns. Default remains conservative.
179
+ const limit = Math.max(1, Math.min(5000, Number(args.limit) || 200))
180
+ const start = Math.max(0, messages.length - limit)
181
+ const projectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
182
+ let considered = 0
183
+ let inserted = 0
184
+ // Ids of rows THIS call actually inserted (ON CONFLICT skips return no id).
185
+ // The post-ingest flush is scoped to exactly these so ingest_session never
186
+ // synchronously inherits other calls'/sessions' raw-embedding backlog.
187
+ const insertedIds = []
188
+ // Monotonic ingest order, independent of the current (post-compaction)
189
+ // array index. source_turn used to be `i+1`, but after compaction shrinks /
190
+ // reindexes session.messages a NEWLY appended turn gets a LOW i and thus a
191
+ // LOW source_turn — and since dump_session_roots / recall order by
192
+ // source_turn first, it would sort BEFORE older pre-compaction rows. Seed a
193
+ // running counter from the current max source_turn for this session so every
194
+ // new row is assigned a turn strictly greater than all previously-ingested
195
+ // ones (true continuation order). Re-ingested (ON CONFLICT) rows keep their
196
+ // original turn and do not consume a new one.
197
+ let prevMaxTurn = 0
198
+ try {
199
+ const maxRow = await db.query(
200
+ `SELECT COALESCE(MAX(source_turn), 0) AS max_turn FROM entries WHERE session_id = $1`,
201
+ [sessionId],
202
+ )
203
+ prevMaxTurn = Number(maxRow.rows?.[0]?.max_turn) || 0
204
+ } catch { prevMaxTurn = 0 }
205
+ const turnAllocator = createIngestTurnAllocator(prevMaxTurn)
206
+ // ── Untimestamped-repeat ordinal: re-ingest vs genuine-append distinction ─
207
+ // The ordinal folded into stableSessionSourceRef must satisfy BOTH: a
208
+ // full/subset re-ingest of the SAME messages reproduces the exact ordinal →
209
+ // same source_ref → ON CONFLICT dedupes; and a genuinely NEW untimestamped
210
+ // turn appended after compaction dropped an earlier identical copy lands on a
211
+ // FREE ordinal above every persisted copy (else it reproduces a persisted
212
+ // ordinal and is silently dropped).
213
+ //
214
+ // Rule (see module-level notes on _assignOccurrence): a re-presented message
215
+ // (same object) reuses its recorded ordinal AND advances the session
216
+ // high-water; a first-seen turn takes the next free high-water ordinal, so an
217
+ // appended identical turn lands above every persisted copy (invariant 1). On
218
+ // a fresh/evicted/restarted state the prefix [0,start) is replayed ONCE to
219
+ // rebuild occNext (positional + any surviving recorded ordinals) so a SUBSET
220
+ // re-ingest reproduces the refs a full re-ingest would (invariants 2 & 3); a
221
+ // warm state skips the replay, keeping steady-state ingest O(new messages)
222
+ // (invariant 4).
223
+ //
224
+ // DURABILITY (reviewer item 1): a genuine untimestamped append arriving in a
225
+ // LATER (warm) call after a restart/eviction IS distinguishable from the
226
+ // cold-replay survivors — the cold replay seeded occNext = survivor-count T,
227
+ // but the DB holds K>T persisted copies, so a warm first-seen turn drawing T
228
+ // would silently collide. `ordinalState.durable` (hash → next-ordinal,
229
+ // persisted per session for untimestamped identities that reached
230
+ // occurrence>0) restores that K: a WARM first-seen untimestamped turn draws
231
+ // max(occNext, durableK) so it lands ABOVE every persisted copy. The COLD
232
+ // positional replay path is NOT consulted for durable (floor=0 there), so a
233
+ // fresh full/subset re-ingest stays pure-positional and dedupes even with a
234
+ // stale or deleted state file (invariants 2 & 3 hold). The only residual
235
+ // (unavoidable) collapse is an identical append that is ALREADY inside the
236
+ // cold-replay array on a compacted restart — indistinguishable from a
237
+ // survivor (identical content, no per-message id); it never DUPLICATES.
238
+ const ordinalState = _touchOrdinalState(sessionId)
239
+ const occNext = ordinalState.occNext
240
+ // A first-seen turn is a genuine new append (eligible for the durable floor)
241
+ // only in a WARM call — one whose ordinal state was already established by an
242
+ // earlier ingest. In the establishing (cold) call every array message is
243
+ // positional-replayed, so the floor must NOT apply.
244
+ const warmCall = ordinalState.seeded
245
+ if (!ordinalState.durableLoaded) {
246
+ // Load once per (re)established state; on LRU eviction the state is dropped
247
+ // and reloaded here, so the high-water survives eviction too. Best-effort.
248
+ ordinalState.durable = new Map()
249
+ try {
250
+ const raw = await loadOrdinalHighWater(sessionId)
251
+ if (raw && typeof raw === 'object') {
252
+ for (const [k, v] of Object.entries(raw)) {
253
+ const n = Number(v)
254
+ if (Number.isFinite(n) && n > 0) ordinalState.durable.set(k, Math.floor(n))
255
+ }
256
+ }
257
+ } catch { /* absent/corrupt state file → in-memory only (invariant 2 safe) */ }
258
+ ordinalState.durableLoaded = true
259
+ }
260
+ if (!ordinalState.seeded) {
261
+ // Replay identity/exclude logic over messages[0, start) so occNext reflects
262
+ // full-array position (and any surviving recorded ordinals) even when this
263
+ // call ingests only a later window. Runs once per fresh/evicted state; a
264
+ // warm state already carries the counts, so re-seeding would double-count.
265
+ for (let i = 0; i < start; i += 1) {
266
+ const m = messages[i]
267
+ if (!m || typeof m !== 'object') continue
268
+ // Cached (WeakMap-keyed) so a repeated hydrate over the SAME prefix
269
+ // objects only pays the clean/normalize cost once per message.
270
+ const fields = _ingestIdentityFields(m)
271
+ if (!fields) continue
272
+ _assignOccurrence(occNext, `${fields.role}\u0000${fields.content}`, m)
273
+ }
274
+ ordinalState.seeded = true
275
+ }
276
+ for (let i = start; i < messages.length; i += 1) {
277
+ const m = messages[i]
278
+ if (!m || typeof m !== 'object') continue
279
+ const role = normalizeIngestRole(m.role)
280
+ // ingest_session persists user/assistant only; system/developer/tool rows
281
+ // are dropped so recall-fasttrack summaries stay conversation-focused and
282
+ // do not re-inject content already in the protected system prefix.
283
+ if (!role) continue
284
+ // Exclude synthetic / non-conversation rows entirely (reference-files
285
+ // injections, compaction summaries, protected-context `.` acks, internal
286
+ // runtime nudges). These are mechanical noise, not conversation.
287
+ if (shouldExcludeIngestMessage(m)) continue
288
+ // Pure-conversation shaping: only the human/model prose text. The ingest
289
+ // shaper NEVER inlines tool_call / tool_result traces and strips the
290
+ // deterministic manager.mjs user-turn prefix envelopes (# Session /
291
+ // # Additional context / # Prefetch / # Task) so only the real human
292
+ // prompt remains. cleanMemoryText then removes the <system-reminder> block
293
+ // and residual transcript noise.
294
+ const content = cleanMemoryText(sessionMessageContentForIngest(m))
295
+ if (!content || !content.trim()) continue
296
+ considered += 1
297
+ const tsMs = parseTsToMs(m.ts ?? m.timestamp ?? (Date.now() - (messages.length - i)))
298
+ // Assign the next monotonic turn BEFORE building the source_ref so identical
299
+ // untimestamped repeats get distinct identities (peekNext is stable until a
300
+ // row is actually inserted → next()).
301
+ const assignedTurn = turnAllocator.peekNext()
302
+ // Stable occurrence index for this identity (pre-hash; role+content is a
303
+ // sufficient discriminator for the duplicate case the ordinal disambiguates).
304
+ // See the distinction-rule comment above and _assignOccurrence. For an
305
+ // UNTIMESTAMPED turn a WARM first-seen assignment is floored by the durable
306
+ // per-identity high-water so a genuine post-restart/eviction append lands
307
+ // above every persisted copy; the durable high-water is then advanced.
308
+ const occKey = `${role}\u0000${content}`
309
+ const rawTs = m.ts ?? m.timestamp
310
+ const untimestamped = !((typeof rawTs === 'number' && Number.isFinite(rawTs))
311
+ || (typeof rawTs === 'string' && rawTs.trim()))
312
+ const idHash = untimestamped ? _identityHash(occKey) : null
313
+ const floor = (warmCall && untimestamped) ? (ordinalState.durable.get(idHash) ?? 0) : 0
314
+ const occurrence = _assignOccurrence(occNext, occKey, m, floor)
315
+ if (untimestamped && occurrence >= 1) {
316
+ // Duplicate untimestamped identity (occurrence>0): record/raise its durable
317
+ // next-ordinal (write-behind persisted below). Monotonic — never regresses
318
+ // a loaded K, so a cold replay counting only survivors can't shrink it.
319
+ const cur = ordinalState.durable.get(idHash) ?? 0
320
+ if (occurrence + 1 > cur) { ordinalState.durable.set(idHash, occurrence + 1); ordinalState.dirty = true }
321
+ }
322
+ // Stable per-message identity. The previous `session:${id}#${i+1}` key was
323
+ // positional, so after compaction shrinks/reindexes session.messages a
324
+ // later turn could reuse an old index and be silently skipped by
325
+ // ON CONFLICT DO NOTHING. stableSessionSourceRef hashes only durable
326
+ // fields (role, original ts if present, tool ids, content) — never the
327
+ // synthesized tsMs fallback or the loop index. For untimestamped turns the
328
+ // monotonic ordinal is folded in so genuine repeats persist (not collapsed).
329
+ const sourceRef = stableSessionSourceRef(sessionId, m, role, content, occurrence)
330
+ const result = await db.query(`
331
+ INSERT INTO entries(ts, role, content, source_ref, session_id, source_turn, project_id)
332
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
333
+ ON CONFLICT DO NOTHING
334
+ RETURNING id
335
+ `, [tsMs, role, content, sourceRef, sessionId, assignedTurn, projectId])
336
+ const rowInserted = Number(result.rowCount ?? result.affectedRows ?? 0) || 0
337
+ if (rowInserted > 0) {
338
+ inserted += rowInserted
339
+ const newId = result.rows?.[0]?.id
340
+ if (newId != null && Number.isFinite(Number(newId))) insertedIds.push(Number(newId))
341
+ turnAllocator.next()
342
+ }
343
+ }
344
+ // Write-behind persist of the durable untimestamped high-water. Not awaited:
345
+ // ingest latency is unchanged and a persist failure only degrades a rare
346
+ // post-restart append (never correctness of THIS call). Serialized per
347
+ // session by the chain, so writes cannot interleave for one session.
348
+ if (ordinalState.dirty) {
349
+ ordinalState.dirty = false
350
+ const snapshot = Object.fromEntries(ordinalState.durable)
351
+ Promise.resolve()
352
+ .then(() => saveOrdinalHighWater(sessionId, snapshot))
353
+ .catch((err) => log(`[ingest] untimestamped high-water persist failed: ${err?.message || err}\n`))
354
+ }
355
+ // Always-on post-ingest raw embedding: freshly ingested rows become
356
+ // dense-searchable immediately (autoclear/recall-fasttrack hydration,
357
+ // recall empty-fallback), without waiting for cycle1 chunking or the
358
+ // ~60s background tick. Local ONNX only — no LLM cost, so it runs
359
+ // regardless of the recap toggle.
360
+ //
361
+ // Two-tier flush so ingest never synchronously inherits OTHER calls'/
362
+ // sessions' raw-embedding backlog:
363
+ // 1) AWAITED (bounded) — scoped to exactly THIS call's insertedIds, so
364
+ // ingest_session resolves once its own rows are embedded and an
365
+ // immediately following recall sees them in the dense leg. A 15s cap
366
+ // keeps a cold ONNX warmup from wedging ingest. Id-scoped, so
367
+ // concurrent ingests embed disjoint row sets (SKIP LOCKED) without
368
+ // stacking redundant work — no chain serialization needed here.
369
+ // 2) BACKGROUND (not awaited) — an unscoped sweep of any PRE-EXISTING
370
+ // backlog (rows left by earlier calls / other sessions / the flush
371
+ // cap). Serialized on _rawEmbedFlushChain so bursts don't stack full
372
+ // backlog scans; the ~60s tick still sweeps whatever this misses.
373
+ if (insertedIds.length > 0) {
374
+ const run = flushRawEmbeddings(db, { limit: 200, ids: insertedIds })
375
+ .then((r) => {
376
+ if (r.attempted > 0) log(`[embed] post-ingest raw flush (own) attempted=${r.attempted} embedded=${r.embedded}\n`)
377
+ return r
378
+ })
379
+ .catch((err) => log(`[embed] post-ingest raw flush failed: ${err?.message || err}\n`))
380
+ let timer
381
+ await Promise.race([
382
+ run,
383
+ new Promise((resolve) => { timer = setTimeout(resolve, INGEST_EMBED_WAIT_MS) }),
384
+ ]).finally(() => clearTimeout(timer))
385
+ }
386
+ // Background backlog sweep — kicked, never awaited. Runs even when THIS
387
+ // call inserted 0 rows, so pre-existing backlog is not left waiting for
388
+ // the next scheduler tick.
389
+ _rawEmbedFlushChain = _rawEmbedFlushChain
390
+ .catch(() => {}) // never let a previous flush failure poison the chain
391
+ .then(() => flushRawEmbeddings(db, { limit: 200 }))
392
+ .then((r) => {
393
+ if (r.attempted > 0) log(`[embed] post-ingest raw flush (backlog) attempted=${r.attempted} embedded=${r.embedded}\n`)
394
+ return r
395
+ })
396
+ .catch((err) => log(`[embed] post-ingest raw backlog flush failed: ${err?.message || err}\n`))
397
+ return { text: `ingest_session: considered=${considered} inserted=${inserted} session=${sessionId}` }
398
+ }
399
+
400
+ return { ingestSessionMessages }
401
+ }