claude-code-session-manager 0.35.18 → 0.37.0

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 (39) hide show
  1. package/dist/assets/{TiptapBody-DgFO_m3g.js → TiptapBody-Cg8YZhVZ.js} +58 -58
  2. package/dist/assets/index-CTTjT08J.css +32 -0
  3. package/dist/assets/index-_iBXLuvt.js +3496 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
  7. package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
  8. package/src/main/__tests__/docEdit.test.cjs +82 -0
  9. package/src/main/__tests__/historyDashboard.test.cjs +163 -0
  10. package/src/main/__tests__/historyRollup.test.cjs +333 -0
  11. package/src/main/__tests__/memoryStale.test.cjs +88 -0
  12. package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
  13. package/src/main/__tests__/queueHistory.test.cjs +233 -0
  14. package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
  15. package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
  16. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +4 -5
  17. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
  18. package/src/main/chatRunner.cjs +57 -18
  19. package/src/main/config.cjs +8 -0
  20. package/src/main/docEdit.cjs +133 -0
  21. package/src/main/files.cjs +53 -0
  22. package/src/main/historyAggregator.cjs +391 -50
  23. package/src/main/historyDashboard.cjs +226 -0
  24. package/src/main/index.cjs +37 -1
  25. package/src/main/ipcSchemas.cjs +29 -0
  26. package/src/main/lib/broadcastCoalescer.cjs +46 -0
  27. package/src/main/lib/historyRollup.cjs +148 -0
  28. package/src/main/lib/memoryStale.cjs +116 -0
  29. package/src/main/lib/queueHistory.cjs +216 -0
  30. package/src/main/lib/rcaFeedbackHook.cjs +346 -0
  31. package/src/main/lib/schedulerConfig.cjs +16 -0
  32. package/src/main/memoryTool.cjs +49 -0
  33. package/src/main/queueOps.cjs +92 -0
  34. package/src/main/scheduler/prdParser.cjs +52 -1
  35. package/src/main/scheduler.cjs +237 -29
  36. package/src/preload/api.d.ts +92 -1
  37. package/src/preload/index.cjs +7 -0
  38. package/dist/assets/index-4dJkn9nT.js +0 -3559
  39. package/dist/assets/index-DX2w2YhJ.css +0 -32
@@ -0,0 +1,226 @@
1
+ /**
2
+ * historyDashboard.cjs — `history:dashboard` IPC: answers entirely from the
3
+ * durable rollup store (src/main/lib/historyRollup.cjs), never touching a
4
+ * transcript .jsonl file. This is what lets the History dashboard render
5
+ * instantly regardless of transcript corpus size or retention/cleanup.
6
+ *
7
+ * Only imports historyRollup.cjs (the rollup reader) and pure helpers
8
+ * re-exported from historyAggregator.cjs (pricing table + date math) — never
9
+ * fs/promises, never PROJECTS_DIR. See historyDashboard.test.cjs's zero-scan
10
+ * guarantee test.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const { ipcMain } = require('electron');
16
+ const historyRollup = require('./lib/historyRollup.cjs');
17
+ const {
18
+ MODEL_PRICING,
19
+ resolvePricingKey,
20
+ localDate,
21
+ subtractDays,
22
+ } = require('./historyAggregator.cjs');
23
+ const { schemas } = require('./ipcSchemas.cjs');
24
+
25
+ // Read range start for rangeDays=0 ("all time"). Rollup dates are always
26
+ // after this, so it's an effective "no lower bound".
27
+ const EARLIEST_DATE = '1970-01-01';
28
+
29
+ function emptyProjectRow(date, projectDir) {
30
+ return {
31
+ date,
32
+ projectDir,
33
+ promptCount: 0,
34
+ inputTokens: 0,
35
+ outputTokens: 0,
36
+ cacheReadTokens: 0,
37
+ cacheCreationTokens: 0,
38
+ toolCallCount: 0,
39
+ toolBreakdown: {},
40
+ sessionCount: 0,
41
+ errorCount: 0,
42
+ activeMinutes: 0,
43
+ byModel: {},
44
+ };
45
+ }
46
+
47
+ /** Fold one rollup bucket (totals line or per-model line) into a row. */
48
+ function foldBucketIntoRow(row, bucket) {
49
+ if (bucket.modelId === historyRollup.TOTALS_MODEL_ID) {
50
+ row.promptCount += bucket.promptCount || 0;
51
+ row.toolCallCount += bucket.toolCallCount || 0;
52
+ row.errorCount += bucket.errorCount || 0;
53
+ row.sessionCount += bucket.sessionCount || 0;
54
+ row.activeMinutes += bucket.activeMinutes || 0;
55
+ for (const [tool, cnt] of Object.entries(bucket.toolBreakdown || {})) {
56
+ row.toolBreakdown[tool] = (row.toolBreakdown[tool] ?? 0) + cnt;
57
+ }
58
+ } else {
59
+ const dst = row.byModel[bucket.modelId] ?? (row.byModel[bucket.modelId] = {
60
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
61
+ });
62
+ dst.inputTokens += bucket.inputTokens || 0;
63
+ dst.outputTokens += bucket.outputTokens || 0;
64
+ dst.cacheReadTokens += bucket.cacheReadTokens || 0;
65
+ dst.cacheCreationTokens += bucket.cacheCreationTokens || 0;
66
+ row.inputTokens += bucket.inputTokens || 0;
67
+ row.outputTokens += bucket.outputTokens || 0;
68
+ row.cacheReadTokens += bucket.cacheReadTokens || 0;
69
+ row.cacheCreationTokens += bucket.cacheCreationTokens || 0;
70
+ }
71
+ }
72
+
73
+ /** Compute per-model + row-level cost at read time — never stored. */
74
+ function computeRowCost(row) {
75
+ const byModel = {};
76
+ let estimatedCostUsd = 0;
77
+ for (const [modelId, bucket] of Object.entries(row.byModel)) {
78
+ const { key, estimated } = resolvePricingKey(modelId);
79
+ const pricing = MODEL_PRICING[key];
80
+ const costUsd =
81
+ (bucket.inputTokens + bucket.cacheCreationTokens) * pricing.i / 1e6 +
82
+ bucket.outputTokens * pricing.o / 1e6 +
83
+ bucket.cacheReadTokens * pricing.c / 1e6;
84
+ byModel[modelId] = { ...bucket, costUsd, ...(estimated ? { estimated: true } : {}) };
85
+ estimatedCostUsd += costUsd;
86
+ }
87
+ return { ...row, byModel, estimatedCostUsd };
88
+ }
89
+
90
+ function emptyTotals() {
91
+ return {
92
+ promptCount: 0,
93
+ inputTokens: 0,
94
+ outputTokens: 0,
95
+ cacheReadTokens: 0,
96
+ cacheCreationTokens: 0,
97
+ toolCallCount: 0,
98
+ sessionCount: 0,
99
+ errorCount: 0,
100
+ activeMinutes: 0,
101
+ estimatedCostUsd: 0,
102
+ };
103
+ }
104
+
105
+ function addRowToTotals(totals, row) {
106
+ totals.promptCount += row.promptCount;
107
+ totals.inputTokens += row.inputTokens;
108
+ totals.outputTokens += row.outputTokens;
109
+ totals.cacheReadTokens += row.cacheReadTokens;
110
+ totals.cacheCreationTokens += row.cacheCreationTokens;
111
+ totals.toolCallCount += row.toolCallCount;
112
+ totals.sessionCount += row.sessionCount;
113
+ totals.errorCount += row.errorCount;
114
+ totals.activeMinutes += row.activeMinutes;
115
+ totals.estimatedCostUsd += row.estimatedCostUsd;
116
+ }
117
+
118
+ /**
119
+ * Answer the History dashboard's full request purely from rollup lines.
120
+ * O(rollup lines in range) — no transcript file is ever opened.
121
+ */
122
+ async function computeDashboard(req) {
123
+ const rangeDays = req?.rangeDays ?? 30;
124
+ const isAll = rangeDays === 0;
125
+ const today = localDate(new Date());
126
+ const to = today;
127
+ const from = isAll ? EARLIEST_DATE : subtractDays(today, rangeDays - 1);
128
+ const prevFrom = isAll ? null : subtractDays(from, rangeDays);
129
+ const prevTo = isAll ? null : subtractDays(from, 1);
130
+ const readFrom = isAll ? EARLIEST_DATE : prevFrom;
131
+
132
+ const map = await historyRollup.readRollup(readFrom, to);
133
+
134
+ const daysMap = new Map(); // date -> Map(projectDir -> row)
135
+ const prevRow = emptyProjectRow('', '');
136
+ const finalizedDates = new Set();
137
+
138
+ for (const bucket of map.values()) {
139
+ if (bucket.projectDir === historyRollup.FINALIZED_PROJECT_ID && bucket.modelId === historyRollup.FINALIZED_MODEL_ID) {
140
+ if (bucket.finalizedAt) finalizedDates.add(bucket.date);
141
+ continue;
142
+ }
143
+
144
+ const inCurrent = bucket.date >= from && bucket.date <= to;
145
+ if (inCurrent) {
146
+ let projMap = daysMap.get(bucket.date);
147
+ if (!projMap) { projMap = new Map(); daysMap.set(bucket.date, projMap); }
148
+ let row = projMap.get(bucket.projectDir);
149
+ if (!row) { row = emptyProjectRow(bucket.date, bucket.projectDir); projMap.set(bucket.projectDir, row); }
150
+ foldBucketIntoRow(row, bucket);
151
+ continue;
152
+ }
153
+
154
+ const inPrev = !isAll && bucket.date >= prevFrom && bucket.date <= prevTo;
155
+ if (inPrev) foldBucketIntoRow(prevRow, bucket);
156
+ }
157
+
158
+ const days = [];
159
+ const totals = emptyTotals();
160
+ const byProjectTotals = {};
161
+ const byModelTotals = {};
162
+ const toolsByProject = {};
163
+
164
+ const sortedDates = Array.from(daysMap.keys()).sort();
165
+ for (const date of sortedDates) {
166
+ const projMap = daysMap.get(date);
167
+ const byProject = {};
168
+ for (const [projectDir, row] of projMap) {
169
+ const rowWithCost = computeRowCost(row);
170
+ byProject[projectDir] = rowWithCost;
171
+ addRowToTotals(totals, rowWithCost);
172
+
173
+ if (!byProjectTotals[projectDir]) byProjectTotals[projectDir] = emptyTotals();
174
+ addRowToTotals(byProjectTotals[projectDir], rowWithCost);
175
+
176
+ if (!toolsByProject[projectDir]) toolsByProject[projectDir] = {};
177
+ for (const [tool, cnt] of Object.entries(row.toolBreakdown)) {
178
+ toolsByProject[projectDir][tool] = (toolsByProject[projectDir][tool] ?? 0) + cnt;
179
+ }
180
+
181
+ for (const [modelId, mb] of Object.entries(rowWithCost.byModel)) {
182
+ const dst = byModelTotals[modelId] ?? (byModelTotals[modelId] = {
183
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0,
184
+ });
185
+ dst.inputTokens += mb.inputTokens;
186
+ dst.outputTokens += mb.outputTokens;
187
+ dst.cacheReadTokens += mb.cacheReadTokens;
188
+ dst.cacheCreationTokens += mb.cacheCreationTokens;
189
+ dst.costUsd += mb.costUsd;
190
+ if (mb.estimated) dst.estimated = true;
191
+ }
192
+ }
193
+ days.push({ date, byProject });
194
+ }
195
+
196
+ const prevTotals = emptyTotals();
197
+ addRowToTotals(prevTotals, computeRowCost(prevRow));
198
+
199
+ const provisionalDates = sortedDates.filter((d) => !finalizedDates.has(d));
200
+
201
+ return {
202
+ from,
203
+ to,
204
+ days,
205
+ prevTotals,
206
+ totals,
207
+ byProjectTotals,
208
+ byModelTotals,
209
+ toolsByProject,
210
+ generatedAt: Date.now(),
211
+ provisionalDates,
212
+ };
213
+ }
214
+
215
+ function registerHistoryDashboardHandlers() {
216
+ ipcMain.handle('history:dashboard', async (_e, rawReq) => {
217
+ const parsed = schemas.historyDashboard.safeParse(rawReq);
218
+ const req = parsed.success ? parsed.data : { rangeDays: 30 };
219
+ return computeDashboard(req);
220
+ });
221
+ }
222
+
223
+ module.exports = {
224
+ registerHistoryDashboardHandlers,
225
+ computeDashboard,
226
+ };
@@ -41,13 +41,17 @@ const pluginInstall = require('./pluginInstall.cjs');
41
41
  const { seedDevPlugin } = require('./seedDevPlugin.cjs');
42
42
  const otel = require('./otel.cjs');
43
43
  const otelSettings = require('./otelSettings.cjs');
44
- const { registerHistoryAggregatorHandlers } = require('./historyAggregator.cjs');
44
+ const { registerHistoryAggregatorHandlers, finalizeClosedDays, refreshIntradayToday } = require('./historyAggregator.cjs');
45
+ const { registerHistoryDashboardHandlers } = require('./historyDashboard.cjs');
46
+ const { tryAcquireLock, releaseLock, DEFAULT_LOCK_PATH } = require('../../scripts/lib/watchdogHelpers.cjs');
47
+ const schedulerConfig = require('./lib/schedulerConfig.cjs');
45
48
  const memoryTool = require('./memoryTool.cjs');
46
49
  const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
47
50
  const agentMemory = require('./agentMemory.cjs');
48
51
  const git = require('./git.cjs');
49
52
  const superagent = require('./superagent.cjs');
50
53
  const filesIpc = require('./files.cjs');
54
+ const { registerDocEditHandlers } = require('./docEdit.cjs');
51
55
  const searchIpc = require('./search.cjs');
52
56
  const repoAnalyzer = require('./repoAnalyzer.cjs');
53
57
  const hivesIpc = require('./hives.cjs');
@@ -150,6 +154,20 @@ ipcMain.handle = function trackedHandle(channel, listener) {
150
154
 
151
155
  const REBOOT_LOG = path.join(os.homedir(), '.claude', 'session-manager-reboot.log');
152
156
 
157
+ // Guards refreshIntradayToday() with the same O_EXCL lock file the external
158
+ // watchdog's finalize pass uses, so an in-app refresh tick and an offline
159
+ // finalize pass never interleave writes to the rollup file. A contended lock
160
+ // just means this tick is skipped — the next timer tick (or the next boot)
161
+ // retries, so skipping is always safe.
162
+ function runIntradayRefresh() {
163
+ if (!tryAcquireLock(DEFAULT_LOCK_PATH)) return;
164
+ refreshIntradayToday()
165
+ .catch((e) => {
166
+ logs.writeLine({ scope: 'history-rollup', level: 'error', message: 'refreshIntradayToday failed', meta: { error: e?.message } });
167
+ })
168
+ .finally(() => releaseLock(DEFAULT_LOCK_PATH));
169
+ }
170
+
153
171
  function logReboot(line) {
154
172
  try {
155
173
  fs.mkdirSync(path.dirname(REBOOT_LOG), { recursive: true });
@@ -742,6 +760,7 @@ watchers.registerWatcherHandlers();
742
760
  teams.registerTeamsHandlers();
743
761
  queueOps.registerQueueOpsHandlers();
744
762
  registerHistoryAggregatorHandlers();
763
+ registerHistoryDashboardHandlers();
745
764
  pluginInstall.registerPluginInstallHandlers();
746
765
  memoryTool.registerMemoryHandlers();
747
766
  registerMemoryAggregateIpc();
@@ -749,6 +768,7 @@ agentMemory.registerAgentMemoryHandlers();
749
768
  git.register(ipcMain);
750
769
  superagent.registerSuperAgentHandlers();
751
770
  filesIpc.registerFilesHandlers();
771
+ registerDocEditHandlers();
752
772
  searchIpc.registerSearchHandlers();
753
773
  repoAnalyzer.register(ipcMain);
754
774
  hivesIpc.registerHiveHandlers();
@@ -1078,6 +1098,22 @@ app.whenReady().then(async () => {
1078
1098
  webRemote.init().catch((e) => {
1079
1099
  logs.writeLine({ scope: 'webRemote', level: 'error', message: 'init failed', meta: { error: e?.message } });
1080
1100
  });
1101
+ // History rollup finalize pass: deferred 30s past boot so it never competes
1102
+ // with first-paint, fire-and-forget (cron/offline refresh is PRD 651 — this
1103
+ // is just the one-shot in-app pass).
1104
+ setTimeout(() => {
1105
+ finalizeClosedDays().catch((e) => {
1106
+ logs.writeLine({ scope: 'history-rollup', level: 'error', message: 'finalizeClosedDays failed', meta: { error: e?.message } });
1107
+ });
1108
+ runIntradayRefresh();
1109
+ }, 30_000);
1110
+ // Keep TODAY's rollup line current so the History dashboard never needs to
1111
+ // fall back to a live transcript scan. Shares the same O_EXCL lock file as
1112
+ // the external watchdog's finalize pass (scripts/lib/watchdogHelpers.cjs)
1113
+ // so the two never interleave writes to the rollup; a contended lock just
1114
+ // means this tick's refresh is skipped (harmless — the next tick retries).
1115
+ const intradayTimer = setInterval(runIntradayRefresh, schedulerConfig.HISTORY_INTRADAY_REFRESH_MS);
1116
+ if (intradayTimer.unref) intradayTimer.unref();
1081
1117
  // Keep the machine awake while the app is open. The scheduler polls billing
1082
1118
  // usage every 2 min and runs `claude -p` jobs that must survive an idle
1083
1119
  // laptop — a system suspend (GNOME/Pop!_OS idle or lid timeout) would freeze
@@ -351,6 +351,14 @@ const memoryCreate = z.object({
351
351
  description: z.string().max(2048).optional(),
352
352
  }).strict();
353
353
 
354
+ // memory:stale — deterministic staleness scorer (PRD 601). `cwd`, when given,
355
+ // is only used for the dead-ref existence check and is re-validated via
356
+ // config.validatePath in memoryTool.cjs before use.
357
+ const memoryStale = z.object({
358
+ workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
359
+ cwd: z.string().max(4096).optional(),
360
+ }).strict();
361
+
354
362
  // memory:aggregate — Memory Clusters (PRD 356). `workspace` here is already
355
363
  // the encoded cwd slug (memoryAggregate.cjs reads directly from
356
364
  // ~/.claude/projects/<workspace>/memory/), same regex as the other memory:*
@@ -401,6 +409,19 @@ const exchangesList = z.object({
401
409
  offset: z.number().int().min(0).max(100000).optional(),
402
410
  }).strict();
403
411
 
412
+ // ──────────────────────────────────────────── Files (duplicate)
413
+ // files:duplicate — the rest of files:* keeps its schemas local to files.cjs;
414
+ // this one lives here per PRD 638 so it's reusable without importing files.cjs.
415
+ const filesDuplicate = z.object({ path: z.string().min(1).max(4096) });
416
+
417
+ // ──────────────────────────────────────────── Doc Edit (PRD 638 rewrite runner)
418
+ // docedit:run — consumed by docEdit.cjs's registerDocEditHandlers.
419
+ const docEditRun = z.object({
420
+ path: z.string().min(1).max(4096),
421
+ before: z.string().min(1).max(8000),
422
+ instruction: z.string().min(1).max(2000),
423
+ }).strict();
424
+
404
425
  // ──────────────────────────────────────────── Chat runner (PRD 319)
405
426
  // Prompt cap: 100 KiB. Matches a generous interactive message budget while
406
427
  // preventing accidental megabyte pastes from reaching claude -p.
@@ -453,6 +474,10 @@ const historyAggregate = z.object({
453
474
  toDate: z.string().regex(DATE_YYYY_MM_DD).optional(),
454
475
  }).nullish();
455
476
 
477
+ const historyDashboard = z.object({
478
+ rangeDays: z.union([z.literal(30), z.literal(60), z.literal(90), z.literal(0)]),
479
+ }).strict();
480
+
456
481
  // ──────────────────────────────────────────── Voice (F1/F5/F7/F8)
457
482
  // Mirrors voiceSettings.cjs isValidConfig / isValidDevicePref / isValid…
458
483
  // validators (ad-hoc on disk). Schemas here gate the IPC boundary so a
@@ -686,6 +711,7 @@ module.exports = {
686
711
  shellOpen,
687
712
  archiveProject,
688
713
  historyAggregate,
714
+ historyDashboard,
689
715
  voiceSetHotkey,
690
716
  voiceSetDevicePref,
691
717
  voiceSetTurnDetector,
@@ -705,6 +731,7 @@ module.exports = {
705
731
  memoryDelete,
706
732
  memoryCreate,
707
733
  memoryAggregate,
734
+ memoryStale,
708
735
  agentMemoryList,
709
736
  agentMemoryGet,
710
737
  agentMemorySet,
@@ -713,6 +740,8 @@ module.exports = {
713
740
  watchersList,
714
741
  watchersRemove,
715
742
  watchersKillTab,
743
+ filesDuplicate,
744
+ docEditRun,
716
745
  chatRun,
717
746
  chatCancel,
718
747
  chatProbeContext,
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Trailing-edge debounce for a broadcast send. `schedule()` arms a timer on
5
+ * the first call; further calls before it fires are no-ops — they do NOT
6
+ * reset the timer (this is a fixed coalescing window, not an inactivity
7
+ * debounce). When the timer fires, `getPayload()` is invoked fresh so the
8
+ * sent payload reflects every mutation that happened during the window, not
9
+ * just the one that armed it. `flush()` cancels any pending timer and sends
10
+ * immediately, for callers where latency matters more than coalescing.
11
+ */
12
+ function createBroadcastCoalescer({ delayMs, send, getPayload }) {
13
+ let timer = null;
14
+
15
+ async function fire() {
16
+ timer = null;
17
+ try {
18
+ const payload = await getPayload();
19
+ send(payload);
20
+ } catch (e) {
21
+ // Self-heals: the next schedule()/flush() call arms a fresh timer/send
22
+ // since `timer` is already null. Log so a persistent getPayload()
23
+ // failure (e.g. a corrupt queue.json) isn't silently invisible.
24
+ console.error('[broadcastCoalescer] fire() failed', e?.message ?? e);
25
+ }
26
+ }
27
+
28
+ function schedule() {
29
+ if (timer) return;
30
+ timer = setTimeout(() => { fire(); }, delayMs);
31
+ if (timer && typeof timer.unref === 'function') timer.unref();
32
+ }
33
+
34
+ async function flush() {
35
+ if (timer) {
36
+ clearTimeout(timer);
37
+ timer = null;
38
+ }
39
+ const payload = await getPayload();
40
+ send(payload);
41
+ }
42
+
43
+ return { schedule, flush };
44
+ }
45
+
46
+ module.exports = { createBroadcastCoalescer };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Durable daily history rollup — persists per (localDate, projectDir, modelId)
3
+ * scalar aggregates to `~/.claude/session-manager/history-rollup.jsonl` so
4
+ * closed (pre-today) History dashboard days survive an Electron restart and
5
+ * transcript retention/cleanup, instead of being recomputed from raw .jsonl
6
+ * transcripts every cold start.
7
+ *
8
+ * Storage shape: one JSON line per bucket. Lines are append-only; the latest
9
+ * line for a given key wins (last-write-wins), so backfill/re-finalize simply
10
+ * appends a corrected line rather than rewriting history. The file is
11
+ * compacted (deduped + rewritten) once it exceeds COMPACT_THRESHOLD_BYTES.
12
+ *
13
+ * Two kinds of lines share the same shape:
14
+ * - totals line (modelId === TOTALS_MODEL_ID ''): promptCount,
15
+ * toolCallCount, toolBreakdown, errorCount, sessionCount are populated;
16
+ * token fields are 0. One per (date, projectDir).
17
+ * - per-model line (modelId === a real model id): inputTokens,
18
+ * outputTokens, cacheReadTokens, cacheCreationTokens are populated; the
19
+ * rest are 0/empty. Cost is NOT stored — it's computed at read time from
20
+ * MODEL_PRICING so a pricing fix retroactively corrects displayed cost.
21
+ * - finalized marker (projectDir === '', modelId === FINALIZED_MODEL_ID):
22
+ * one per date, carries only `finalizedAt`. A date only counts as closed
23
+ * (safe to trust the rollup with no live re-scan) once this exists.
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const path = require('node:path');
29
+ const os = require('node:os');
30
+ const config = require('../config.cjs');
31
+
32
+ const ROLLUP_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'history-rollup.jsonl');
33
+ const COMPACT_THRESHOLD_BYTES = 5 * 1024 * 1024;
34
+
35
+ const TOTALS_MODEL_ID = '';
36
+ const FINALIZED_MODEL_ID = '__finalized__';
37
+ const FINALIZED_PROJECT_ID = '';
38
+ // Bumped when a bucket/finalized-marker line's shape changes in a way that
39
+ // requires re-deriving it from transcripts (v1 → v2 added `activeMinutes`).
40
+ // isDateFinalized() gates on this so old-shape finalized days get re-scanned
41
+ // by the existing finalizeClosedDays() budget/resume loop instead of a new
42
+ // migration path.
43
+ const ROLLUP_VERSION = 2;
44
+
45
+ function rollupKey(date, projectDir, modelId) {
46
+ return `${date}|${projectDir}|${modelId}`;
47
+ }
48
+
49
+ /**
50
+ * Merge JSONL lines into a Map<key, bucket>, last-write-wins per rollupKey.
51
+ * Malformed lines are skipped rather than throwing, so one corrupt line
52
+ * doesn't take down the whole rollup.
53
+ */
54
+ function mergeRollupLines(lines) {
55
+ const map = new Map();
56
+ for (const raw of lines) {
57
+ const line = raw.trim();
58
+ if (!line) continue;
59
+ let obj;
60
+ try { obj = JSON.parse(line); } catch { continue; }
61
+ if (!obj || typeof obj.date !== 'string') continue;
62
+ const projectDir = typeof obj.projectDir === 'string' ? obj.projectDir : '';
63
+ const modelId = typeof obj.modelId === 'string' ? obj.modelId : '';
64
+ // v1 lines predate activeMinutes — default so every reader sees a number.
65
+ if (typeof obj.activeMinutes !== 'number') obj.activeMinutes = 0;
66
+ map.set(rollupKey(obj.date, projectDir, modelId), obj);
67
+ }
68
+ return map;
69
+ }
70
+
71
+ /**
72
+ * Read all rollup lines and return the merged buckets whose `date` falls
73
+ * within [fromDate, toDate] inclusive (both are localDate 'YYYY-MM-DD'
74
+ * strings). Finalized-marker lines are included in the result — callers that
75
+ * only want data lines should filter on modelId/projectDir.
76
+ */
77
+ async function readRollup(fromDate, toDate) {
78
+ const { exists, text } = await config.readText(ROLLUP_PATH);
79
+ if (!exists || !text) return new Map();
80
+ const merged = mergeRollupLines(text.split('\n'));
81
+ const filtered = new Map();
82
+ for (const [key, bucket] of merged) {
83
+ if (bucket.date >= fromDate && bucket.date <= toDate) filtered.set(key, bucket);
84
+ }
85
+ return filtered;
86
+ }
87
+
88
+ /**
89
+ * True if `date` (< today, by convention) has a finalized marker on disk
90
+ * written at ROLLUP_VERSION or later. A marker written under an older schema
91
+ * (no `v`, or v < ROLLUP_VERSION) is treated as NOT finalized so the existing
92
+ * finalizeClosedDays() budget/resume pass re-scans and upgrades it in place —
93
+ * no separate migration machinery needed.
94
+ */
95
+ function isDateFinalized(mergedMap, date) {
96
+ const bucket = mergedMap.get(rollupKey(date, FINALIZED_PROJECT_ID, FINALIZED_MODEL_ID));
97
+ if (!bucket?.finalizedAt) return false;
98
+ return (bucket.v ?? 1) >= ROLLUP_VERSION;
99
+ }
100
+
101
+ /**
102
+ * Append bucket entries (append-only) to the rollup file, then compact
103
+ * (dedupe + rewrite) if the file has grown past COMPACT_THRESHOLD_BYTES.
104
+ * Uses config.cjs's validatePath/validateWrite for boundary safety and
105
+ * writeTextAtomic (tmp + rename) for the compaction rewrite — never hand
106
+ * rolls its own atomic-write scheme.
107
+ */
108
+ async function appendRollupDays(entries) {
109
+ if (!entries || entries.length === 0) return;
110
+
111
+ const real = config.validatePath(ROLLUP_PATH);
112
+ config.validateWrite(real);
113
+ const fsp = require('node:fs/promises');
114
+ await fsp.mkdir(path.dirname(real), { recursive: true });
115
+
116
+ const lines = entries.map((e) => JSON.stringify(e)).join('\n') + '\n';
117
+ await fsp.appendFile(real, lines, 'utf8');
118
+
119
+ let size = 0;
120
+ try { size = (await fsp.stat(real)).size; } catch { /* just wrote it */ }
121
+ if (size > COMPACT_THRESHOLD_BYTES) {
122
+ await compact();
123
+ }
124
+ }
125
+
126
+ /** Dedupe the rollup file down to its last-write-wins merged lines. */
127
+ async function compact() {
128
+ const { exists, text } = await config.readText(ROLLUP_PATH);
129
+ if (!exists || !text) return;
130
+ const merged = mergeRollupLines(text.split('\n'));
131
+ const rewritten = Array.from(merged.values()).map((b) => JSON.stringify(b)).join('\n') + '\n';
132
+ await config.writeTextAtomic(ROLLUP_PATH, rewritten);
133
+ }
134
+
135
+ module.exports = {
136
+ ROLLUP_PATH,
137
+ COMPACT_THRESHOLD_BYTES,
138
+ TOTALS_MODEL_ID,
139
+ FINALIZED_MODEL_ID,
140
+ FINALIZED_PROJECT_ID,
141
+ ROLLUP_VERSION,
142
+ rollupKey,
143
+ mergeRollupLines,
144
+ readRollup,
145
+ isDateFinalized,
146
+ appendRollupDays,
147
+ compact,
148
+ };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pure, deterministic staleness scorer for workspace memories. No fs/electron
5
+ * access — all IO is injected via `entries` (pre-read) and `existsPath`
6
+ * (predicate), so this module is unit-testable with zero mocking.
7
+ */
8
+
9
+ const DAY_MS = 86_400_000;
10
+ const AGE_STALE_DAYS = 90;
11
+ const MAX_DEAD_REF_CANDIDATES = 20;
12
+
13
+ const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
14
+ // Backtick-quoted repo-path-shaped tokens, optionally suffixed with a :line.
15
+ const PATH_TOKEN_RE = /`([A-Za-z0-9_./-]+\.[A-Za-z0-9]{1,5}(?::\d+)?)`/g;
16
+
17
+ const KNOWN_SOURCE_EXTENSIONS = new Set([
18
+ 'js', 'jsx', 'ts', 'tsx', 'cjs', 'mjs', 'json', 'md', 'py', 'rb', 'go', 'rs',
19
+ 'java', 'c', 'h', 'cpp', 'hpp', 'css', 'scss', 'html', 'yml', 'yaml', 'sh',
20
+ 'txt', 'toml', 'lock', 'sql', 'php', 'swift', 'kt', 'vue',
21
+ ]);
22
+
23
+ function stripName(name) {
24
+ return name.replace(/\.md$/, '');
25
+ }
26
+
27
+ /**
28
+ * Extract dead-ref candidate paths from a memory body.
29
+ * Complexity: O(body length) — one regex pass, then O(candidates) filtering.
30
+ */
31
+ function extractCandidates(body) {
32
+ const seen = new Set();
33
+ const out = [];
34
+ let m;
35
+ PATH_TOKEN_RE.lastIndex = 0;
36
+ while ((m = PATH_TOKEN_RE.exec(body)) !== null) {
37
+ if (out.length >= MAX_DEAD_REF_CANDIDATES) break;
38
+ let token = m[1].replace(/:\d+$/, '');
39
+ if (token.startsWith('/') || token.startsWith('~')) continue; // not repo-relative
40
+ const ext = token.includes('.') ? token.split('.').pop().toLowerCase() : '';
41
+ const hasSlash = token.includes('/');
42
+ if (!hasSlash && !KNOWN_SOURCE_EXTENSIONS.has(ext)) continue;
43
+ if (seen.has(token)) continue;
44
+ seen.add(token);
45
+ out.push(token);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * scoreMemories({ entries, now, existsPath })
52
+ *
53
+ * entries: Array<{ name, mtimeMs, body }>
54
+ * now: ms epoch
55
+ * existsPath: (relPath: string) => boolean
56
+ *
57
+ * Complexity: O(total body bytes) — a single pass per entry to extract
58
+ * wikilink targets and dead-ref candidates; the inbound-link fold is
59
+ * O(entries + total links), NOT O(n^2) substring scans.
60
+ */
61
+ function scoreMemories({ entries, now, existsPath }) {
62
+ // Signal 2 (pass 1): per-entry set of wikilink targets, deduped so repeated
63
+ // links to the same slug within one body count once toward that slug's
64
+ // inbound count.
65
+ const perEntryTargets = entries.map((e) => {
66
+ const targets = new Set();
67
+ let m;
68
+ WIKILINK_RE.lastIndex = 0;
69
+ while ((m = WIKILINK_RE.exec(e.body)) !== null) {
70
+ targets.add(m[1].trim());
71
+ }
72
+ return targets;
73
+ });
74
+
75
+ const inboundCounts = new Map();
76
+ entries.forEach((e, i) => {
77
+ const selfSlug = stripName(e.name);
78
+ for (const target of perEntryTargets[i]) {
79
+ if (target === selfSlug) continue; // a memory linking itself isn't an inbound reference
80
+ inboundCounts.set(target, (inboundCounts.get(target) || 0) + 1);
81
+ }
82
+ });
83
+
84
+ return entries.map((e) => {
85
+ const ageDays = Math.floor((now - e.mtimeMs) / DAY_MS);
86
+ const slug = stripName(e.name);
87
+ const inboundLinks = inboundCounts.get(slug) || 0;
88
+
89
+ const candidates = extractCandidates(e.body);
90
+ const deadRefs = candidates.filter((c) => !existsPath(c));
91
+
92
+ const reasons = [];
93
+ if (deadRefs.length > 0) {
94
+ reasons.push(
95
+ deadRefs.length === 1
96
+ ? 'references 1 path that no longer exists'
97
+ : `references ${deadRefs.length} paths that no longer exist`
98
+ );
99
+ }
100
+ const oldAndUnlinked = ageDays > AGE_STALE_DAYS && inboundLinks === 0;
101
+ if (oldAndUnlinked) {
102
+ reasons.push('90+ days old with no inbound links');
103
+ }
104
+
105
+ return {
106
+ name: e.name,
107
+ ageDays,
108
+ inboundLinks,
109
+ deadRefs,
110
+ stale: deadRefs.length > 0 || oldAndUnlinked,
111
+ reasons,
112
+ };
113
+ });
114
+ }
115
+
116
+ module.exports = { scoreMemories };