mixdog 0.9.14 → 0.9.16
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/package.json +1 -1
- package/scripts/bench/cache-probe-tasks.json +1 -1
- package/scripts/bench/lead-review-tasks-r3.json +1 -1
- package/scripts/bench/r4-mixed-tasks.json +1 -1
- package/scripts/bench/review-tasks.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/explore-bench.mjs +5 -4
- package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
- package/scripts/output-style-smoke.mjs +3 -3
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/termio-input-smoke.mjs +199 -0
- package/scripts/tool-efficiency-diag.mjs +88 -0
- package/scripts/tool-smoke.mjs +40 -24
- package/scripts/tui-frame-harness-shim.mjs +32 -0
- package/scripts/tui-frame-harness.mjs +306 -0
- package/src/agents/heavy-worker/AGENT.md +6 -7
- package/src/agents/worker/AGENT.md +6 -7
- package/src/lib/keychain-cjs.cjs +28 -11
- package/src/lib/rules-builder.cjs +6 -10
- package/src/mixdog-session-runtime.mjs +90 -20
- package/src/output-styles/simple.md +22 -24
- package/src/rules/agent/00-core.md +12 -11
- package/src/rules/agent/30-explorer.md +22 -21
- package/src/rules/lead/01-general.md +8 -8
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/lead/lead-brief.md +11 -12
- package/src/rules/shared/01-tool.md +25 -14
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +21 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
- package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +55 -0
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +22 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +33 -25
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
- package/src/runtime/channels/backends/telegram.mjs +29 -9
- package/src/runtime/channels/lib/event-queue.mjs +6 -2
- package/src/runtime/channels/lib/memory-client.mjs +66 -1
- package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
- package/src/runtime/memory/index.mjs +95 -8
- package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
- package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
- package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
- package/src/runtime/memory/lib/memory-embed.mjs +48 -0
- package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
- package/src/runtime/memory/lib/memory.mjs +88 -1
- package/src/runtime/memory/lib/session-ingest.mjs +34 -3
- package/src/runtime/memory/lib/trace-store.mjs +52 -3
- package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
- package/src/runtime/shared/child-guardian.mjs +4 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/singleton-owner.mjs +26 -0
- package/src/runtime/shared/tool-execution-contract.mjs +25 -0
- package/src/runtime/shared/transcript-writer.mjs +3 -1
- package/src/session-runtime/config-helpers.mjs +7 -2
- package/src/session-runtime/cwd-plugins.mjs +6 -1
- package/src/session-runtime/effort.mjs +6 -1
- package/src/session-runtime/plugin-mcp.mjs +116 -12
- package/src/session-runtime/settings-api.mjs +7 -1
- package/src/standalone/channel-worker.mjs +25 -3
- package/src/standalone/explore-tool.mjs +5 -5
- package/src/standalone/hook-bus/config.mjs +38 -2
- package/src/standalone/hook-bus/handlers.mjs +103 -11
- package/src/standalone/hook-bus.mjs +20 -6
- package/src/standalone/memory-runtime-proxy.mjs +40 -5
- package/src/tui/App.jsx +214 -30
- package/src/tui/app/core-memory-picker.mjs +6 -6
- package/src/tui/app/model-options.mjs +10 -4
- package/src/tui/app/settings-picker.mjs +5 -30
- package/src/tui/app/slash-commands.mjs +0 -1
- package/src/tui/app/slash-dispatch.mjs +0 -16
- package/src/tui/app/text-layout.mjs +57 -0
- package/src/tui/app/transcript-window.mjs +53 -2
- package/src/tui/app/use-mouse-input.mjs +60 -47
- package/src/tui/app/use-transcript-window.mjs +38 -10
- package/src/tui/components/PromptInput.jsx +18 -1
- package/src/tui/components/TextEntryPanel.jsx +97 -33
- package/src/tui/dist/index.mjs +567 -229
- package/src/tui/engine/tool-card-results.mjs +22 -5
- package/src/tui/engine.mjs +126 -7
- package/src/tui/index.jsx +4 -1
- package/src/workflows/default/WORKFLOW.md +27 -31
- package/vendor/ink/build/components/App.js +62 -17
- package/vendor/ink/build/ink.js +78 -3
- package/vendor/ink/build/input-parser.d.ts +9 -4
- package/vendor/ink/build/input-parser.js +45 -176
- package/vendor/ink/build/log-update.js +47 -2
- package/vendor/ink/build/termio-keypress.js +240 -0
- package/vendor/ink/build/termio-tokenize.js +253 -0
|
@@ -65,6 +65,7 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
|
|
|
65
65
|
const signal = options?.signal
|
|
66
66
|
throwIfAborted(signal)
|
|
67
67
|
const coalescedRetry = options?.coalescedRetry === true
|
|
68
|
+
const catchUpDrainPass = options?.catchUpDrainPass === true
|
|
68
69
|
const retryAttempt = Math.max(0, Number(options?.coalescedRetryAttempt || 0))
|
|
69
70
|
const maxRetries = resolveCoalesceMaxRetries(config, 3)
|
|
70
71
|
const requestSignature = makeCycleRequestSignature('cycle2', config, {
|
|
@@ -74,7 +75,13 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
|
|
|
74
75
|
const scheduleRetry = () => scheduleCoalescedCycleRetry(
|
|
75
76
|
db,
|
|
76
77
|
'cycle2',
|
|
77
|
-
() => runCycle2(db, config, {
|
|
78
|
+
() => runCycle2(db, config, {
|
|
79
|
+
...options,
|
|
80
|
+
signal: undefined,
|
|
81
|
+
catchUpDrainPass: false,
|
|
82
|
+
coalescedRetry: true,
|
|
83
|
+
coalescedRetryAttempt: retryAttempt + 1,
|
|
84
|
+
}, dataDir),
|
|
78
85
|
config,
|
|
79
86
|
requestSignature,
|
|
80
87
|
)
|
|
@@ -119,12 +126,15 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
|
|
|
119
126
|
let result = null
|
|
120
127
|
let coalescedRuns = 0
|
|
121
128
|
let coalescedRequests = 0
|
|
122
|
-
if (coalescedRetry) {
|
|
129
|
+
if (coalescedRetry && !catchUpDrainPass) {
|
|
123
130
|
const pending = await consumeCycleRequests(db, 'cycle2', requestSignature)
|
|
124
131
|
if (pending <= 0) return { ok: true, ...partial, skippedInFlight: false, coalescedRetryNoop: true }
|
|
125
132
|
coalescedRuns += 1
|
|
126
133
|
coalescedRequests += pending
|
|
127
134
|
__mixdogMemoryLog(`[cycle2] retrying coalesced requests=${pending}\n`)
|
|
135
|
+
} else if (coalescedRetry && catchUpDrainPass) {
|
|
136
|
+
coalescedRuns += 1
|
|
137
|
+
__mixdogMemoryLog('[cycle2] catch-up drain pass (direct)\n')
|
|
128
138
|
}
|
|
129
139
|
try {
|
|
130
140
|
result = await _runCycle2Impl(db, config, options, dataDir)
|
|
@@ -135,7 +145,7 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
|
|
|
135
145
|
}
|
|
136
146
|
throw err
|
|
137
147
|
}
|
|
138
|
-
const maxDrains = resolveCoalesceMaxDrains(config, 1)
|
|
148
|
+
const maxDrains = catchUpDrainPass ? 0 : resolveCoalesceMaxDrains(config, 1)
|
|
139
149
|
let drainLoops = 0
|
|
140
150
|
while (drainLoops < maxDrains) {
|
|
141
151
|
throwIfAborted(signal)
|
|
@@ -251,7 +261,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
251
261
|
// below. Cleanup of duplicates/stale user-core overlap also runs via
|
|
252
262
|
// phase_merge / cycle3.
|
|
253
263
|
const rowsRes = await db.query(`
|
|
254
|
-
SELECT id, element, category, summary, score, last_seen_at, project_id, status
|
|
264
|
+
SELECT id, element, category, summary, score, last_seen_at, project_id, status, reviewed_at
|
|
255
265
|
FROM entries
|
|
256
266
|
WHERE is_root = 1
|
|
257
267
|
AND (status = 'pending' OR ($2::boolean AND status = 'active'))
|
|
@@ -275,7 +285,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
275
285
|
if (activeRecheckQuota > 0 && activeCount > 0) {
|
|
276
286
|
const seen = new Set(rows.map(r => Number(r.id)))
|
|
277
287
|
const recheckRes = await db.query(`
|
|
278
|
-
SELECT id, element, category, summary, score, last_seen_at, project_id, status
|
|
288
|
+
SELECT id, element, category, summary, score, last_seen_at, project_id, status, reviewed_at
|
|
279
289
|
FROM entries
|
|
280
290
|
WHERE is_root = 1 AND status = 'active'
|
|
281
291
|
ORDER BY reviewed_at ASC NULLS FIRST, score ASC, id ASC
|
|
@@ -402,17 +412,20 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
402
412
|
|
|
403
413
|
if (a.action === 'active') {
|
|
404
414
|
if (row.status === 'pending') {
|
|
405
|
-
statusBatch.push({ entry_id: id, new_status: 'active', was_pending: true })
|
|
415
|
+
statusBatch.push({ entry_id: id, new_status: 'active', was_pending: true, expected: { status: row.status, reviewedAt: row.reviewed_at } })
|
|
406
416
|
} else if (row.status === 'active') {
|
|
407
417
|
stats.kept += 1
|
|
408
418
|
}
|
|
409
419
|
await setCoreSummary(id, explicitCore)
|
|
410
420
|
accepted = true
|
|
411
421
|
} else if (a.action === 'archived') {
|
|
412
|
-
statusBatch.push({ entry_id: id, new_status: 'archived', was_pending: row.status === 'pending' })
|
|
422
|
+
statusBatch.push({ entry_id: id, new_status: 'archived', was_pending: row.status === 'pending', expected: { status: row.status, reviewedAt: row.reviewed_at } })
|
|
413
423
|
accepted = true
|
|
414
424
|
} else if (a.action === 'update') {
|
|
415
|
-
|
|
425
|
+
// Optimistic guard: skip if the row moved since the gate read it
|
|
426
|
+
// (concurrent cycle/recall write). row is the snapshot the verdict
|
|
427
|
+
// was computed against.
|
|
428
|
+
if (await applyUpdate(db, id, a.element, a.summary, { signal, expected: { status: row.status, reviewedAt: row.reviewed_at } })) stats.updated += 1
|
|
416
429
|
await setCoreSummary(id, explicitCore)
|
|
417
430
|
accepted = true
|
|
418
431
|
} else if (a.action === 'merge') {
|
|
@@ -14,6 +14,52 @@ import { createHash } from 'crypto'
|
|
|
14
14
|
// flag set by a different DB.
|
|
15
15
|
const _embCacheReady = new WeakSet()
|
|
16
16
|
|
|
17
|
+
// Opportunistic retention for embedding_cache. Unbounded growth otherwise:
|
|
18
|
+
// every distinct embedded text leaves a permanent row. Prune by row-count cap
|
|
19
|
+
// (oldest inserted rows first) so the working set of hot vectors survives while
|
|
20
|
+
// the tail is reclaimed. Applied opportunistically after cache writes, rate-
|
|
21
|
+
// limited per db handle so it never runs on the hot path more than once/interval.
|
|
22
|
+
const EMB_CACHE_MAX_ROWS = (() => {
|
|
23
|
+
const n = Number(process.env.MIXDOG_EMBED_CACHE_MAX_ROWS)
|
|
24
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 200_000
|
|
25
|
+
})()
|
|
26
|
+
const EMB_CACHE_PRUNE_INTERVAL_MS = 10 * 60_000
|
|
27
|
+
const _embCachePruneAt = new WeakMap() // db → next-allowed prune epoch ms
|
|
28
|
+
|
|
29
|
+
async function maybePruneEmbCache(db) {
|
|
30
|
+
const now = Date.now()
|
|
31
|
+
const next = _embCachePruneAt.get(db) ?? 0
|
|
32
|
+
if (now < next) return
|
|
33
|
+
_embCachePruneAt.set(db, now + EMB_CACHE_PRUNE_INTERVAL_MS)
|
|
34
|
+
try {
|
|
35
|
+
// Only prune when actually over the cap.
|
|
36
|
+
const { rows } = await db.query(`SELECT count(*)::bigint AS n FROM memory.embedding_cache`)
|
|
37
|
+
let over = Number(rows?.[0]?.n ?? 0) - EMB_CACHE_MAX_ROWS
|
|
38
|
+
if (over <= 0) return
|
|
39
|
+
// Delete OLDEST rows first (ctid ascending ≈ insertion age) in bounded
|
|
40
|
+
// batches so a large backlog never takes one long ACCESS-heavy DELETE that
|
|
41
|
+
// locks the table. Each batch caps at PRUNE_BATCH; loop until under cap.
|
|
42
|
+
const PRUNE_BATCH = 5000
|
|
43
|
+
let guard = 0
|
|
44
|
+
while (over > 0 && guard++ < 1000) {
|
|
45
|
+
const del = await db.query(
|
|
46
|
+
`DELETE FROM memory.embedding_cache
|
|
47
|
+
WHERE ctid IN (
|
|
48
|
+
SELECT ctid FROM memory.embedding_cache
|
|
49
|
+
ORDER BY ctid ASC
|
|
50
|
+
LIMIT $1
|
|
51
|
+
)`,
|
|
52
|
+
[Math.min(PRUNE_BATCH, over)],
|
|
53
|
+
)
|
|
54
|
+
const n = Number(del?.rowCount ?? 0)
|
|
55
|
+
if (n === 0) break
|
|
56
|
+
over -= n
|
|
57
|
+
}
|
|
58
|
+
} catch (err) {
|
|
59
|
+
__mixdogMemoryLog(`[embed] cache prune skipped: ${err?.message || err}\n`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
17
63
|
export function inferChunkProjectId(members) {
|
|
18
64
|
const storedIds = new Set()
|
|
19
65
|
for (const m of members) {
|
|
@@ -103,6 +149,8 @@ export async function cachedEmbedTextBatch(db, texts, options = {}) {
|
|
|
103
149
|
throwIfAborted(signal)
|
|
104
150
|
for (const e of misses) hitMap.set(e.hash.toString('hex'), e.vector)
|
|
105
151
|
}
|
|
152
|
+
// Opportunistic retention cap (rate-limited per db).
|
|
153
|
+
await maybePruneEmbCache(db)
|
|
106
154
|
|
|
107
155
|
return entries.map(e => hitMap.get(e.hash.toString('hex')) ?? null)
|
|
108
156
|
}
|
|
@@ -15,6 +15,16 @@ import { recallReadQuery } from './memory-recall-read-query.mjs'
|
|
|
15
15
|
const _MV_HOT_ACTIVE_TTL_MS = 60_000
|
|
16
16
|
const _mvHotActiveCache = new WeakMap() // db → { populated: boolean, ts: number }
|
|
17
17
|
const SEMANTIC_ONLY_MIN_SIM = 0.72
|
|
18
|
+
// Member-hit time gate: a chunk MEMBER whose own ts falls inside the requested
|
|
19
|
+
// [ts_from, ts_to] window is an in-window match even when its ROOT's ts sits
|
|
20
|
+
// outside it. Returns true when the member ts is within the (open-ended) window.
|
|
21
|
+
function memberTsInWindow(row, tsFrom, tsTo) {
|
|
22
|
+
const ts = Number(row?.ts)
|
|
23
|
+
if (!Number.isFinite(ts)) return true // undated member: don't drop on window
|
|
24
|
+
if (tsFrom != null && ts < tsFrom) return false
|
|
25
|
+
if (tsTo != null && ts > tsTo) return false
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
18
28
|
// Cross-language semantic rescue: a query in one language against rows in
|
|
19
29
|
// another rarely clears SEMANTIC_ONLY_MIN_SIM on a small (384-dim) embedding
|
|
20
30
|
// model, yet the TOP dense ranks are still overwhelmingly relevant. Accept
|
|
@@ -209,10 +219,10 @@ export async function searchRelevantHybrid(db, query, options = {}) {
|
|
|
209
219
|
// buildFilterClause: pushes ts/status/scope filters INTO candidate SELECTs.
|
|
210
220
|
// offset = 1-based index of the first bind param it may consume.
|
|
211
221
|
// Returns { clause: string, params: any[] }; clause begins with AND or is ''.
|
|
212
|
-
function buildFilterClause(offset) {
|
|
222
|
+
function buildFilterClause(offset, opts = {}) {
|
|
213
223
|
return buildRecallScopeFilter(offset, {
|
|
214
|
-
ts_from: tsFrom,
|
|
215
|
-
ts_to: tsTo,
|
|
224
|
+
ts_from: opts.skipTsWindow ? null : tsFrom,
|
|
225
|
+
ts_to: opts.skipTsWindow ? null : tsTo,
|
|
216
226
|
excludeStatuses,
|
|
217
227
|
category: categories,
|
|
218
228
|
projectScope,
|
|
@@ -602,6 +612,12 @@ LEFT JOIN exact x ON x.id = c.id`
|
|
|
602
612
|
} else if (row.chunk_root != null && row.chunk_root !== row.id) {
|
|
603
613
|
const r = rootById.get(Number(row.chunk_root))
|
|
604
614
|
if (!r) continue
|
|
615
|
+
// Time-filter on the MEMBER's own ts before resolving to the root. A
|
|
616
|
+
// member match that falls inside the requested [ts_from, ts_to] window
|
|
617
|
+
// was previously dropped when its ROOT's ts sat outside the window (the
|
|
618
|
+
// final fetch filters on root ts). Gate the member here on its own ts so
|
|
619
|
+
// in-window member hits survive root resolution.
|
|
620
|
+
if (!memberTsInWindow(row, tsFrom, tsTo)) continue
|
|
605
621
|
memberHitRootIds.add(r.id)
|
|
606
622
|
targetRow = r
|
|
607
623
|
} else {
|
|
@@ -642,16 +658,44 @@ LEFT JOIN exact x ON x.id = c.id`
|
|
|
642
658
|
|
|
643
659
|
// ── Final fetch: full row for each root by id = ANY(bigint[]) ────────────
|
|
644
660
|
const topIds = rootIdsForReturn.map(x => Number(x.root.id))
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
661
|
+
// Roots reached via an in-window MEMBER hit must NOT be re-dropped by the
|
|
662
|
+
// final ts window filter: the root's own ts can legitimately sit outside the
|
|
663
|
+
// window even though a member matched inside it (member ts already gated
|
|
664
|
+
// above). Fetch member-hit roots with a status/scope-only filter (no ts
|
|
665
|
+
// window); fetch the rest with the full window filter; merge.
|
|
666
|
+
const memberHitExemptIds = [...memberHitRootIds].map(Number)
|
|
667
|
+
let finalRows
|
|
668
|
+
if (memberHitExemptIds.length > 0) {
|
|
669
|
+
const exemptSet = new Set(memberHitExemptIds)
|
|
670
|
+
const nonExempt = topIds.filter(id => !exemptSet.has(id))
|
|
671
|
+
// Window+status filter for non-member-hit roots.
|
|
672
|
+
const { clause: winFilter, params: winParams } = buildFilterClause(2)
|
|
673
|
+
// Status/scope-only filter (no ts window) for member-hit roots.
|
|
674
|
+
const { clause: statusFilter, params: statusParams } = buildFilterClause(2, { skipTsWindow: true })
|
|
675
|
+
const [a, b] = await Promise.all([
|
|
676
|
+
nonExempt.length > 0
|
|
677
|
+
? recallReadQuery(db,
|
|
678
|
+
`SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
679
|
+
element, category, summary, project_id, status, score, last_seen_at
|
|
680
|
+
FROM entries WHERE id = ANY($1::bigint[]) ${winFilter}`,
|
|
681
|
+
[nonExempt, ...winParams])
|
|
682
|
+
: Promise.resolve({ rows: [] }),
|
|
683
|
+
recallReadQuery(db,
|
|
684
|
+
`SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
685
|
+
element, category, summary, project_id, status, score, last_seen_at
|
|
686
|
+
FROM entries WHERE id = ANY($1::bigint[]) ${statusFilter}`,
|
|
687
|
+
[memberHitExemptIds, ...statusParams]),
|
|
688
|
+
])
|
|
689
|
+
finalRows = [...a.rows, ...b.rows]
|
|
690
|
+
} else {
|
|
691
|
+
const { clause: winFilter, params: winParams } = buildFilterClause(2)
|
|
692
|
+
const r = await recallReadQuery(db,
|
|
693
|
+
`SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
694
|
+
element, category, summary, project_id, status, score, last_seen_at
|
|
695
|
+
FROM entries WHERE id = ANY($1::bigint[]) ${winFilter}`,
|
|
696
|
+
[topIds, ...winParams])
|
|
697
|
+
finalRows = r.rows
|
|
698
|
+
}
|
|
655
699
|
const finalById = new Map(finalRows.map(r => [Number(r.id), r]))
|
|
656
700
|
|
|
657
701
|
// Members: single batch fetch keyed by chunk_root = ANY($1) — one
|
|
@@ -10,6 +10,8 @@ import { ensurePgInstance, closePgInstance, withSchemaBootstrapLock } from './pg
|
|
|
10
10
|
import { mkdirSync } from 'fs'
|
|
11
11
|
import { resolve } from 'path'
|
|
12
12
|
import { cleanMemoryText } from './memory-extraction.mjs'
|
|
13
|
+
import { isInternalRuntimeNotificationText, isModelVisibleToolCompletionWrapper } from '../../shared/tool-execution-contract.mjs'
|
|
14
|
+
import { isUnquotedToolCompletionHead } from './session-ingest.mjs'
|
|
13
15
|
|
|
14
16
|
const dbs = new Map()
|
|
15
17
|
const opening = new Map()
|
|
@@ -421,6 +423,61 @@ export async function ensureCurrentSchemaExtensions(db, dims) {
|
|
|
421
423
|
} catch (err) {
|
|
422
424
|
__mixdogMemoryLog(`[memory] attachment-placeholder cleanup failed: ${err?.message || err}\n`)
|
|
423
425
|
}
|
|
426
|
+
// One-time cleanup: runtime tool-completion notification rows ("The async
|
|
427
|
+
// shell/agent task ... has finished ... - review this result in your next
|
|
428
|
+
// step." followed by an unquoted or `> `-quoted Result body, and
|
|
429
|
+
// "[mixdog-runtime] ..." nudges) that were persisted by the transcript
|
|
430
|
+
// watcher before it gained the shouldExcludeIngestMessage exclusion
|
|
431
|
+
// (transcript-ingest.mjs). Gated by a meta flag so this DELETE runs at
|
|
432
|
+
// most once ever, not on every boot (mirrors boot.schema_bootstrap_complete).
|
|
433
|
+
// SQL prefilter narrows candidates to role='user' rows only; the DELETE
|
|
434
|
+
// itself only fires for rows the SAME JS predicates ingest applies confirm
|
|
435
|
+
// as internal-runtime text — a row that merely starts with "background
|
|
436
|
+
// task" prose is never deleted unless isInternalRuntimeNotificationText
|
|
437
|
+
// (or the instruction-head match) itself confirms it. Best-effort so a
|
|
438
|
+
// failure never blocks boot.
|
|
439
|
+
const NOTIFICATION_CLEANUP_META_KEY = 'cleanup.notification_rows_v1'
|
|
440
|
+
try {
|
|
441
|
+
const already = await db.query(`SELECT 1 FROM meta WHERE key = $1`, [NOTIFICATION_CLEANUP_META_KEY])
|
|
442
|
+
if (!already?.rows?.length) {
|
|
443
|
+
const candidates = await db.query(
|
|
444
|
+
`SELECT id, content FROM entries
|
|
445
|
+
WHERE role = 'user' AND (
|
|
446
|
+
content LIKE 'The async % task % has finished%'
|
|
447
|
+
OR content LIKE '[mixdog-runtime]%'
|
|
448
|
+
OR content LIKE 'background task%'
|
|
449
|
+
)`,
|
|
450
|
+
)
|
|
451
|
+
const rows = Array.isArray(candidates?.rows) ? candidates.rows : []
|
|
452
|
+
const idsToDelete = rows
|
|
453
|
+
.filter((row) => {
|
|
454
|
+
const text = String(row?.content ?? '')
|
|
455
|
+
return /^\[mixdog-runtime\]/.test(text.trimStart())
|
|
456
|
+
|| isInternalRuntimeNotificationText(text)
|
|
457
|
+
|| isModelVisibleToolCompletionWrapper(text)
|
|
458
|
+
|| isUnquotedToolCompletionHead(text)
|
|
459
|
+
})
|
|
460
|
+
.map((row) => row.id)
|
|
461
|
+
.filter((id) => id != null)
|
|
462
|
+
if (idsToDelete.length > 0) {
|
|
463
|
+
const deleted = await db.query(
|
|
464
|
+
`DELETE FROM entries WHERE id = ANY($1::bigint[])`,
|
|
465
|
+
[idsToDelete],
|
|
466
|
+
)
|
|
467
|
+
const n = Number(deleted?.rowCount ?? idsToDelete.length)
|
|
468
|
+
if (n > 0) {
|
|
469
|
+
__mixdogMemoryLog(`[memory] ensureCurrentSchemaExtensions: removed ${n} runtime notification rows\n`)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
await db.query(
|
|
473
|
+
`INSERT INTO meta(key, value) VALUES ($1, $2::jsonb)
|
|
474
|
+
ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value`,
|
|
475
|
+
[NOTIFICATION_CLEANUP_META_KEY, JSON.stringify('1')],
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
} catch (err) {
|
|
479
|
+
__mixdogMemoryLog(`[memory] notification-row cleanup failed: ${err?.message || err}\n`)
|
|
480
|
+
}
|
|
424
481
|
// core_entries gained an embedding column for cross-table semantic dedup
|
|
425
482
|
// between user-curated rows and cycle2-promoted entries. ALTER + index are
|
|
426
483
|
// idempotent and define the current runtime schema.
|
|
@@ -575,7 +632,37 @@ export async function closeDatabase(dataDir) {
|
|
|
575
632
|
export async function isBootstrapComplete(db) {
|
|
576
633
|
try {
|
|
577
634
|
const r = await db.query(`SELECT 1 FROM meta WHERE key = 'boot.schema_bootstrap_complete'`)
|
|
578
|
-
|
|
635
|
+
if (r.rows.length === 0) return false
|
|
636
|
+
// The meta flag alone is not proof the schema is CURRENT: an older cluster
|
|
637
|
+
// whose pgdata predates a column/type addition can carry the flag while the
|
|
638
|
+
// physical schema has drifted, and trusting the flag would skip init() and
|
|
639
|
+
// leave inserts failing forever. Verify the critical objects init() creates
|
|
640
|
+
// actually exist; on drift return false so the caller re-runs init() (all
|
|
641
|
+
// DDL there is IF NOT EXISTS / additive, so re-running is a safe no-op on a
|
|
642
|
+
// healthy DB and self-heals a drifted one — no destructive change).
|
|
643
|
+
const drift = await db.query(`
|
|
644
|
+
SELECT
|
|
645
|
+
EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
646
|
+
WHERE t.typname = 'entry_status' AND n.nspname = 'memory') AS has_status_type,
|
|
647
|
+
EXISTS (SELECT 1 FROM information_schema.tables
|
|
648
|
+
WHERE table_schema = 'memory' AND table_name = 'entries') AS has_entries,
|
|
649
|
+
EXISTS (SELECT 1 FROM information_schema.columns
|
|
650
|
+
WHERE table_schema = 'memory' AND table_name = 'entries'
|
|
651
|
+
AND column_name = 'status') AS has_status_col,
|
|
652
|
+
EXISTS (SELECT 1 FROM information_schema.columns
|
|
653
|
+
WHERE table_schema = 'memory' AND table_name = 'entries'
|
|
654
|
+
AND column_name = 'chunk_root') AS has_chunk_root,
|
|
655
|
+
EXISTS (SELECT 1 FROM information_schema.tables
|
|
656
|
+
WHERE table_schema = 'memory' AND table_name = 'meta') AS has_meta
|
|
657
|
+
`)
|
|
658
|
+
const d = drift.rows?.[0] ?? {}
|
|
659
|
+
const healthy = d.has_status_type && d.has_entries && d.has_status_col
|
|
660
|
+
&& d.has_chunk_root && d.has_meta
|
|
661
|
+
if (!healthy) {
|
|
662
|
+
__mixdogMemoryLog(`[memory] bootstrap flag present but schema drifted (${JSON.stringify(d)}); re-running init()\n`)
|
|
663
|
+
return false
|
|
664
|
+
}
|
|
665
|
+
return true
|
|
579
666
|
} catch {
|
|
580
667
|
return false
|
|
581
668
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
|
-
import { isInternalRuntimeNotificationText } from '../../shared/tool-execution-contract.mjs'
|
|
2
|
+
import { isInternalRuntimeNotificationText, isModelVisibleToolCompletionWrapper } from '../../shared/tool-execution-contract.mjs'
|
|
3
3
|
|
|
4
4
|
// Side-effect-free helpers for ingest_session (recall fast-track hydration).
|
|
5
5
|
// Extracted from memory/index.mjs so the pure logic (stable identity, sensitive
|
|
@@ -52,14 +52,20 @@ function toolIdentityIds(m) {
|
|
|
52
52
|
// regardless of its position after compaction shrinks/reindexes the transcript.
|
|
53
53
|
// Two textually identical untimestamped plain messages intentionally dedupe to
|
|
54
54
|
// one row (stable dedupe preferred over positional separation).
|
|
55
|
-
export function stableSessionSourceRef(sessionId, m, role, content) {
|
|
55
|
+
export function stableSessionSourceRef(sessionId, m, role, content, ordinal) {
|
|
56
56
|
const toolIds = toolIdentityIds(m)
|
|
57
57
|
// Only an ORIGINAL, caller-supplied timestamp counts as durable identity.
|
|
58
58
|
const rawTs = m?.ts ?? m?.timestamp
|
|
59
59
|
const originalTs = (typeof rawTs === 'number' && Number.isFinite(rawTs)) || (typeof rawTs === 'string' && rawTs.trim())
|
|
60
60
|
? String(rawTs)
|
|
61
61
|
: ''
|
|
62
|
-
|
|
62
|
+
// Untimestamped, textually-identical turns previously collapsed to one row
|
|
63
|
+
// (same hash). Fold a stable ORDINAL (caller-supplied turn/index) into the
|
|
64
|
+
// identity so genuine repeats persist as distinct rows. The ordinal is only
|
|
65
|
+
// used when no durable original ts exists — a timestamped turn keeps its
|
|
66
|
+
// compaction-stable identity independent of array position.
|
|
67
|
+
const ordinalPart = originalTs ? '' : (Number.isFinite(Number(ordinal)) ? String(Math.floor(Number(ordinal))) : '')
|
|
68
|
+
const identity = [role, originalTs, ordinalPart, toolIds.join(','), content].join('\u0000')
|
|
63
69
|
const hash = crypto.createHash('sha256').update(identity).digest('hex').slice(0, 24)
|
|
64
70
|
return `session:${sessionId}:${hash}`
|
|
65
71
|
}
|
|
@@ -262,6 +268,24 @@ export function sessionMessageContentForIngest(m) {
|
|
|
262
268
|
return base
|
|
263
269
|
}
|
|
264
270
|
|
|
271
|
+
// Head-line shape of toolCompletionInstruction() (tool-execution-contract.mjs)
|
|
272
|
+
// as it is ACTUALLY persisted by mgr.enqueuePendingMessage — UNQUOTED, no
|
|
273
|
+
// `> ` prefix on the Result body (real DB rows look like:
|
|
274
|
+
// "The async shell task <id> has finished (completed, exit 0) - review this
|
|
275
|
+
// result in your next step.\nResult:\nbackground task\ntask_id: ...").
|
|
276
|
+
// isModelVisibleToolCompletionWrapper only matches the QUOTED (`> `) shape
|
|
277
|
+
// mirrored via the notify-wrapper's own quoting, so it misses this unquoted
|
|
278
|
+
// persisted form. The instruction head alone is a sufficient, unambiguous
|
|
279
|
+
// fingerprint (only the runtime ever emits this exact phrase).
|
|
280
|
+
const UNQUOTED_TOOL_COMPLETION_HEAD_RE = /^The async (?:shell task|agent task|\S+ execution|\S+) .*has finished\b.*review this result in your next step/i
|
|
281
|
+
|
|
282
|
+
// Exported so memory.mjs's one-time cleanup (ensureCurrentSchemaExtensions)
|
|
283
|
+
// can confirm SQL-prefiltered candidate rows with the SAME predicate the live
|
|
284
|
+
// ingest filter uses, rather than re-deriving the regex.
|
|
285
|
+
export function isUnquotedToolCompletionHead(text) {
|
|
286
|
+
return UNQUOTED_TOOL_COMPLETION_HEAD_RE.test(String(text ?? '').trimStart())
|
|
287
|
+
}
|
|
288
|
+
|
|
265
289
|
// Row-exclusion predicate for ingest_session. Synthetic / non-conversation
|
|
266
290
|
// rows (reference-files injections, compaction summaries, protected-context
|
|
267
291
|
// `.` acks, internal runtime nudges) are dropped ENTIRELY — they are noise,
|
|
@@ -298,6 +322,13 @@ export function shouldExcludeIngestMessage(m) {
|
|
|
298
322
|
const text = typeof raw === 'string' ? raw : ''
|
|
299
323
|
if (/^\[mixdog-runtime\]/.test(text.trimStart())) return true
|
|
300
324
|
if (isInternalRuntimeNotificationText(text)) return true
|
|
325
|
+
// Model-visible tool-completion mirror rows (mgr.enqueuePendingMessage of
|
|
326
|
+
// modelVisibleToolCompletionMessage — "The async shell/agent task ... has
|
|
327
|
+
// finished ... - review this result in your next step.\n\nResult:\n> ...").
|
|
328
|
+
// These are runtime notifications, not conversation, on both ingest paths.
|
|
329
|
+
if (isModelVisibleToolCompletionWrapper(text)) return true
|
|
330
|
+
// Unquoted persisted form of the same wrapper (see comment above).
|
|
331
|
+
if (isUnquotedToolCompletionHead(text)) return true
|
|
301
332
|
}
|
|
302
333
|
return false
|
|
303
334
|
}
|
|
@@ -146,12 +146,23 @@ async function migrateSchemaDrift(client) {
|
|
|
146
146
|
// readers/writers indefinitely and wedge boot (reviewer Medium). 5s is
|
|
147
147
|
// generous for a metadata change; on timeout we leave the drift in place
|
|
148
148
|
// (inserts keep failing as before — no worse) instead of hanging startup.
|
|
149
|
-
|
|
149
|
+
//
|
|
150
|
+
// SET LOCAL only affects the enclosing transaction; with pg autocommit each
|
|
151
|
+
// stray statement was its own transaction, so `SET LOCAL lock_timeout` was a
|
|
152
|
+
// no-op that ended immediately and the ALTERs ran with the default (0 =
|
|
153
|
+
// unbounded) timeout — the exact wedge this was meant to prevent. Wrap the
|
|
154
|
+
// whole thing in an explicit transaction so SET LOCAL actually scopes the
|
|
155
|
+
// ALTERs; on any error roll back (leaving drift in place, inserts fail as
|
|
156
|
+
// before — no worse).
|
|
150
157
|
try {
|
|
158
|
+
await client.query('BEGIN')
|
|
159
|
+
await client.query(`SET LOCAL lock_timeout = '5s'`)
|
|
151
160
|
await client.query(`ALTER TABLE IF EXISTS trace_events ADD COLUMN IF NOT EXISTS agent TEXT`)
|
|
152
161
|
await client.query(`ALTER TABLE IF EXISTS agent_sessions ADD COLUMN IF NOT EXISTS agent TEXT`)
|
|
153
|
-
|
|
154
|
-
|
|
162
|
+
await client.query('COMMIT')
|
|
163
|
+
} catch (err) {
|
|
164
|
+
try { await client.query('ROLLBACK') } catch {}
|
|
165
|
+
__mixdogMemoryLog(`[trace-store] schema-drift migrate skipped: ${err?.message ?? err}\n`)
|
|
155
166
|
}
|
|
156
167
|
}
|
|
157
168
|
|
|
@@ -455,6 +466,43 @@ async function ensureCurrentAndNextMonthPartitions(client) {
|
|
|
455
466
|
`)
|
|
456
467
|
}
|
|
457
468
|
|
|
469
|
+
// Trace retention: drop named monthly partitions older than the configured
|
|
470
|
+
// window. Trace is regenerable observability data, so aging out old months is
|
|
471
|
+
// non-destructive to durable state. Config via MIXDOG_TRACE_RETENTION_MONTHS
|
|
472
|
+
// (default 6); set to 0 to disable. Only DROPs partitions strictly older than
|
|
473
|
+
// the cutoff month; the default catch-all partition is never dropped.
|
|
474
|
+
const TRACE_RETENTION_MONTHS = (() => {
|
|
475
|
+
const raw = process.env.MIXDOG_TRACE_RETENTION_MONTHS
|
|
476
|
+
if (raw == null || raw === '') return 6
|
|
477
|
+
const n = Number(raw)
|
|
478
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 6
|
|
479
|
+
})()
|
|
480
|
+
|
|
481
|
+
async function dropAgedTracePartitions(client) {
|
|
482
|
+
if (TRACE_RETENTION_MONTHS <= 0) return
|
|
483
|
+
// DO blocks take no bind params, so compute the cutoff month in SQL and pass
|
|
484
|
+
// the retention count as a literal (validated integer above — no injection).
|
|
485
|
+
const months = TRACE_RETENTION_MONTHS
|
|
486
|
+
await client.query(`
|
|
487
|
+
DO $$
|
|
488
|
+
DECLARE
|
|
489
|
+
cutoff TEXT := to_char(now() - interval '${months} months', 'YYYY_MM');
|
|
490
|
+
part RECORD;
|
|
491
|
+
BEGIN
|
|
492
|
+
FOR part IN
|
|
493
|
+
SELECT c.relname
|
|
494
|
+
FROM pg_class c
|
|
495
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
496
|
+
WHERE n.nspname = 'trace'
|
|
497
|
+
AND c.relname ~ '^trace_events_[0-9]{4}_[0-9]{2}$'
|
|
498
|
+
AND substring(c.relname from 'trace_events_(.*)') < cutoff
|
|
499
|
+
LOOP
|
|
500
|
+
EXECUTE format('DROP TABLE IF EXISTS trace.%I', part.relname);
|
|
501
|
+
END LOOP;
|
|
502
|
+
END $$
|
|
503
|
+
`)
|
|
504
|
+
}
|
|
505
|
+
|
|
458
506
|
async function isBootstrapComplete(client) {
|
|
459
507
|
try {
|
|
460
508
|
// Harden the check: require (a) root is partitioned, (b) schema-version
|
|
@@ -579,6 +627,7 @@ export async function openTraceDatabase(dataDir) {
|
|
|
579
627
|
try {
|
|
580
628
|
await c.query(`SET search_path = trace, public`)
|
|
581
629
|
await ensureCurrentAndNextMonthPartitions(c)
|
|
630
|
+
await dropAgedTracePartitions(c)
|
|
582
631
|
} catch (err) {
|
|
583
632
|
__mixdogMemoryLog(`[trace-store] periodic partition ensure failed: ${err?.message ?? err}\n`)
|
|
584
633
|
} finally {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// this module stays a pure factory with no import-time side effects.
|
|
10
10
|
import fs from 'node:fs'
|
|
11
11
|
import path from 'node:path'
|
|
12
|
+
import { normalizeIngestRole, sessionMessageContentForIngest, shouldExcludeIngestMessage } from './session-ingest.mjs'
|
|
12
13
|
|
|
13
14
|
// Pure: coerce a transcript timestamp (seconds, ms, or ISO string) to ms.
|
|
14
15
|
export function parseTsToMs(value) {
|
|
@@ -107,8 +108,8 @@ export function createTranscriptIngest({
|
|
|
107
108
|
|
|
108
109
|
function snapshotTranscriptOffset(transcriptPath) {
|
|
109
110
|
const stored = _transcriptOffsets.get(transcriptPath)
|
|
110
|
-
if (!stored) return { bytes: 0, lineIndex: 0 }
|
|
111
|
-
return { bytes: Number(stored.bytes) || 0, lineIndex: Number(stored.lineIndex) || 0 }
|
|
111
|
+
if (!stored) return { bytes: 0, lineIndex: 0, generation: 0 }
|
|
112
|
+
return { bytes: Number(stored.bytes) || 0, lineIndex: Number(stored.lineIndex) || 0, generation: Number(stored.generation) || 0 }
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
async function ingestTranscriptFileImpl(transcriptPath, { cwd } = {}) {
|
|
@@ -117,9 +118,18 @@ export function createTranscriptIngest({
|
|
|
117
118
|
try { stat = await fs.promises.stat(transcriptPath) } catch { return 0 }
|
|
118
119
|
const sessionUuid = path.basename(transcriptPath, '.jsonl')
|
|
119
120
|
const prev = snapshotTranscriptOffset(transcriptPath)
|
|
121
|
+
// Generation counter: a truncate/rewrite of the transcript (size shrank
|
|
122
|
+
// below the persisted byte offset) resets bytes/lineIndex to 0, so the
|
|
123
|
+
// rewritten lines reuse the SAME transcript:${uuid}#${index} refs as the
|
|
124
|
+
// pre-truncate content. With ON CONFLICT DO NOTHING the rewritten rows were
|
|
125
|
+
// silently dropped. Bump a persisted per-file generation on each detected
|
|
126
|
+
// reset and fold it into source_ref so rewritten lines get fresh identities
|
|
127
|
+
// and persist. Existing rows (gen 0, no suffix) are unaffected.
|
|
128
|
+
let generation = Number(prev.generation) || 0
|
|
120
129
|
if (stat.size < prev.bytes) {
|
|
121
130
|
prev.bytes = 0
|
|
122
131
|
prev.lineIndex = 0
|
|
132
|
+
generation += 1
|
|
123
133
|
}
|
|
124
134
|
if (stat.size <= prev.bytes) return 0
|
|
125
135
|
|
|
@@ -180,7 +190,17 @@ export function createTranscriptIngest({
|
|
|
180
190
|
lastGoodLineIndex = index
|
|
181
191
|
continue
|
|
182
192
|
}
|
|
183
|
-
|
|
193
|
+
// Reuse the ingest_session shape/exclude predicates so the transcript
|
|
194
|
+
// watcher stores ONLY pure conversation rows — same purity as
|
|
195
|
+
// ingest_session (strips manager.mjs prefix envelopes, drops synthetic
|
|
196
|
+
// reference-files/compaction/ack/internal-notification rows).
|
|
197
|
+
const shaped = { role, content: parsed.message?.content }
|
|
198
|
+
if (shouldExcludeIngestMessage(shaped)) {
|
|
199
|
+
lastGoodBytes += consumedBytes
|
|
200
|
+
lastGoodLineIndex = index
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
const content = sessionMessageContentForIngest(shaped)
|
|
184
204
|
if (!content || !content.trim()) {
|
|
185
205
|
lastGoodBytes += consumedBytes
|
|
186
206
|
lastGoodLineIndex = index
|
|
@@ -193,7 +213,9 @@ export function createTranscriptIngest({
|
|
|
193
213
|
continue
|
|
194
214
|
}
|
|
195
215
|
const tsMs = parseTsToMs(parsed.timestamp ?? parsed.ts ?? Date.now())
|
|
196
|
-
const sourceRef =
|
|
216
|
+
const sourceRef = generation > 0
|
|
217
|
+
? `transcript:${sessionUuid}#${index}@g${generation}`
|
|
218
|
+
: `transcript:${sessionUuid}#${index}`
|
|
197
219
|
try {
|
|
198
220
|
const result = await db.query(
|
|
199
221
|
`INSERT INTO entries(ts, role, content, source_ref, session_id, source_turn, project_id)
|
|
@@ -215,6 +237,7 @@ export function createTranscriptIngest({
|
|
|
215
237
|
_transcriptOffsets.set(transcriptPath, {
|
|
216
238
|
bytes: lastGoodBytes,
|
|
217
239
|
lineIndex: lastGoodLineIndex,
|
|
240
|
+
generation,
|
|
218
241
|
})
|
|
219
242
|
await persistTranscriptOffsets()
|
|
220
243
|
return count
|
|
@@ -24,11 +24,13 @@ function alive(pid) {
|
|
|
24
24
|
}
|
|
25
25
|
function killTarget(force) {
|
|
26
26
|
if (platform === 'win32') {
|
|
27
|
+
const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\\\Windows';
|
|
28
|
+
const taskkill = systemRoot + '\\\\System32\\\\taskkill.exe';
|
|
27
29
|
if (force) {
|
|
28
|
-
try { spawnSync(
|
|
30
|
+
try { spawnSync(taskkill, ['/PID', String(childPid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }); } catch {}
|
|
29
31
|
} else {
|
|
30
32
|
try { process.kill(childPid, 'SIGTERM'); } catch {}
|
|
31
|
-
try { spawnSync(
|
|
33
|
+
try { spawnSync(taskkill, ['/PID', String(childPid), '/T'], { stdio: 'ignore', windowsHide: true }); } catch {}
|
|
32
34
|
}
|
|
33
35
|
return;
|
|
34
36
|
}
|
|
@@ -9,7 +9,8 @@ export function openInBrowser(url) {
|
|
|
9
9
|
const u = String(url);
|
|
10
10
|
let candidates;
|
|
11
11
|
if (process.platform === 'win32') {
|
|
12
|
-
|
|
12
|
+
const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
|
|
13
|
+
candidates = [[`${systemRoot}\\System32\\rundll32.exe`, ['url.dll,FileProtocolHandler', u]]];
|
|
13
14
|
} else if (process.platform === 'darwin') {
|
|
14
15
|
candidates = [['open', [u]]];
|
|
15
16
|
} else if (isWSL()) {
|
|
@@ -102,3 +102,29 @@ export function releaseSingletonOwner(path, pid = process.pid) {
|
|
|
102
102
|
return false;
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
// Atomic ownership handoff: replace the owner record from fromPid to toOwner
|
|
107
|
+
// under the SAME claim lock, so a concurrent claimSingletonOwner() cannot slip
|
|
108
|
+
// between a release() and a re-claim() and fork a competing daemon. Verifies
|
|
109
|
+
// the current on-disk pid is still fromPid before writing; if it drifted
|
|
110
|
+
// (someone else won), the handoff is refused (owned:false) and the caller
|
|
111
|
+
// should fall back to the winner rather than overwrite it.
|
|
112
|
+
export function handoffSingletonOwner(path, fromPid, toOwner = {}) {
|
|
113
|
+
const lockPath = acquireClaimLock(path);
|
|
114
|
+
if (!lockPath) return { owned: false, owner: readJson(path) };
|
|
115
|
+
try {
|
|
116
|
+
const current = readJson(path);
|
|
117
|
+
if (current?.pid && Number(current.pid) !== Number(fromPid)) {
|
|
118
|
+
return { owned: false, owner: current };
|
|
119
|
+
}
|
|
120
|
+
const owner = {
|
|
121
|
+
kind: 'runtime',
|
|
122
|
+
claimedAt: new Date().toISOString(),
|
|
123
|
+
...toOwner,
|
|
124
|
+
};
|
|
125
|
+
writeJsonAtomic(path, owner);
|
|
126
|
+
return { owned: true, owner };
|
|
127
|
+
} finally {
|
|
128
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
}
|