mixdog 0.9.37 → 0.9.38

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 (27) hide show
  1. package/package.json +1 -1
  2. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  3. package/scripts/explore-bench.mjs +65 -8
  4. package/scripts/explore-prompt-policy-test.mjs +6 -2
  5. package/scripts/notify-completion-mirror-test.mjs +73 -0
  6. package/scripts/session-sweep.mjs +266 -0
  7. package/src/rules/agent/30-explorer.md +35 -17
  8. package/src/rules/shared/01-tool.md +7 -3
  9. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  10. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  11. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
  12. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
  13. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  14. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
  15. package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
  16. package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +6 -6
  18. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  19. package/src/session-runtime/provider-models.mjs +3 -3
  20. package/src/session-runtime/runtime-core.mjs +43 -8
  21. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  22. package/src/standalone/explore-tool.mjs +1 -1
  23. package/src/tui/dist/index.mjs +16 -1
  24. package/src/tui/engine/agent-job-feed.mjs +10 -0
  25. package/src/tui/engine/queue-helpers.mjs +8 -0
  26. package/src/tui/engine/session-flow.mjs +15 -1
  27. package/src/tui/engine/turn.mjs +6 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.37",
3
+ "version": "0.9.38",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -0,0 +1,141 @@
1
+ // Regression tests for recoverPending recovery bugs:
2
+ // (a) an entry must survive when notifyFn resolves false (not delivered) and
3
+ // only be removed after a confirmed (truthy) ack.
4
+ // (b) a scoped recovery that matches purely on clientHostPid must NOT stamp
5
+ // the reconnecting filter session's id onto another session's abort — it
6
+ // delivers to the true owner session, and leaves the entry persisted when
7
+ // there is no owner session to target.
8
+ import test from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import {
14
+ addPending,
15
+ recoverPending,
16
+ } from '../src/runtime/agent/orchestrator/dispatch-persist.mjs';
17
+
18
+ const FILE = 'pending-dispatches.json';
19
+
20
+ function readMap(dir) {
21
+ try {
22
+ const raw = readFileSync(join(dir, FILE), 'utf8');
23
+ return raw.trim() ? JSON.parse(raw) : {};
24
+ } catch { return {}; }
25
+ }
26
+
27
+ async function waitFor(fn, { timeout = 3000, step = 20 } = {}) {
28
+ const deadline = Date.now() + timeout;
29
+ for (;;) {
30
+ if (fn()) return true;
31
+ if (Date.now() > deadline) return false;
32
+ await new Promise((r) => setTimeout(r, step));
33
+ }
34
+ }
35
+
36
+ function tmp() {
37
+ const dir = mkdtempSync(join(tmpdir(), 'dpersist-'));
38
+ return dir;
39
+ }
40
+
41
+ test('(a) only explicit false/0 keeps entry; undefined/void resolve deletes', async () => {
42
+ const dir = tmp();
43
+ try {
44
+ addPending(dir, 'h-a', 'recall', ['q'], 'sid-owner', 1111);
45
+ await waitFor(() => 'h-a' in readMap(dir));
46
+
47
+ // Explicit false → undelivered, entry MUST survive for retry.
48
+ recoverPending(dir, () => Promise.resolve(false), {});
49
+ await new Promise((r) => setTimeout(r, 200));
50
+ assert.ok('h-a' in readMap(dir), 'entry removed despite notifyFn=false');
51
+
52
+ // Explicit 0 → also undelivered, entry survives.
53
+ recoverPending(dir, () => Promise.resolve(0), {});
54
+ await new Promise((r) => setTimeout(r, 200));
55
+ assert.ok('h-a' in readMap(dir), 'entry removed despite notifyFn=0');
56
+
57
+ // undefined/void resolve from a delivered notifyFn → entry removed.
58
+ recoverPending(dir, () => Promise.resolve(undefined), {});
59
+ const gone = await waitFor(() => !('h-a' in readMap(dir)));
60
+ assert.ok(gone, 'entry not removed after undefined (delivered) resolve');
61
+ } finally {
62
+ rmSync(dir, { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ test('(b) hostPid-only match delivers to true owner session, never the filter session', async () => {
67
+ const dir = tmp();
68
+ try {
69
+ addPending(dir, 'h-b', 'recall', ['q'], 'owner-A', 4242);
70
+ await waitFor(() => 'h-b' in readMap(dir));
71
+
72
+ let seen = null;
73
+ // Reconnect as a DIFFERENT session that only shares the host pid.
74
+ recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
75
+ sessionId: 'session-B',
76
+ clientHostPid: 4242,
77
+ });
78
+ await waitFor(() => seen != null);
79
+ assert.equal(seen.caller_session_id, 'owner-A', 'stamped filter session instead of true owner');
80
+ assert.notEqual(seen.caller_session_id, 'session-B');
81
+ } finally {
82
+ rmSync(dir, { recursive: true, force: true });
83
+ }
84
+ });
85
+
86
+ test('(b) hostPid-only match with no owner session is left persisted, not injected', async () => {
87
+ const dir = tmp();
88
+ try {
89
+ addPending(dir, 'h-c', 'recall', ['q'], null, 5353);
90
+ await waitFor(() => 'h-c' in readMap(dir));
91
+
92
+ let called = false;
93
+ recoverPending(dir, () => { called = true; return Promise.resolve(true); }, {
94
+ sessionId: 'session-C',
95
+ clientHostPid: 5353,
96
+ });
97
+ await new Promise((r) => setTimeout(r, 250));
98
+ assert.equal(called, false, 'notifyFn fired for an entry with no owner session');
99
+ assert.ok('h-c' in readMap(dir), 'entry with no owner session was not left persisted');
100
+ } finally {
101
+ rmSync(dir, { recursive: true, force: true });
102
+ }
103
+ });
104
+
105
+ test('owner-session match still stamps the reconnecting session id', async () => {
106
+ const dir = tmp();
107
+ try {
108
+ addPending(dir, 'h-d', 'recall', ['q'], 'prior-sid', 6464);
109
+ await waitFor(() => 'h-d' in readMap(dir));
110
+
111
+ let seen = null;
112
+ recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
113
+ sessionId: 'new-sid',
114
+ priorSessionId: 'prior-sid',
115
+ clientHostPid: 6464,
116
+ });
117
+ await waitFor(() => seen != null);
118
+ assert.equal(seen.caller_session_id, 'new-sid', 'owner match should stamp reconnecting session id');
119
+ } finally {
120
+ rmSync(dir, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test('priorSessionId match with no current sessionId keeps the entry owner cid', async () => {
125
+ const dir = tmp();
126
+ try {
127
+ addPending(dir, 'h-e', 'recall', ['q'], 'prior-sid', 7575);
128
+ await waitFor(() => 'h-e' in readMap(dir));
129
+
130
+ let seen = null;
131
+ // priorSessionId matches the owner but no current sessionId is supplied →
132
+ // filterSid is null; must fall back to the entry's known owner cid.
133
+ recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
134
+ priorSessionId: 'prior-sid',
135
+ });
136
+ await waitFor(() => seen != null);
137
+ assert.equal(seen.caller_session_id, 'prior-sid', 'dropped known owner cid when sessionId absent');
138
+ } finally {
139
+ rmSync(dir, { recursive: true, force: true });
140
+ }
141
+ });
@@ -6,13 +6,41 @@
6
6
  // node scripts/explore-bench.mjs [roundLabel]
7
7
  // Fresh process per run = no in-memory result cache carryover.
8
8
  import { readFileSync, existsSync } from 'node:fs';
9
- import { join } from 'node:path';
9
+ import { join, isAbsolute } from 'node:path';
10
10
  import { homedir } from 'node:os';
11
11
  import { runExplore } from '../src/standalone/explore-tool.mjs';
12
12
 
13
13
  const ROUND = process.argv[2] || 'r0';
14
14
  const CWD = 'C:/Project/mixdog';
15
15
 
16
+ // Anchor helpers: an anchor line carries a `path:line`. Strict quality =
17
+ // expected token must land ON such a line (not merely somewhere in the text),
18
+ // and anchors===0 is always a miss (except expectFail). Spot-accuracy verifies
19
+ // the referenced file exists and the line number is within the file length.
20
+ function anchorLinesOf(text) {
21
+ return text.split('\n').filter((l) => /:\d+/.test(l));
22
+ }
23
+ function checkAnchorLine(line, ctxTokens = []) {
24
+ const m = line.match(/([A-Za-z0-9_.\/\\-]+):(\d+)/);
25
+ if (!m) return null; // no parseable path:line
26
+ const p = m[1];
27
+ const ln = Number(m[2]);
28
+ const full = isAbsolute(p) ? p : join(CWD, p);
29
+ if (!existsSync(full)) return false;
30
+ try {
31
+ const fileLines = readFileSync(full, 'utf8').split('\n');
32
+ if (!(ln >= 1 && ln <= fileLines.length)) return false;
33
+ // Fabricated-line guard: the cited line's ±2 window must contain a
34
+ // query/expected token, so a plausible-but-wrong line number fails.
35
+ if (ctxTokens.length) {
36
+ // ±2 lines around the 1-based cited line (indices ln-3 .. ln+1 inclusive).
37
+ const win = fileLines.slice(Math.max(0, ln - 3), ln + 2).join('\n').toLowerCase();
38
+ if (!ctxTokens.some((t) => win.includes(t))) return false;
39
+ }
40
+ return true;
41
+ } catch { return false; }
42
+ }
43
+
16
44
  // High-fanout timing attribution: emit lightweight per-iteration send_ms
17
45
  // loop rows (no verbose payload estimate) so we can split LLM send time
18
46
  // from tool time under the parallel round below. Trace-only; no behavior
@@ -68,6 +96,13 @@ const QUERIES = [
68
96
  q: 'where is the mixdog config json file path resolved (data dir)',
69
97
  expect: ['config', 'plugin-paths'],
70
98
  },
99
+ {
100
+ // path-only class: the correct answer is a verified file/dir location on
101
+ // disk; a bare path (no :line) is an allowed HIT for this query.
102
+ q: 'where does mixdog store background shell job stdout logs on disk',
103
+ expect: ['shell-jobs', 'shell-job-paths', 'getShellJobsDir', 'shellJobStdout'],
104
+ pathOnly: true,
105
+ },
71
106
  {
72
107
  q: 'GraphQL schema stitching resolver implementation', // not in this repo
73
108
  expectFail: true,
@@ -98,17 +133,38 @@ function readTraceSince(ts) {
98
133
  const t0 = Date.now();
99
134
  // Parallel round: all queries fire at once. Per-session trace attribution is
100
135
  // by session_id so counts stay correct; per-query ms includes queueing.
101
- const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail }) => {
136
+ const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail, pathOnly }) => {
102
137
  const qt0 = Date.now();
103
138
  const res = await runExplore({ query: q, cwd: CWD }, { callerCwd: CWD });
104
139
  const text = res?.content?.[0]?.text || '';
105
- const anchors = text.split('\n').filter((l) => /:\d+/.test(l)).length;
140
+ const aLines = anchorLinesOf(text);
141
+ const anchors = aLines.length;
106
142
  const failed = /EXPLORATION_FAILED/.test(text);
107
- const lower = text.toLowerCase();
143
+ // Context tokens for the fabricated-line guard: expanded expected + query
144
+ // words, length>=4 to drop filler.
145
+ const ctxTokens = [
146
+ ...(expect || []).flatMap((e) => e.toLowerCase().split(/[^a-z0-9]+/)),
147
+ ...q.toLowerCase().split(/[^a-z0-9]+/),
148
+ ].filter((t) => t.length >= 4);
149
+ // Spot-check up to 2 anchors per query for file/line validity + line context.
150
+ let badAnchors = 0;
151
+ for (const l of aLines.slice(0, 2)) {
152
+ if (checkAnchorLine(l, ctxTokens) === false) badAnchors++;
153
+ }
154
+ const allLines = text.split('\n').filter((l) => l.trim());
155
+ // Strict hit: expected token must appear ON an anchor line (>=1 anchor). For
156
+ // the path-only class, an expected token on any verified path line HITs even
157
+ // without :line. expectFail wants EXPLORATION_FAILED with zero anchors.
108
158
  const hit = expectFail
109
159
  ? (failed && anchors === 0)
110
- : (!failed && (expect || []).some((e) => lower.includes(e.toLowerCase())));
111
- return { q: q.slice(0, 48), ms: Date.now() - qt0, anchors, failed, hit, bytes: text.length };
160
+ : pathOnly
161
+ ? (!failed && (expect || []).some(
162
+ (e) => allLines.some((l) => l.toLowerCase().includes(e.toLowerCase())),
163
+ ))
164
+ : (!failed && anchors > 0 && (expect || []).some(
165
+ (e) => aLines.some((l) => l.toLowerCase().includes(e.toLowerCase())),
166
+ ));
167
+ return { q: q.slice(0, 48), ms: Date.now() - qt0, anchors, failed, hit, badAnchors, bytes: text.length };
112
168
  }));
113
169
 
114
170
  // Identify explorer sessions from trace rows, then attribute both tool calls
@@ -145,9 +201,10 @@ const sendCount = [...sendBySession.values()].reduce((a, s) => a + s.sends, 0);
145
201
 
146
202
  console.log(`\n=== explore-bench round=${ROUND} ===`);
147
203
  for (const r of results) {
148
- console.log(` [${String(r.ms).padStart(6)}ms] ${r.hit ? 'HIT ' : 'MISS'} anchors=${r.anchors} failed=${r.failed} bytes=${r.bytes} ${r.q}`);
204
+ console.log(` [${String(r.ms).padStart(6)}ms] ${r.hit ? 'HIT ' : 'MISS'} anchors=${r.anchors} bad=${r.badAnchors} failed=${r.failed} bytes=${r.bytes} ${r.q}`);
149
205
  }
150
- console.log(` quality: ${results.filter((r) => r.hit).length}/${results.length} hits`);
206
+ const badTotal = results.reduce((a, r) => a + r.badAnchors, 0);
207
+ console.log(` quality: ${results.filter((r) => r.hit).length}/${results.length} hits (strict) bad-anchors=${badTotal}`);
151
208
  console.log(` sessions=${bySession.size} toolcalls p50=${p50} max=${callCounts[callCounts.length - 1] ?? 0} total=${callCounts.reduce((a, b) => a + b, 0)}`);
152
209
  for (const [id, s] of bySession) console.log(` ${id.slice(-8)}: ${s.tools.join(',')}`);
153
210
  const wallMs = Date.now() - t0;
@@ -13,9 +13,13 @@ test('explore per-query prompt leaves path policy to tool schemas', () => {
13
13
 
14
14
  test('grep glob find tool schemas carry unverified path policy', () => {
15
15
  const byName = Object.fromEntries(BUILTIN_TOOLS.map((tool) => [tool.name, tool]));
16
- assert.match(byName.grep.description, /Unknown path\/name find first/i);
16
+ // Conditional find-first: only genuinely guessed fragments route through
17
+ // find, and in the SAME turn — project root itself is a verified scope.
18
+ assert.match(byName.grep.description, /project root counts as verified/i);
19
+ assert.match(byName.grep.description, /guessed path fragment → find first, same turn/i);
17
20
  assert.match(byName.grep.description, /no path "\." \+ guessed src\/\*\*/i);
18
- assert.match(byName.glob.description, /Unknown root\/name find first/i);
21
+ assert.match(byName.glob.description, /project root is verified/i);
22
+ assert.match(byName.glob.description, /Guessed root\/name → find first, same turn/i);
19
23
  assert.match(byName.find.description, /verify roots before grep\/glob/i);
20
24
  });
21
25
 
@@ -0,0 +1,73 @@
1
+ // notifyFnForSession must not double-inject a background-task completion. The
2
+ // mirror skip is gated on an EXPLICIT model-visible-delivery ack
3
+ // (modelVisibleDelivered), set ONLY by the TUI execution-ui path that injects
4
+ // the completion body into the active loop — never on a generic truthy
5
+ // onNotification return. So: delivered ack → mirror suppressed; headless /
6
+ // display-only / API listener (no ack) → mirror kept as the sole delivery.
7
+ import test from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+
10
+ import { shouldMirrorCompletionToPendingQueue } from '../src/session-runtime/runtime-core.mjs';
11
+
12
+ // A terminal agent completion with a Result body — accepted by
13
+ // shouldPersistModelVisibleToolCompletion.
14
+ const completionText = 'Async agent task task_1 completed finished.\n\nResult:\n> ok';
15
+ const completionMeta = { type: 'agent_task_result', execution_id: 'task_1', status: 'completed' };
16
+
17
+ test('delivered-live completion: no pending mirror (explicit model-visible ack)', () => {
18
+ const mirror = shouldMirrorCompletionToPendingQueue({
19
+ callerSessionId: 'sess_live',
20
+ modelVisibleDelivered: true,
21
+ hasEnqueue: true,
22
+ text: completionText,
23
+ meta: completionMeta,
24
+ });
25
+ assert.equal(mirror, false, 'live twin already injected → mirror suppressed');
26
+ });
27
+
28
+ test('headless completion: pending mirror kept (no ack)', () => {
29
+ const mirror = shouldMirrorCompletionToPendingQueue({
30
+ callerSessionId: 'sess_headless',
31
+ modelVisibleDelivered: false,
32
+ hasEnqueue: true,
33
+ text: completionText,
34
+ meta: completionMeta,
35
+ });
36
+ assert.equal(mirror, true, 'no listener delivered → queue copy is the sole delivery');
37
+ });
38
+
39
+ test('display-only listener returning true (no ack): pending mirror kept', () => {
40
+ // A generic display/API listener consumes the event (would return true) but
41
+ // never injects the model-visible body, so it sets NO modelVisibleDelivered
42
+ // ack. The mirror must stay, or the model never sees the completion body.
43
+ const mirror = shouldMirrorCompletionToPendingQueue({
44
+ callerSessionId: 'sess_display_only',
45
+ modelVisibleDelivered: false,
46
+ hasEnqueue: true,
47
+ text: completionText,
48
+ meta: completionMeta,
49
+ });
50
+ assert.equal(mirror, true, 'generic handled===true is NOT an ack → mirror kept');
51
+ });
52
+
53
+ test('unhandled but non-terminal notification is never mirrored', () => {
54
+ const mirror = shouldMirrorCompletionToPendingQueue({
55
+ callerSessionId: 'sess_headless',
56
+ modelVisibleDelivered: false,
57
+ hasEnqueue: true,
58
+ text: 'still running...',
59
+ meta: { status: 'running' },
60
+ });
61
+ assert.equal(mirror, false, 'only persistable terminal completions mirror');
62
+ });
63
+
64
+ test('missing session id or enqueue capability never mirrors', () => {
65
+ assert.equal(shouldMirrorCompletionToPendingQueue({
66
+ callerSessionId: '', modelVisibleDelivered: false, hasEnqueue: true,
67
+ text: completionText, meta: completionMeta,
68
+ }), false, 'no caller session → nothing to mirror into');
69
+ assert.equal(shouldMirrorCompletionToPendingQueue({
70
+ callerSessionId: 'sess_headless', modelVisibleDelivered: false, hasEnqueue: false,
71
+ text: completionText, meta: completionMeta,
72
+ }), false, 'no enqueue capability → cannot mirror');
73
+ });
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session-sweep.mjs — DRY-RUN ONLY session-store retention reporter.
4
+ *
5
+ * Reports which session files a prune would reclaim WITHOUT touching disk:
6
+ * NO unlink, NO writes to session-summaries.json, NO mutation of store.mjs
7
+ * runtime behavior. It only reads.
8
+ *
9
+ * Reuses the real store helpers read-only:
10
+ * - getPluginData() → resolves the live data dir (…/.mixdog/data)
11
+ * - summaryIndexPath()/listStoredSessionSummaries() → authoritative
12
+ * per-session lifecycle rows (updatedAt/closed/status), avoiding a full
13
+ * 476MB JSON.parse of every session file.
14
+ *
15
+ * Candidate policy (conservative; keep everything else):
16
+ * - closed/tombstoned sessions (row.closed === true || status === 'closed')
17
+ * ONLY when their closedAt/updatedAt is older than --min-closed-age-days
18
+ * (default 7d) — a recently-closed session may still be resumed, so it is
19
+ * kept until the gate elapses.
20
+ * - OR sessions older than --max-age-days by updatedAt (default 30d)
21
+ * Thresholds are params: --max-age-days=<n> --min-closed-age-days=<n> --now=<epochMs>
22
+ *
23
+ * This tool NEVER deletes. It only prints a report.
24
+ */
25
+ import { existsSync, readdirSync, statSync } from 'fs';
26
+ import { readFileSync } from 'fs';
27
+ import { join } from 'path';
28
+ import { getPluginData } from '../src/runtime/agent/orchestrator/config.mjs';
29
+ import {
30
+ summaryIndexPath,
31
+ listStoredSessionSummaries,
32
+ } from '../src/runtime/agent/orchestrator/session/store.mjs';
33
+
34
+ // ── Cheap top-level-only scalar scan ────────────────────────────────────────
35
+ // Same depth-1 tokenizer idea as lifecycle-scan.mjs's scanTopLevelLifecycle
36
+ // (bracket-depth + string-escape aware; the whole `messages` array is skipped
37
+ // by depth counting, never parsed), extended to capture the lifecycle+age
38
+ // scalars we classify on (closed/status/updatedAt/createdAt). This lets the
39
+ // report read the AUTHORITATIVE per-file lifecycle without a full 476MB
40
+ // JSON.parse and without depending on the (stale) summary index.
41
+ const WANT = new Set(['closed', 'status', 'updatedAt', 'createdAt', 'closedAt']);
42
+ function isWs(ch) { return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; }
43
+ function skipString(raw, i) {
44
+ const len = raw.length;
45
+ i++;
46
+ while (i < len) {
47
+ const ch = raw[i];
48
+ if (ch === '\\') { i += 2; continue; }
49
+ if (ch === '"') return i + 1;
50
+ i++;
51
+ }
52
+ return i;
53
+ }
54
+ function skipValue(raw, i) {
55
+ const len = raw.length;
56
+ const c = raw[i];
57
+ if (c === '"') return skipString(raw, i);
58
+ if (c === '{' || c === '[') {
59
+ let depth = 1; i++;
60
+ while (i < len && depth > 0) {
61
+ const ch = raw[i];
62
+ if (ch === '"') { i = skipString(raw, i); continue; }
63
+ if (ch === '{' || ch === '[') depth++;
64
+ else if (ch === '}' || ch === ']') depth--;
65
+ i++;
66
+ }
67
+ return i;
68
+ }
69
+ while (i < len && raw[i] !== ',' && raw[i] !== '}' && raw[i] !== ']' && !isWs(raw[i])) i++;
70
+ return i;
71
+ }
72
+ function scanTopLevelScalars(raw) {
73
+ const len = raw.length;
74
+ let i = 0;
75
+ while (i < len && isWs(raw[i])) i++;
76
+ if (raw[i] !== '{') return null;
77
+ i++;
78
+ const out = {};
79
+ while (i < len) {
80
+ while (i < len && isWs(raw[i])) i++;
81
+ if (i >= len) return out;
82
+ if (raw[i] === '}') return out;
83
+ if (raw[i] === ',') { i++; continue; }
84
+ if (raw[i] !== '"') return out;
85
+ const keyStart = i;
86
+ i = skipString(raw, i);
87
+ let key;
88
+ try { key = JSON.parse(raw.slice(keyStart, i)); } catch { return out; }
89
+ while (i < len && isWs(raw[i])) i++;
90
+ if (raw[i] !== ':') return out;
91
+ i++;
92
+ while (i < len && isWs(raw[i])) i++;
93
+ const valStart = i;
94
+ i = skipValue(raw, i);
95
+ if (WANT.has(key)) {
96
+ try { out[key] = JSON.parse(raw.slice(valStart, i)); } catch { /* ignore */ }
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function parseArgs(argv) {
103
+ const out = { maxAgeDays: 30, minClosedAgeDays: 7, now: Date.now() };
104
+ for (const arg of argv) {
105
+ const m = /^--([^=]+)=(.*)$/.exec(arg);
106
+ if (!m) continue;
107
+ const [, key, val] = m;
108
+ if (key === 'max-age-days') out.maxAgeDays = Number(val);
109
+ else if (key === 'min-closed-age-days') out.minClosedAgeDays = Number(val);
110
+ else if (key === 'now') out.now = Number(val);
111
+ }
112
+ if (!Number.isFinite(out.maxAgeDays) || out.maxAgeDays < 0) out.maxAgeDays = 30;
113
+ if (!Number.isFinite(out.minClosedAgeDays) || out.minClosedAgeDays < 0) out.minClosedAgeDays = 7;
114
+ if (!Number.isFinite(out.now) || out.now <= 0) out.now = Date.now();
115
+ return out;
116
+ }
117
+
118
+ function fmtBytes(n) {
119
+ if (!Number.isFinite(n) || n <= 0) return '0 B';
120
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
121
+ let i = 0;
122
+ let v = n;
123
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
124
+ return `${v.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
125
+ }
126
+
127
+ function fmtTs(ms) {
128
+ if (!Number.isFinite(ms) || ms <= 0) return 'n/a';
129
+ try { return new Date(ms).toISOString(); } catch { return String(ms); }
130
+ }
131
+
132
+ function main() {
133
+ const { maxAgeDays, minClosedAgeDays, now } = parseArgs(process.argv.slice(2));
134
+ const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
135
+ const minClosedAgeMs = minClosedAgeDays * 24 * 60 * 60 * 1000;
136
+
137
+ const dir = join(getPluginData(), 'sessions');
138
+ if (!existsSync(dir)) {
139
+ process.stdout.write(`[session-sweep] no sessions dir at ${dir}\n`);
140
+ return;
141
+ }
142
+
143
+ // Authoritative lifecycle rows (read-only). rebuildIfMissing:false so this
144
+ // report never triggers a summary-index write.
145
+ const rows = listStoredSessionSummaries({ rebuildIfMissing: false });
146
+ const rowById = new Map();
147
+ for (const r of rows) if (r?.id) rowById.set(r.id, r);
148
+
149
+ // Disk scan for sizes + mtime fallback. `.hb` sidecar bytes are attributed
150
+ // to their session so reclaimable bytes reflect the full on-disk footprint.
151
+ const files = readdirSync(dir);
152
+ const jsonFiles = files.filter((f) => f.endsWith('.json'));
153
+ const hbSizeById = new Map();
154
+ for (const f of files) {
155
+ if (!f.endsWith('.hb')) continue;
156
+ const id = f.slice(0, -3);
157
+ try { hbSizeById.set(id, statSync(join(dir, f)).size || 0); } catch { /* ignore */ }
158
+ }
159
+
160
+ let totalFiles = 0;
161
+ let totalBytes = 0;
162
+ const candidates = []; // { id, reason, updatedAt, bytes, inIndex }
163
+ let closedCount = 0;
164
+ let ageOnlyCount = 0;
165
+ let closedBytes = 0;
166
+ let ageOnlyBytes = 0;
167
+ let reclaimBytes = 0;
168
+ let scanFailFiles = 0;
169
+ let closedButFreshCount = 0; // closed, but within the min-closed-age gate → kept
170
+ let closedButFreshBytes = 0;
171
+ const fileIds = new Set();
172
+
173
+ for (const f of jsonFiles) {
174
+ const id = f.slice(0, -5);
175
+ fileIds.add(id);
176
+ const full = join(dir, f);
177
+ let size = 0;
178
+ let mtimeMs = 0;
179
+ try { const st = statSync(full); size = st.size || 0; mtimeMs = st.mtimeMs || 0; } catch { /* skip */ }
180
+ const hb = hbSizeById.get(id) || 0;
181
+ const bytes = size + hb;
182
+ totalFiles += 1;
183
+ totalBytes += bytes;
184
+
185
+ const row = rowById.get(id) || null;
186
+ // Authoritative per-file lifecycle via cheap top-level scan; the summary
187
+ // index is stale here (most on-disk files are unindexed), so the file
188
+ // itself — not the index — decides closed/age. Fall back to the index
189
+ // row, then to file mtime, only when the scan can't resolve a field.
190
+ let scan = null;
191
+ try { scan = scanTopLevelScalars(readFileSync(full, 'utf-8')); } catch { scan = null; }
192
+ if (!scan) scanFailFiles += 1;
193
+ const closedScan = scan && (scan.closed === true || scan.status === 'closed');
194
+ const closedRow = row && (row.closed === true || row.status === 'closed');
195
+ const closed = scan ? !!closedScan : !!closedRow;
196
+ let updatedAt = scan && Number(scan.updatedAt) > 0 ? Number(scan.updatedAt) : 0;
197
+ if (!updatedAt && row && Number(row.updatedAt) > 0) updatedAt = Number(row.updatedAt);
198
+ if (!updatedAt) updatedAt = mtimeMs;
199
+ const ageMs = now - updatedAt;
200
+ // Min-closed-age gate: a closed session only qualifies once its close
201
+ // timestamp (closedAt when present, else updatedAt — markSessionClosed
202
+ // sets updatedAt=Date.now() at tombstone time) is older than the gate.
203
+ // Recently-closed sessions may still be resumed, so keep them.
204
+ const closedAt = scan && Number(scan.closedAt) > 0 ? Number(scan.closedAt) : updatedAt;
205
+ const closedAge = now - closedAt;
206
+ const closedQualifies = closed
207
+ && (minClosedAgeMs <= 0
208
+ || (Number.isFinite(closedAt) && closedAt > 0 && closedAge > minClosedAgeMs));
209
+ const ageOnly = !closed && maxAgeMs > 0 && Number.isFinite(updatedAt) && updatedAt > 0 && ageMs > maxAgeMs;
210
+
211
+ if (closed && !closedQualifies) {
212
+ closedButFreshCount += 1;
213
+ closedButFreshBytes += bytes;
214
+ }
215
+ if (!closedQualifies && !ageOnly) continue; // keep
216
+
217
+ const reason = closedQualifies ? 'closed' : 'age';
218
+ candidates.push({ id, reason, updatedAt, bytes, inIndex: !!row });
219
+ reclaimBytes += bytes;
220
+ if (closedQualifies) { closedCount += 1; closedBytes += bytes; }
221
+ else { ageOnlyCount += 1; ageOnlyBytes += bytes; }
222
+ }
223
+
224
+ candidates.sort((a, b) => (a.updatedAt || 0) - (b.updatedAt || 0));
225
+ const oldest = candidates[0] || null;
226
+ const newest = candidates[candidates.length - 1] || null;
227
+ const dropRows = candidates.filter((c) => c.inIndex).length;
228
+ const remainFiles = totalFiles - candidates.length;
229
+ // Rows in the index whose session file is already gone from disk — a
230
+ // rebuild (scans existing files only) drops these regardless of retention.
231
+ const staleRows = rows.filter((r) => r?.id && !fileIds.has(r.id)).length;
232
+ // A real prune would delete candidate files then rebuild the index from the
233
+ // surviving files, so the post-rebuild index equals the surviving-file count
234
+ // (every remaining file gets a row, including currently-unindexed ones).
235
+ const rebuiltIndexRows = remainFiles;
236
+
237
+ const L = [];
238
+ L.push('══ session-sweep DRY-RUN report (NO files deleted, NO writes) ══');
239
+ L.push(`data dir : ${dir}`);
240
+ L.push(`summary index : ${summaryIndexPath()} (${rows.length} rows)`);
241
+ L.push(`now : ${fmtTs(now)}`);
242
+ L.push(`retention (age) : > ${maxAgeDays} days by updatedAt`);
243
+ L.push(`retention (closed) : (closed=true OR status='closed') AND closed > ${minClosedAgeDays}d ago`);
244
+ L.push('');
245
+ L.push(`total session files : ${totalFiles} (${fmtBytes(totalBytes)})`);
246
+ L.push(`candidates (drop) : ${candidates.length} (${fmtBytes(reclaimBytes)} reclaimable)`);
247
+ L.push(` ├─ closed : ${closedCount} (${fmtBytes(closedBytes)})`);
248
+ L.push(` └─ age-only >${maxAgeDays}d : ${ageOnlyCount} (${fmtBytes(ageOnlyBytes)})`);
249
+ L.push(`kept: closed <${minClosedAgeDays}d : ${closedButFreshCount} (${fmtBytes(closedButFreshBytes)}) — within min-closed-age gate`);
250
+ L.push(`would remain : ${remainFiles} files (${fmtBytes(totalBytes - reclaimBytes)})`);
251
+ L.push(`oldest candidate : ${oldest ? `${oldest.id} @ ${fmtTs(oldest.updatedAt)} [${oldest.reason}]` : 'n/a'}`);
252
+ L.push(`newest candidate : ${newest ? `${newest.id} @ ${fmtTs(newest.updatedAt)} [${newest.reason}]` : 'n/a'}`);
253
+ if (scanFailFiles > 0) L.push(`unparsable files : ${scanFailFiles} (fell back to index/mtime)`);
254
+ L.push('');
255
+ L.push('── summary-index rebuild plan ──');
256
+ L.push(`index rows total : ${rows.length}`);
257
+ L.push(`stale rows (no file): ${staleRows} (already-deleted sessions; drop on rebuild)`);
258
+ L.push(`candidate rows drop : ${dropRows} (candidate files that also have an index row)`);
259
+ L.push(`orphan candidates : ${candidates.length - dropRows} (candidate file present, no index row)`);
260
+ L.push(`index rows after : ${rebuiltIndexRows} (= surviving files; rebuild reindexes all remaining)`);
261
+ L.push('');
262
+ L.push('DRY-RUN ONLY — this tool performed no unlink and no disk writes.');
263
+ process.stdout.write(L.join('\n') + '\n');
264
+ }
265
+
266
+ main();