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.
- package/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- 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
|
+
}
|