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.
- package/dist/assets/{TiptapBody-DgFO_m3g.js → TiptapBody-Cg8YZhVZ.js} +58 -58
- package/dist/assets/index-CTTjT08J.css +32 -0
- package/dist/assets/index-_iBXLuvt.js +3496 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
- package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
- package/src/main/__tests__/docEdit.test.cjs +82 -0
- package/src/main/__tests__/historyDashboard.test.cjs +163 -0
- package/src/main/__tests__/historyRollup.test.cjs +333 -0
- package/src/main/__tests__/memoryStale.test.cjs +88 -0
- package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
- package/src/main/__tests__/queueHistory.test.cjs +233 -0
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +4 -5
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
- package/src/main/chatRunner.cjs +57 -18
- package/src/main/config.cjs +8 -0
- package/src/main/docEdit.cjs +133 -0
- package/src/main/files.cjs +53 -0
- package/src/main/historyAggregator.cjs +391 -50
- package/src/main/historyDashboard.cjs +226 -0
- package/src/main/index.cjs +37 -1
- package/src/main/ipcSchemas.cjs +29 -0
- package/src/main/lib/broadcastCoalescer.cjs +46 -0
- package/src/main/lib/historyRollup.cjs +148 -0
- package/src/main/lib/memoryStale.cjs +116 -0
- package/src/main/lib/queueHistory.cjs +216 -0
- package/src/main/lib/rcaFeedbackHook.cjs +346 -0
- package/src/main/lib/schedulerConfig.cjs +16 -0
- package/src/main/memoryTool.cjs +49 -0
- package/src/main/queueOps.cjs +92 -0
- package/src/main/scheduler/prdParser.cjs +52 -1
- package/src/main/scheduler.cjs +237 -29
- package/src/preload/api.d.ts +92 -1
- package/src/preload/index.cjs +7 -0
- package/dist/assets/index-4dJkn9nT.js +0 -3559
- 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
|
+
};
|
package/src/main/index.cjs
CHANGED
|
@@ -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
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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 };
|