smart-context-mcp 1.0.2 → 1.0.4
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/README.md +145 -30
- package/package.json +8 -3
- package/scripts/check-repo-safety.js +84 -0
- package/scripts/claude-hook.js +33 -0
- package/scripts/headless-wrapper.js +106 -0
- package/scripts/init-clients.js +138 -7
- package/scripts/report-metrics.js +24 -119
- package/src/hooks/claude-hooks.js +424 -0
- package/src/mcp-server.js +6 -3
- package/src/metrics.js +218 -8
- package/src/orchestration/headless-wrapper.js +314 -0
- package/src/repo-safety.js +166 -0
- package/src/server.js +83 -4
- package/src/storage/sqlite.js +1092 -0
- package/src/tools/smart-metrics.js +249 -0
- package/src/tools/smart-summary.js +1230 -324
- package/src/tools/smart-turn.js +307 -0
- package/src/utils/runtime-config.js +13 -1
|
@@ -1,23 +1,120 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { projectRoot } from '../utils/runtime-config.js';
|
|
4
1
|
import { countTokens } from '../tokenCounter.js';
|
|
5
2
|
import { persistMetrics } from '../metrics.js';
|
|
3
|
+
import { enforceRepoSafety, getRepoSafety } from '../repo-safety.js';
|
|
4
|
+
import {
|
|
5
|
+
ACTIVE_SESSION_SCOPE,
|
|
6
|
+
SQLITE_SCHEMA_VERSION,
|
|
7
|
+
cleanupLegacyState,
|
|
8
|
+
compactState,
|
|
9
|
+
importLegacyState,
|
|
10
|
+
withStateDb,
|
|
11
|
+
withStateDbSnapshot,
|
|
12
|
+
} from '../storage/sqlite.js';
|
|
6
13
|
|
|
7
14
|
const MAX_SESSION_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
8
15
|
const DEFAULT_MAX_TOKENS = 500;
|
|
9
16
|
const VALID_STATUSES = new Set(['planning', 'in_progress', 'blocked', 'completed']);
|
|
17
|
+
const ACTIVE_STATUSES = new Set(['planning', 'in_progress', 'blocked']);
|
|
10
18
|
const DEFAULT_STATUS = 'in_progress';
|
|
11
|
-
const
|
|
19
|
+
const AUTO_RESUME_SESSION_ID = 'auto';
|
|
20
|
+
const AUTO_RESUME_RECENCY_GAP_MS = 12 * 60 * 60 * 1000;
|
|
21
|
+
const MAX_RESUME_CANDIDATES = 5;
|
|
22
|
+
const DEFAULT_CHECKPOINT_EVENT = 'manual';
|
|
23
|
+
const CHECKPOINT_FIELD_BASE_SCORE = {
|
|
24
|
+
goal: 5,
|
|
25
|
+
status: 4,
|
|
26
|
+
pinnedContext: 4,
|
|
27
|
+
unresolvedQuestions: 2,
|
|
28
|
+
currentFocus: 2,
|
|
29
|
+
whyBlocked: 4,
|
|
30
|
+
completed: 2,
|
|
31
|
+
decisions: 4,
|
|
32
|
+
blockers: 4,
|
|
33
|
+
nextStep: 4,
|
|
34
|
+
touchedFiles: 1,
|
|
35
|
+
};
|
|
36
|
+
const CHECKPOINT_POLICY_BY_EVENT = {
|
|
37
|
+
manual: {
|
|
38
|
+
persistByDefault: true,
|
|
39
|
+
minScore: 1,
|
|
40
|
+
requiredChangedFields: [],
|
|
41
|
+
reason: 'Manual checkpoint requested.',
|
|
42
|
+
},
|
|
43
|
+
milestone: {
|
|
44
|
+
persistByDefault: true,
|
|
45
|
+
minScore: 4,
|
|
46
|
+
requiredChangedFields: ['completed', 'decisions', 'nextStep', 'touchedFiles', 'currentFocus', 'pinnedContext'],
|
|
47
|
+
reason: 'Milestone checkpoints should persist durable progress.',
|
|
48
|
+
},
|
|
49
|
+
decision: {
|
|
50
|
+
persistByDefault: true,
|
|
51
|
+
minScore: 4,
|
|
52
|
+
requiredChangedFields: ['decisions', 'pinnedContext', 'nextStep'],
|
|
53
|
+
reason: 'Decision checkpoints should preserve rationale and next actions.',
|
|
54
|
+
},
|
|
55
|
+
blocker: {
|
|
56
|
+
persistByDefault: true,
|
|
57
|
+
minScore: 4,
|
|
58
|
+
requiredChangedFields: ['status', 'blockers', 'whyBlocked', 'nextStep'],
|
|
59
|
+
reason: 'Blocker checkpoints should preserve blocking context.',
|
|
60
|
+
},
|
|
61
|
+
status_change: {
|
|
62
|
+
persistByDefault: true,
|
|
63
|
+
minScore: 3,
|
|
64
|
+
requiredChangedFields: ['status', 'nextStep', 'whyBlocked'],
|
|
65
|
+
reason: 'Status changes should be checkpointed when state actually changes.',
|
|
66
|
+
},
|
|
67
|
+
file_change: {
|
|
68
|
+
persistByDefault: true,
|
|
69
|
+
minScore: 3,
|
|
70
|
+
requiredChangedFields: ['touchedFiles', 'completed', 'nextStep'],
|
|
71
|
+
reason: 'File-change checkpoints should persist only when work moved forward.',
|
|
72
|
+
},
|
|
73
|
+
task_switch: {
|
|
74
|
+
persistByDefault: true,
|
|
75
|
+
minScore: 4,
|
|
76
|
+
requiredChangedFields: ['goal', 'currentFocus', 'nextStep', 'pinnedContext'],
|
|
77
|
+
reason: 'Task switches should preserve the handoff state.',
|
|
78
|
+
},
|
|
79
|
+
task_complete: {
|
|
80
|
+
persistByDefault: true,
|
|
81
|
+
minScore: 5,
|
|
82
|
+
requiredChangedFields: ['status', 'completed', 'decisions', 'nextStep'],
|
|
83
|
+
reason: 'Task completion should leave a durable summary.',
|
|
84
|
+
},
|
|
85
|
+
session_end: {
|
|
86
|
+
persistByDefault: true,
|
|
87
|
+
minScore: 3,
|
|
88
|
+
requiredChangedFields: ['nextStep', 'status', 'completed', 'decisions', 'touchedFiles'],
|
|
89
|
+
reason: 'Session-end checkpoints should capture the latest restart point.',
|
|
90
|
+
},
|
|
91
|
+
read_only: {
|
|
92
|
+
persistByDefault: false,
|
|
93
|
+
minScore: Infinity,
|
|
94
|
+
requiredChangedFields: [],
|
|
95
|
+
reason: 'Read-only exploration should not persist by default.',
|
|
96
|
+
},
|
|
97
|
+
heartbeat: {
|
|
98
|
+
persistByDefault: false,
|
|
99
|
+
minScore: Infinity,
|
|
100
|
+
requiredChangedFields: [],
|
|
101
|
+
reason: 'Heartbeat checkpoints are intentionally suppressed.',
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const SUMMARY_WRITE_ACTIONS = new Set(['update', 'append', 'auto_append', 'checkpoint', 'reset', 'compact']);
|
|
105
|
+
const HARD_BLOCK_REPO_SAFETY_REASONS = [
|
|
106
|
+
['tracked', 'isTracked'],
|
|
107
|
+
['staged', 'isStaged'],
|
|
108
|
+
];
|
|
12
109
|
|
|
13
|
-
const
|
|
14
|
-
const
|
|
110
|
+
const getTimestamp = (value, fallback = Date.now()) => {
|
|
111
|
+
const parsed = Date.parse(value ?? '');
|
|
112
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
113
|
+
};
|
|
15
114
|
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
20
|
-
}
|
|
115
|
+
const toIsoString = (value, fallback = new Date().toISOString()) => {
|
|
116
|
+
const parsed = getTimestamp(value, NaN);
|
|
117
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
|
|
21
118
|
};
|
|
22
119
|
|
|
23
120
|
const generateSessionId = (goal) => {
|
|
@@ -28,122 +125,6 @@ const generateSessionId = (goal) => {
|
|
|
28
125
|
return `${date}-${slug}`;
|
|
29
126
|
};
|
|
30
127
|
|
|
31
|
-
const getSessionPath = (sessionId) => path.join(getSessionsDir(), `${sessionId}.json`);
|
|
32
|
-
|
|
33
|
-
const loadSession = (sessionId) => {
|
|
34
|
-
const sessionPath = getSessionPath(sessionId);
|
|
35
|
-
if (!fs.existsSync(sessionPath)) {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
try {
|
|
39
|
-
const data = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
|
|
40
|
-
return data;
|
|
41
|
-
} catch {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const saveSession = (sessionId, data) => {
|
|
47
|
-
ensureSessionsDir();
|
|
48
|
-
const sessionPath = getSessionPath(sessionId);
|
|
49
|
-
const sessionData = {
|
|
50
|
-
...data,
|
|
51
|
-
schemaVersion: SESSION_SCHEMA_VERSION,
|
|
52
|
-
sessionId,
|
|
53
|
-
updatedAt: new Date().toISOString(),
|
|
54
|
-
};
|
|
55
|
-
fs.writeFileSync(sessionPath, JSON.stringify(sessionData, null, 2), 'utf8');
|
|
56
|
-
|
|
57
|
-
const activeSessionFile = getActiveSessionFile();
|
|
58
|
-
fs.writeFileSync(activeSessionFile, JSON.stringify({ sessionId, updatedAt: sessionData.updatedAt }, null, 2), 'utf8');
|
|
59
|
-
|
|
60
|
-
return sessionData;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const getActiveSession = () => {
|
|
64
|
-
const activeSessionFile = getActiveSessionFile();
|
|
65
|
-
if (!fs.existsSync(activeSessionFile)) {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
try {
|
|
69
|
-
const { sessionId } = JSON.parse(fs.readFileSync(activeSessionFile, 'utf8'));
|
|
70
|
-
const activeSession = loadSession(sessionId);
|
|
71
|
-
if (!activeSession) {
|
|
72
|
-
fs.unlinkSync(activeSessionFile);
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
return activeSession;
|
|
76
|
-
} catch {
|
|
77
|
-
try {
|
|
78
|
-
fs.unlinkSync(activeSessionFile);
|
|
79
|
-
} catch {}
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const cleanupStaleSessions = () => {
|
|
85
|
-
ensureSessionsDir();
|
|
86
|
-
const sessionsDir = getSessionsDir();
|
|
87
|
-
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.json') && f !== 'active.json');
|
|
88
|
-
const now = Date.now();
|
|
89
|
-
let cleaned = 0;
|
|
90
|
-
|
|
91
|
-
const activeSession = getActiveSession();
|
|
92
|
-
const activeSessionId = activeSession?.sessionId;
|
|
93
|
-
|
|
94
|
-
for (const file of files) {
|
|
95
|
-
const sessionPath = path.join(sessionsDir, file);
|
|
96
|
-
try {
|
|
97
|
-
const data = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
|
|
98
|
-
|
|
99
|
-
if (data.sessionId === activeSessionId) {
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const age = now - new Date(data.updatedAt).getTime();
|
|
104
|
-
if (age > MAX_SESSION_AGE_MS) {
|
|
105
|
-
fs.unlinkSync(sessionPath);
|
|
106
|
-
cleaned += 1;
|
|
107
|
-
}
|
|
108
|
-
} catch {
|
|
109
|
-
fs.unlinkSync(sessionPath);
|
|
110
|
-
cleaned += 1;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return cleaned;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const listSessions = () => {
|
|
118
|
-
ensureSessionsDir();
|
|
119
|
-
cleanupStaleSessions();
|
|
120
|
-
|
|
121
|
-
const sessionsDir = getSessionsDir();
|
|
122
|
-
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.json') && f !== 'active.json');
|
|
123
|
-
const now = Date.now();
|
|
124
|
-
|
|
125
|
-
return files
|
|
126
|
-
.map(file => {
|
|
127
|
-
const sessionPath = path.join(sessionsDir, file);
|
|
128
|
-
try {
|
|
129
|
-
const data = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
|
|
130
|
-
const age = now - new Date(data.updatedAt).getTime();
|
|
131
|
-
return {
|
|
132
|
-
sessionId: data.sessionId,
|
|
133
|
-
goal: data.goal,
|
|
134
|
-
status: data.status,
|
|
135
|
-
updatedAt: data.updatedAt,
|
|
136
|
-
ageMs: age,
|
|
137
|
-
isStale: age > MAX_SESSION_AGE_MS,
|
|
138
|
-
};
|
|
139
|
-
} catch {
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
})
|
|
143
|
-
.filter(Boolean)
|
|
144
|
-
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
145
|
-
};
|
|
146
|
-
|
|
147
128
|
const truncateString = (str, maxLength) => {
|
|
148
129
|
if (!str || str.length <= maxLength) return str;
|
|
149
130
|
if (maxLength <= 3) return '';
|
|
@@ -172,7 +153,7 @@ const compactFilePath = (filePath) => {
|
|
|
172
153
|
|
|
173
154
|
const validateUpdateInput = (update) => {
|
|
174
155
|
if (!update || typeof update !== 'object') {
|
|
175
|
-
throw new Error('update parameter is required for update/append actions');
|
|
156
|
+
throw new Error('update parameter is required for update/append/auto_append actions');
|
|
176
157
|
}
|
|
177
158
|
|
|
178
159
|
if (update.status !== undefined && !VALID_STATUSES.has(update.status)) {
|
|
@@ -197,6 +178,331 @@ const mergeUniqueStrings = (...lists) => {
|
|
|
197
178
|
return result;
|
|
198
179
|
};
|
|
199
180
|
|
|
181
|
+
const buildComparableSessionState = (data = {}) => ({
|
|
182
|
+
goal: typeof data.goal === 'string' ? data.goal : '',
|
|
183
|
+
status: normalizeStatus(data.status),
|
|
184
|
+
pinnedContext: mergeUniqueStrings(data.pinnedContext),
|
|
185
|
+
unresolvedQuestions: mergeUniqueStrings(data.unresolvedQuestions),
|
|
186
|
+
currentFocus: typeof data.currentFocus === 'string' ? data.currentFocus : '',
|
|
187
|
+
whyBlocked: typeof data.whyBlocked === 'string' ? data.whyBlocked : '',
|
|
188
|
+
completed: mergeUniqueStrings(data.completed),
|
|
189
|
+
decisions: mergeUniqueStrings(data.decisions),
|
|
190
|
+
blockers: mergeUniqueStrings(data.blockers),
|
|
191
|
+
nextStep: typeof data.nextStep === 'string' ? data.nextStep : '',
|
|
192
|
+
touchedFiles: mergeUniqueStrings(data.touchedFiles),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const buildAppendData = (existingData, update) => {
|
|
196
|
+
const completed = mergeUniqueStrings(existingData.completed, update.completed);
|
|
197
|
+
const decisions = mergeUniqueStrings(existingData.decisions, update.decisions);
|
|
198
|
+
const touchedFiles = mergeUniqueStrings(existingData.touchedFiles, update.touchedFiles);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
goal: update.goal || existingData.goal || 'Untitled session',
|
|
202
|
+
status: normalizeStatus(update.status, normalizeStatus(existingData.status)),
|
|
203
|
+
pinnedContext: mergeUniqueStrings(existingData.pinnedContext, update.pinnedContext),
|
|
204
|
+
unresolvedQuestions: mergeUniqueStrings(existingData.unresolvedQuestions, update.unresolvedQuestions),
|
|
205
|
+
currentFocus: update.currentFocus || existingData.currentFocus || '',
|
|
206
|
+
whyBlocked: update.whyBlocked || existingData.whyBlocked || '',
|
|
207
|
+
completed,
|
|
208
|
+
decisions,
|
|
209
|
+
blockers: update.blockers !== undefined ? mergeUniqueStrings(update.blockers) : (existingData.blockers || []),
|
|
210
|
+
nextStep: update.nextStep || existingData.nextStep || '',
|
|
211
|
+
touchedFiles,
|
|
212
|
+
completedCount: completed.length,
|
|
213
|
+
decisionsCount: decisions.length,
|
|
214
|
+
touchedFilesCount: touchedFiles.length,
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const buildReplaceData = (update) => {
|
|
219
|
+
const completed = mergeUniqueStrings(update.completed);
|
|
220
|
+
const decisions = mergeUniqueStrings(update.decisions);
|
|
221
|
+
const touchedFiles = mergeUniqueStrings(update.touchedFiles);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
goal: update.goal || 'Untitled session',
|
|
225
|
+
status: normalizeStatus(update.status),
|
|
226
|
+
pinnedContext: mergeUniqueStrings(update.pinnedContext),
|
|
227
|
+
unresolvedQuestions: mergeUniqueStrings(update.unresolvedQuestions),
|
|
228
|
+
currentFocus: update.currentFocus ?? '',
|
|
229
|
+
whyBlocked: update.whyBlocked ?? '',
|
|
230
|
+
completed,
|
|
231
|
+
decisions,
|
|
232
|
+
blockers: mergeUniqueStrings(update.blockers),
|
|
233
|
+
nextStep: update.nextStep ?? '',
|
|
234
|
+
touchedFiles,
|
|
235
|
+
completedCount: completed.length,
|
|
236
|
+
decisionsCount: decisions.length,
|
|
237
|
+
touchedFilesCount: touchedFiles.length,
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const diffUniqueStrings = (before, after) => {
|
|
242
|
+
const seen = new Set(mergeUniqueStrings(before));
|
|
243
|
+
return mergeUniqueStrings(after).filter((item) => !seen.has(item));
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const getAutoAppendChanges = (existingData, mergedData) => {
|
|
247
|
+
if (!existingData || Object.keys(existingData).length === 0) {
|
|
248
|
+
return ['create_session'];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const comparableBefore = buildComparableSessionState(existingData);
|
|
252
|
+
const comparableAfter = buildComparableSessionState(mergedData);
|
|
253
|
+
const changes = [];
|
|
254
|
+
|
|
255
|
+
if (comparableAfter.goal !== comparableBefore.goal) changes.push('goal');
|
|
256
|
+
if (comparableAfter.status !== comparableBefore.status) changes.push('status');
|
|
257
|
+
if (comparableAfter.currentFocus !== comparableBefore.currentFocus) changes.push('currentFocus');
|
|
258
|
+
if (comparableAfter.whyBlocked !== comparableBefore.whyBlocked) changes.push('whyBlocked');
|
|
259
|
+
if (comparableAfter.nextStep !== comparableBefore.nextStep) changes.push('nextStep');
|
|
260
|
+
if (diffUniqueStrings(comparableBefore.pinnedContext, comparableAfter.pinnedContext).length > 0) changes.push('pinnedContext');
|
|
261
|
+
if (diffUniqueStrings(comparableBefore.unresolvedQuestions, comparableAfter.unresolvedQuestions).length > 0) changes.push('unresolvedQuestions');
|
|
262
|
+
if (diffUniqueStrings(comparableBefore.completed, comparableAfter.completed).length > 0) changes.push('completed');
|
|
263
|
+
if (diffUniqueStrings(comparableBefore.decisions, comparableAfter.decisions).length > 0) changes.push('decisions');
|
|
264
|
+
if (JSON.stringify(comparableAfter.blockers) !== JSON.stringify(comparableBefore.blockers)) changes.push('blockers');
|
|
265
|
+
if (diffUniqueStrings(comparableBefore.touchedFiles, comparableAfter.touchedFiles).length > 0) changes.push('touchedFiles');
|
|
266
|
+
|
|
267
|
+
return changes;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const analyzeCheckpointChanges = (existingData, mergedData) => {
|
|
271
|
+
const changedFields = getAutoAppendChanges(existingData, mergedData);
|
|
272
|
+
const comparableBefore = buildComparableSessionState(existingData);
|
|
273
|
+
const comparableAfter = buildComparableSessionState(mergedData);
|
|
274
|
+
const fieldStats = {};
|
|
275
|
+
const scoreByField = {};
|
|
276
|
+
let score = 0;
|
|
277
|
+
|
|
278
|
+
const assignFieldScore = (field, nextScore) => {
|
|
279
|
+
if (!changedFields.includes(field) || nextScore <= 0) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
scoreByField[field] = nextScore;
|
|
284
|
+
score += nextScore;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
if (changedFields.includes('goal')) {
|
|
288
|
+
fieldStats.goal = {
|
|
289
|
+
before: comparableBefore.goal,
|
|
290
|
+
after: comparableAfter.goal,
|
|
291
|
+
};
|
|
292
|
+
assignFieldScore('goal', CHECKPOINT_FIELD_BASE_SCORE.goal);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (changedFields.includes('status')) {
|
|
296
|
+
const before = comparableBefore.status;
|
|
297
|
+
const after = comparableAfter.status;
|
|
298
|
+
const transitionBonus = (after === 'blocked' || after === 'completed' || before === 'blocked') ? 2 : 0;
|
|
299
|
+
fieldStats.status = { before, after };
|
|
300
|
+
assignFieldScore('status', CHECKPOINT_FIELD_BASE_SCORE.status + transitionBonus);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (changedFields.includes('currentFocus')) {
|
|
304
|
+
fieldStats.currentFocus = {
|
|
305
|
+
before: comparableBefore.currentFocus,
|
|
306
|
+
after: comparableAfter.currentFocus,
|
|
307
|
+
};
|
|
308
|
+
assignFieldScore('currentFocus', CHECKPOINT_FIELD_BASE_SCORE.currentFocus);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (changedFields.includes('whyBlocked')) {
|
|
312
|
+
fieldStats.whyBlocked = {
|
|
313
|
+
before: comparableBefore.whyBlocked,
|
|
314
|
+
after: comparableAfter.whyBlocked,
|
|
315
|
+
};
|
|
316
|
+
assignFieldScore('whyBlocked', CHECKPOINT_FIELD_BASE_SCORE.whyBlocked);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (changedFields.includes('nextStep')) {
|
|
320
|
+
fieldStats.nextStep = {
|
|
321
|
+
before: comparableBefore.nextStep,
|
|
322
|
+
after: comparableAfter.nextStep,
|
|
323
|
+
};
|
|
324
|
+
assignFieldScore('nextStep', CHECKPOINT_FIELD_BASE_SCORE.nextStep);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const arrayFields = [
|
|
328
|
+
'pinnedContext',
|
|
329
|
+
'unresolvedQuestions',
|
|
330
|
+
'completed',
|
|
331
|
+
'decisions',
|
|
332
|
+
'blockers',
|
|
333
|
+
'touchedFiles',
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
for (const field of arrayFields) {
|
|
337
|
+
if (!changedFields.includes(field)) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const added = diffUniqueStrings(comparableBefore[field], comparableAfter[field]);
|
|
342
|
+
const removed = diffUniqueStrings(comparableAfter[field], comparableBefore[field]);
|
|
343
|
+
fieldStats[field] = {
|
|
344
|
+
added,
|
|
345
|
+
addedCount: added.length,
|
|
346
|
+
removedCount: removed.length,
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
let nextScore = CHECKPOINT_FIELD_BASE_SCORE[field] ?? 0;
|
|
350
|
+
if (field === 'completed') nextScore += Math.min(2, Math.max(0, added.length - 1));
|
|
351
|
+
if (field === 'decisions') nextScore += Math.min(2, Math.max(0, added.length - 1));
|
|
352
|
+
if (field === 'pinnedContext') nextScore += Math.min(2, Math.max(0, added.length - 1));
|
|
353
|
+
if (field === 'blockers') nextScore += Math.min(2, Math.max(0, added.length - 1));
|
|
354
|
+
if (field === 'touchedFiles') nextScore = added.length >= 3 ? 3 : added.length >= 1 ? 1 : 0;
|
|
355
|
+
if (field === 'unresolvedQuestions') nextScore += Math.min(1, Math.max(0, added.length - 1));
|
|
356
|
+
|
|
357
|
+
assignFieldScore(field, nextScore);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
changedFields,
|
|
362
|
+
fieldStats,
|
|
363
|
+
score,
|
|
364
|
+
scoreByField,
|
|
365
|
+
};
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const hasMeaningfulAutoAppendInput = (update = {}) =>
|
|
369
|
+
update.status !== undefined
|
|
370
|
+
|| Object.values(pruneEmptyFields(update)).some((value) =>
|
|
371
|
+
(typeof value === 'string' && value.trim().length > 0)
|
|
372
|
+
|| (Array.isArray(value) && value.length > 0),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const normalizeCheckpointEvent = (value) => {
|
|
376
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
377
|
+
return CHECKPOINT_POLICY_BY_EVENT[normalized] ? normalized : DEFAULT_CHECKPOINT_EVENT;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const resolveCheckpointDecision = ({ event, force = false, existingData, mergedData }) => {
|
|
381
|
+
const normalizedEvent = normalizeCheckpointEvent(event);
|
|
382
|
+
const policy = CHECKPOINT_POLICY_BY_EVENT[normalizedEvent];
|
|
383
|
+
const analysis = analyzeCheckpointChanges(existingData, mergedData);
|
|
384
|
+
const { changedFields, fieldStats, score, scoreByField } = analysis;
|
|
385
|
+
|
|
386
|
+
if (force) {
|
|
387
|
+
return {
|
|
388
|
+
event: normalizedEvent,
|
|
389
|
+
changedFields,
|
|
390
|
+
fieldStats,
|
|
391
|
+
score,
|
|
392
|
+
scoreByField,
|
|
393
|
+
threshold: policy.minScore,
|
|
394
|
+
shouldPersist: true,
|
|
395
|
+
reason: 'Checkpoint forced by caller.',
|
|
396
|
+
policy,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (!policy.persistByDefault) {
|
|
401
|
+
return {
|
|
402
|
+
event: normalizedEvent,
|
|
403
|
+
changedFields,
|
|
404
|
+
fieldStats,
|
|
405
|
+
score,
|
|
406
|
+
scoreByField,
|
|
407
|
+
threshold: policy.minScore,
|
|
408
|
+
shouldPersist: false,
|
|
409
|
+
reason: policy.reason,
|
|
410
|
+
policy,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (changedFields.length === 0) {
|
|
415
|
+
return {
|
|
416
|
+
event: normalizedEvent,
|
|
417
|
+
changedFields,
|
|
418
|
+
fieldStats,
|
|
419
|
+
score,
|
|
420
|
+
scoreByField,
|
|
421
|
+
threshold: policy.minScore,
|
|
422
|
+
shouldPersist: false,
|
|
423
|
+
reason: 'Checkpoint skipped because no meaningful context changed.',
|
|
424
|
+
policy,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (policy.requiredChangedFields.length > 0) {
|
|
429
|
+
const relevantChange = policy.requiredChangedFields.some((field) => changedFields.includes(field));
|
|
430
|
+
if (!relevantChange) {
|
|
431
|
+
return {
|
|
432
|
+
event: normalizedEvent,
|
|
433
|
+
changedFields,
|
|
434
|
+
fieldStats,
|
|
435
|
+
score,
|
|
436
|
+
scoreByField,
|
|
437
|
+
threshold: policy.minScore,
|
|
438
|
+
shouldPersist: false,
|
|
439
|
+
reason: `Checkpoint event "${normalizedEvent}" requires one of: ${policy.requiredChangedFields.join(', ')}.`,
|
|
440
|
+
policy,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (normalizedEvent === 'file_change' && changedFields.length === 1 && changedFields[0] === 'touchedFiles') {
|
|
446
|
+
const addedFiles = fieldStats.touchedFiles?.addedCount ?? 0;
|
|
447
|
+
if (addedFiles < 2) {
|
|
448
|
+
return {
|
|
449
|
+
event: normalizedEvent,
|
|
450
|
+
changedFields,
|
|
451
|
+
fieldStats,
|
|
452
|
+
score,
|
|
453
|
+
scoreByField,
|
|
454
|
+
threshold: policy.minScore,
|
|
455
|
+
shouldPersist: false,
|
|
456
|
+
reason: 'Checkpoint skipped because a single touched file without progress is not significant enough.',
|
|
457
|
+
policy,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (normalizedEvent === 'task_complete') {
|
|
463
|
+
const completedStateReached = mergedData.status === 'completed';
|
|
464
|
+
if (!completedStateReached && !changedFields.includes('completed')) {
|
|
465
|
+
return {
|
|
466
|
+
event: normalizedEvent,
|
|
467
|
+
changedFields,
|
|
468
|
+
fieldStats,
|
|
469
|
+
score,
|
|
470
|
+
scoreByField,
|
|
471
|
+
threshold: policy.minScore,
|
|
472
|
+
shouldPersist: false,
|
|
473
|
+
reason: 'Task completion checkpoints require completed work or a completed status.',
|
|
474
|
+
policy,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (score < policy.minScore) {
|
|
480
|
+
return {
|
|
481
|
+
event: normalizedEvent,
|
|
482
|
+
changedFields,
|
|
483
|
+
fieldStats,
|
|
484
|
+
score,
|
|
485
|
+
scoreByField,
|
|
486
|
+
threshold: policy.minScore,
|
|
487
|
+
shouldPersist: false,
|
|
488
|
+
reason: `Checkpoint skipped because significance score ${score} is below threshold ${policy.minScore}.`,
|
|
489
|
+
policy,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
event: normalizedEvent,
|
|
495
|
+
changedFields,
|
|
496
|
+
fieldStats,
|
|
497
|
+
score,
|
|
498
|
+
scoreByField,
|
|
499
|
+
threshold: policy.minScore,
|
|
500
|
+
shouldPersist: true,
|
|
501
|
+
reason: policy.reason,
|
|
502
|
+
policy,
|
|
503
|
+
};
|
|
504
|
+
};
|
|
505
|
+
|
|
200
506
|
const uniqueTail = (items, limit) => mergeUniqueStrings(items || []).slice(-limit);
|
|
201
507
|
const uniqueHead = (items, limit) => mergeUniqueStrings(items || []).slice(0, limit);
|
|
202
508
|
|
|
@@ -220,34 +526,110 @@ const pruneEmptyFields = (value) =>
|
|
|
220
526
|
}),
|
|
221
527
|
);
|
|
222
528
|
|
|
223
|
-
const
|
|
224
|
-
|
|
529
|
+
const parseJsonText = (value, fallback) => {
|
|
530
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
531
|
+
return fallback;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
return JSON.parse(value);
|
|
536
|
+
} catch {
|
|
537
|
+
return fallback;
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const getSessionRow = (db, sessionId) => db.prepare(`
|
|
542
|
+
SELECT
|
|
543
|
+
session_id,
|
|
544
|
+
goal,
|
|
545
|
+
status,
|
|
546
|
+
current_focus,
|
|
547
|
+
why_blocked,
|
|
548
|
+
next_step,
|
|
549
|
+
pinned_context_json,
|
|
550
|
+
unresolved_questions_json,
|
|
551
|
+
blockers_json,
|
|
552
|
+
snapshot_json,
|
|
553
|
+
completed_count,
|
|
554
|
+
decisions_count,
|
|
555
|
+
touched_files_count,
|
|
556
|
+
created_at,
|
|
557
|
+
updated_at
|
|
558
|
+
FROM sessions
|
|
559
|
+
WHERE session_id = ?
|
|
560
|
+
`).get(sessionId);
|
|
561
|
+
|
|
562
|
+
const getActiveSessionId = (db) =>
|
|
563
|
+
db.prepare(`
|
|
564
|
+
SELECT session_id
|
|
565
|
+
FROM active_session
|
|
566
|
+
WHERE scope = ?
|
|
567
|
+
`).get(ACTIVE_SESSION_SCOPE)?.session_id ?? null;
|
|
568
|
+
|
|
569
|
+
const hydrateSession = (row) => {
|
|
570
|
+
if (!row) {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const snapshot = parseJsonText(row.snapshot_json, {});
|
|
575
|
+
const completed = mergeUniqueStrings(snapshot.completed);
|
|
576
|
+
const decisions = mergeUniqueStrings(snapshot.decisions);
|
|
577
|
+
const touchedFiles = mergeUniqueStrings(snapshot.touchedFiles);
|
|
578
|
+
const pinnedContext = mergeUniqueStrings(parseJsonText(row.pinned_context_json, []), snapshot.pinnedContext);
|
|
579
|
+
const unresolvedQuestions = mergeUniqueStrings(parseJsonText(row.unresolved_questions_json, []), snapshot.unresolvedQuestions);
|
|
580
|
+
const blockers = mergeUniqueStrings(parseJsonText(row.blockers_json, []), snapshot.blockers);
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
...snapshot,
|
|
584
|
+
schemaVersion: Number(snapshot.schemaVersion ?? 1),
|
|
585
|
+
sessionId: row.session_id,
|
|
586
|
+
goal: typeof row.goal === 'string' ? row.goal : (snapshot.goal ?? ''),
|
|
587
|
+
status: normalizeStatus(row.status, normalizeStatus(snapshot.status)),
|
|
588
|
+
currentFocus: typeof row.current_focus === 'string' ? row.current_focus : (snapshot.currentFocus ?? ''),
|
|
589
|
+
whyBlocked: typeof row.why_blocked === 'string' ? row.why_blocked : (snapshot.whyBlocked ?? ''),
|
|
590
|
+
nextStep: typeof row.next_step === 'string' ? row.next_step : (snapshot.nextStep ?? ''),
|
|
591
|
+
pinnedContext,
|
|
592
|
+
unresolvedQuestions,
|
|
593
|
+
blockers,
|
|
594
|
+
completed,
|
|
595
|
+
decisions,
|
|
596
|
+
touchedFiles,
|
|
597
|
+
completedCount: Number.isInteger(row.completed_count) ? row.completed_count : completed.length,
|
|
598
|
+
decisionsCount: Number.isInteger(row.decisions_count) ? row.decisions_count : decisions.length,
|
|
599
|
+
touchedFilesCount: Number.isInteger(row.touched_files_count) ? row.touched_files_count : touchedFiles.length,
|
|
600
|
+
createdAt: row.created_at,
|
|
601
|
+
updatedAt: row.updated_at,
|
|
602
|
+
};
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const buildSessionSummary = (session) => {
|
|
606
|
+
const status = normalizeStatus(session.status);
|
|
225
607
|
const whyBlocked = status === 'blocked'
|
|
226
|
-
? (isMeaningfulString(
|
|
608
|
+
? (isMeaningfulString(session.whyBlocked) ? session.whyBlocked : (session.blockers || []).find(isMeaningfulString))
|
|
227
609
|
: undefined;
|
|
228
|
-
const completed = mergeUniqueStrings(
|
|
229
|
-
const decisions = mergeUniqueStrings(
|
|
230
|
-
const touchedFiles = mergeUniqueStrings(
|
|
610
|
+
const completed = mergeUniqueStrings(session.completed);
|
|
611
|
+
const decisions = mergeUniqueStrings(session.decisions);
|
|
612
|
+
const touchedFiles = mergeUniqueStrings(session.touchedFiles);
|
|
231
613
|
|
|
232
614
|
return pruneEmptyFields({
|
|
233
615
|
status,
|
|
234
|
-
nextStep: isMeaningfulString(
|
|
235
|
-
pinnedContext: uniqueHead(
|
|
236
|
-
unresolvedQuestions: uniqueHead(
|
|
237
|
-
currentFocus: isMeaningfulString(
|
|
616
|
+
nextStep: isMeaningfulString(session.nextStep) ? session.nextStep : undefined,
|
|
617
|
+
pinnedContext: uniqueHead(session.pinnedContext, 3),
|
|
618
|
+
unresolvedQuestions: uniqueHead(session.unresolvedQuestions, 3),
|
|
619
|
+
currentFocus: isMeaningfulString(session.currentFocus) ? session.currentFocus : undefined,
|
|
238
620
|
whyBlocked,
|
|
239
|
-
goal: isMeaningfulString(
|
|
621
|
+
goal: isMeaningfulString(session.goal) ? session.goal : undefined,
|
|
240
622
|
recentCompleted: uniqueTail(completed, 3),
|
|
241
623
|
keyDecisions: uniqueTail(decisions, 2),
|
|
242
624
|
hotFiles: uniqueTail(touchedFiles.map(compactFilePath), 5),
|
|
243
|
-
completedCount:
|
|
244
|
-
decisionsCount:
|
|
245
|
-
touchedFilesCount:
|
|
625
|
+
completedCount: session.completedCount ?? completed.length,
|
|
626
|
+
decisionsCount: session.decisionsCount ?? decisions.length,
|
|
627
|
+
touchedFilesCount: session.touchedFilesCount ?? touchedFiles.length,
|
|
246
628
|
});
|
|
247
629
|
};
|
|
248
630
|
|
|
249
631
|
const compressSummary = (data, maxTokens) => {
|
|
250
|
-
const baseSummary =
|
|
632
|
+
const baseSummary = buildSessionSummary(data);
|
|
251
633
|
let compressed = baseSummary;
|
|
252
634
|
let summary = JSON.stringify(compressed, null, 2);
|
|
253
635
|
let tokens = countTokens(summary);
|
|
@@ -316,12 +698,12 @@ const compressSummary = (data, maxTokens) => {
|
|
|
316
698
|
};
|
|
317
699
|
|
|
318
700
|
const reductionSteps = [
|
|
319
|
-
() => shrinkArrayField('recentCompleted'),
|
|
320
|
-
() => shrinkArrayField('keyDecisions'),
|
|
321
701
|
() => shrinkArrayField('hotFiles'),
|
|
702
|
+
() => shrinkArrayField('keyDecisions'),
|
|
703
|
+
() => shrinkArrayField('recentCompleted'),
|
|
322
704
|
() => shrinkArrayField('unresolvedQuestions'),
|
|
323
|
-
() => shrinkScalarField('goal'),
|
|
324
705
|
() => shrinkScalarField('currentFocus'),
|
|
706
|
+
() => shrinkScalarField('goal'),
|
|
325
707
|
() => shrinkScalarField('whyBlocked'),
|
|
326
708
|
() => shrinkArrayField('pinnedContext'),
|
|
327
709
|
() => shrinkScalarField('nextStep', { removable: false }),
|
|
@@ -383,203 +765,727 @@ const compressSummary = (data, maxTokens) => {
|
|
|
383
765
|
return { compressed, tokens, truncated: true, omitted, compressionLevel };
|
|
384
766
|
};
|
|
385
767
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
768
|
+
const cacheSummary = (db, sessionId, { compressed, tokens, compressionLevel, omitted, updatedAt }) => {
|
|
769
|
+
db.prepare(`
|
|
770
|
+
INSERT INTO summary_cache(
|
|
771
|
+
session_id,
|
|
772
|
+
summary_json,
|
|
773
|
+
tokens,
|
|
774
|
+
compression_level,
|
|
775
|
+
omitted_json,
|
|
776
|
+
updated_at
|
|
777
|
+
)
|
|
778
|
+
VALUES(?, ?, ?, ?, ?, ?)
|
|
779
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
780
|
+
summary_json = excluded.summary_json,
|
|
781
|
+
tokens = excluded.tokens,
|
|
782
|
+
compression_level = excluded.compression_level,
|
|
783
|
+
omitted_json = excluded.omitted_json,
|
|
784
|
+
updated_at = excluded.updated_at
|
|
785
|
+
`).run(
|
|
786
|
+
sessionId,
|
|
787
|
+
JSON.stringify(compressed),
|
|
788
|
+
tokens,
|
|
789
|
+
compressionLevel,
|
|
790
|
+
JSON.stringify(omitted),
|
|
791
|
+
updatedAt,
|
|
792
|
+
);
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
const writeActiveSession = (db, sessionId, updatedAt) => {
|
|
796
|
+
db.prepare(`
|
|
797
|
+
INSERT INTO active_session(scope, session_id, updated_at)
|
|
798
|
+
VALUES(?, ?, ?)
|
|
799
|
+
ON CONFLICT(scope) DO UPDATE SET
|
|
800
|
+
session_id = excluded.session_id,
|
|
801
|
+
updated_at = excluded.updated_at
|
|
802
|
+
`).run(ACTIVE_SESSION_SCOPE, sessionId, updatedAt);
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const saveSession = (db, sessionId, data, { action, eventPayload } = {}) => {
|
|
806
|
+
const existing = hydrateSession(getSessionRow(db, sessionId));
|
|
807
|
+
const createdAt = existing?.createdAt ?? new Date().toISOString();
|
|
808
|
+
const updatedAt = new Date().toISOString();
|
|
809
|
+
const completed = mergeUniqueStrings(data.completed);
|
|
810
|
+
const decisions = mergeUniqueStrings(data.decisions);
|
|
811
|
+
const touchedFiles = mergeUniqueStrings(data.touchedFiles);
|
|
812
|
+
const pinnedContext = mergeUniqueStrings(data.pinnedContext);
|
|
813
|
+
const unresolvedQuestions = mergeUniqueStrings(data.unresolvedQuestions);
|
|
814
|
+
const blockers = mergeUniqueStrings(data.blockers);
|
|
815
|
+
|
|
816
|
+
const snapshot = {
|
|
817
|
+
goal: typeof data.goal === 'string' ? data.goal : '',
|
|
818
|
+
status: normalizeStatus(data.status),
|
|
819
|
+
pinnedContext,
|
|
820
|
+
unresolvedQuestions,
|
|
821
|
+
currentFocus: data.currentFocus ?? '',
|
|
822
|
+
whyBlocked: data.whyBlocked ?? '',
|
|
823
|
+
completed,
|
|
824
|
+
decisions,
|
|
825
|
+
blockers,
|
|
826
|
+
nextStep: data.nextStep ?? '',
|
|
827
|
+
touchedFiles,
|
|
828
|
+
completedCount: completed.length,
|
|
829
|
+
decisionsCount: decisions.length,
|
|
830
|
+
touchedFilesCount: touchedFiles.length,
|
|
831
|
+
schemaVersion: SQLITE_SCHEMA_VERSION,
|
|
832
|
+
sessionId,
|
|
833
|
+
createdAt,
|
|
834
|
+
updatedAt,
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
db.prepare(`
|
|
838
|
+
INSERT INTO sessions(
|
|
839
|
+
session_id,
|
|
840
|
+
goal,
|
|
841
|
+
status,
|
|
842
|
+
current_focus,
|
|
843
|
+
why_blocked,
|
|
844
|
+
next_step,
|
|
845
|
+
pinned_context_json,
|
|
846
|
+
unresolved_questions_json,
|
|
847
|
+
blockers_json,
|
|
848
|
+
snapshot_json,
|
|
849
|
+
completed_count,
|
|
850
|
+
decisions_count,
|
|
851
|
+
touched_files_count,
|
|
852
|
+
created_at,
|
|
853
|
+
updated_at
|
|
854
|
+
)
|
|
855
|
+
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
856
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
857
|
+
goal = excluded.goal,
|
|
858
|
+
status = excluded.status,
|
|
859
|
+
current_focus = excluded.current_focus,
|
|
860
|
+
why_blocked = excluded.why_blocked,
|
|
861
|
+
next_step = excluded.next_step,
|
|
862
|
+
pinned_context_json = excluded.pinned_context_json,
|
|
863
|
+
unresolved_questions_json = excluded.unresolved_questions_json,
|
|
864
|
+
blockers_json = excluded.blockers_json,
|
|
865
|
+
snapshot_json = excluded.snapshot_json,
|
|
866
|
+
completed_count = excluded.completed_count,
|
|
867
|
+
decisions_count = excluded.decisions_count,
|
|
868
|
+
touched_files_count = excluded.touched_files_count,
|
|
869
|
+
updated_at = excluded.updated_at,
|
|
870
|
+
created_at = sessions.created_at
|
|
871
|
+
`).run(
|
|
872
|
+
sessionId,
|
|
873
|
+
snapshot.goal,
|
|
874
|
+
snapshot.status,
|
|
875
|
+
snapshot.currentFocus,
|
|
876
|
+
snapshot.whyBlocked,
|
|
877
|
+
snapshot.nextStep,
|
|
878
|
+
JSON.stringify(pinnedContext),
|
|
879
|
+
JSON.stringify(unresolvedQuestions),
|
|
880
|
+
JSON.stringify(blockers),
|
|
881
|
+
JSON.stringify(snapshot),
|
|
882
|
+
snapshot.completedCount,
|
|
883
|
+
snapshot.decisionsCount,
|
|
884
|
+
snapshot.touchedFilesCount,
|
|
885
|
+
snapshot.createdAt,
|
|
886
|
+
snapshot.updatedAt,
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
writeActiveSession(db, sessionId, updatedAt);
|
|
890
|
+
|
|
891
|
+
if (action) {
|
|
892
|
+
db.prepare(`
|
|
893
|
+
INSERT INTO session_events(
|
|
894
|
+
session_id,
|
|
895
|
+
event_type,
|
|
896
|
+
payload_json,
|
|
897
|
+
token_cost,
|
|
898
|
+
created_at
|
|
899
|
+
)
|
|
900
|
+
VALUES(?, ?, ?, ?, ?)
|
|
901
|
+
`).run(
|
|
902
|
+
sessionId,
|
|
903
|
+
action,
|
|
904
|
+
JSON.stringify(pruneEmptyFields(eventPayload ?? {})),
|
|
905
|
+
0,
|
|
906
|
+
updatedAt,
|
|
907
|
+
);
|
|
402
908
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
909
|
+
|
|
910
|
+
return snapshot;
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const cleanupStaleSessions = (db) => {
|
|
914
|
+
const activeSessionId = getActiveSessionId(db);
|
|
915
|
+
const rows = db.prepare(`
|
|
916
|
+
SELECT session_id, updated_at
|
|
917
|
+
FROM sessions
|
|
918
|
+
`).all();
|
|
919
|
+
|
|
920
|
+
const now = Date.now();
|
|
921
|
+
let cleaned = 0;
|
|
922
|
+
|
|
923
|
+
for (const row of rows) {
|
|
924
|
+
if (row.session_id === activeSessionId) {
|
|
925
|
+
continue;
|
|
414
926
|
}
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
return {
|
|
420
|
-
action: 'get',
|
|
421
|
-
sessionId: targetSessionId,
|
|
422
|
-
found: false,
|
|
423
|
-
message: 'Session not found.',
|
|
424
|
-
};
|
|
927
|
+
|
|
928
|
+
const ageMs = now - getTimestamp(row.updated_at, now);
|
|
929
|
+
if (ageMs <= MAX_SESSION_AGE_MS) {
|
|
930
|
+
continue;
|
|
425
931
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
932
|
+
|
|
933
|
+
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(row.session_id);
|
|
934
|
+
cleaned += 1;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return cleaned;
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
const listSessions = (db, { cleanup = true } = {}) => {
|
|
941
|
+
if (cleanup) {
|
|
942
|
+
cleanupStaleSessions(db);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const now = Date.now();
|
|
946
|
+
|
|
947
|
+
return db.prepare(`
|
|
948
|
+
SELECT session_id, goal, status, updated_at
|
|
949
|
+
FROM sessions
|
|
950
|
+
ORDER BY datetime(updated_at) DESC, session_id ASC
|
|
951
|
+
`).all().map((row) => {
|
|
952
|
+
const ageMs = now - getTimestamp(row.updated_at, now);
|
|
953
|
+
return {
|
|
954
|
+
sessionId: row.session_id,
|
|
955
|
+
goal: row.goal,
|
|
956
|
+
status: row.status,
|
|
957
|
+
updatedAt: row.updated_at,
|
|
958
|
+
ageMs,
|
|
959
|
+
isStale: ageMs > MAX_SESSION_AGE_MS,
|
|
960
|
+
};
|
|
961
|
+
});
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
const buildResumeCandidates = (sessions) =>
|
|
965
|
+
sessions.slice(0, MAX_RESUME_CANDIDATES).map((session) => ({
|
|
966
|
+
sessionId: session.sessionId,
|
|
967
|
+
goal: session.goal,
|
|
968
|
+
status: session.status,
|
|
969
|
+
updatedAt: session.updatedAt,
|
|
970
|
+
ageMs: session.ageMs,
|
|
971
|
+
isStale: session.isStale,
|
|
972
|
+
}));
|
|
973
|
+
|
|
974
|
+
const addRepoSafety = (result, repoSafety = getRepoSafety()) => ({
|
|
975
|
+
...result,
|
|
976
|
+
repoSafety,
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
const getMutationSafetyPolicy = () => {
|
|
980
|
+
const repoSafety = enforceRepoSafety();
|
|
981
|
+
const reasons = HARD_BLOCK_REPO_SAFETY_REASONS
|
|
982
|
+
.filter(([, field]) => repoSafety[field])
|
|
983
|
+
.map(([reason]) => reason);
|
|
984
|
+
|
|
985
|
+
return {
|
|
986
|
+
repoSafety,
|
|
987
|
+
shouldBlock: reasons.length > 0,
|
|
988
|
+
reasons,
|
|
989
|
+
};
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
const buildMutationBlockedMessage = (reasons, stateDbPath = '.devctx/state.sqlite') => {
|
|
993
|
+
if (reasons.includes('tracked') && reasons.includes('staged')) {
|
|
994
|
+
return `Refused to mutate project-local context because ${stateDbPath} is tracked and staged by git. Fix git hygiene first.`;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (reasons.includes('tracked')) {
|
|
998
|
+
return `Refused to mutate project-local context because ${stateDbPath} is tracked by git. Untrack it before continuing.`;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (reasons.includes('staged')) {
|
|
1002
|
+
return `Refused to mutate project-local context because ${stateDbPath} is staged for commit. Unstage it before continuing.`;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
return `Refused to mutate project-local context because ${stateDbPath} failed runtime safety checks.`;
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
const buildMutationBlockedResponse = ({ action, sessionId, repoSafety, reasons }) =>
|
|
1009
|
+
addRepoSafety({
|
|
1010
|
+
action,
|
|
1011
|
+
sessionId: sessionId ?? null,
|
|
1012
|
+
blocked: true,
|
|
1013
|
+
mutationBlocked: true,
|
|
1014
|
+
blockedBy: reasons,
|
|
1015
|
+
message: buildMutationBlockedMessage(reasons, repoSafety.stateDbPath ?? '.devctx/state.sqlite'),
|
|
1016
|
+
}, repoSafety);
|
|
1017
|
+
|
|
1018
|
+
const resolveAutoResumeTarget = (db, { forceRecommended = false, cleanup = true } = {}) => {
|
|
1019
|
+
const sessions = listSessions(db, { cleanup });
|
|
1020
|
+
const candidates = buildResumeCandidates(sessions);
|
|
1021
|
+
|
|
1022
|
+
if (sessions.length === 0) {
|
|
1023
|
+
return {
|
|
1024
|
+
found: false,
|
|
1025
|
+
ambiguous: false,
|
|
1026
|
+
candidates,
|
|
1027
|
+
recommendedSessionId: null,
|
|
1028
|
+
message: 'No saved sessions found. Use action=update to create one.',
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (sessions.length === 1) {
|
|
440
1033
|
return {
|
|
441
|
-
action: 'get',
|
|
442
|
-
sessionId: targetSessionId,
|
|
443
1034
|
found: true,
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
1035
|
+
sessionId: sessions[0].sessionId,
|
|
1036
|
+
autoResumed: true,
|
|
1037
|
+
resumeSource: 'latest_only',
|
|
1038
|
+
candidates,
|
|
1039
|
+
recommendedSessionId: sessions[0].sessionId,
|
|
1040
|
+
ambiguous: false,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const openSessions = sessions.filter((session) => ACTIVE_STATUSES.has(normalizeStatus(session.status)));
|
|
1045
|
+
if (openSessions.length === 1) {
|
|
1046
|
+
return {
|
|
1047
|
+
found: true,
|
|
1048
|
+
sessionId: openSessions[0].sessionId,
|
|
1049
|
+
autoResumed: true,
|
|
1050
|
+
resumeSource: 'only_open_session',
|
|
1051
|
+
candidates,
|
|
1052
|
+
recommendedSessionId: openSessions[0].sessionId,
|
|
1053
|
+
ambiguous: false,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const [latest, secondLatest] = sessions;
|
|
1058
|
+
const recencyGapMs = getTimestamp(latest?.updatedAt, 0) - getTimestamp(secondLatest?.updatedAt, 0);
|
|
1059
|
+
|
|
1060
|
+
if (Number.isFinite(recencyGapMs) && recencyGapMs >= AUTO_RESUME_RECENCY_GAP_MS) {
|
|
1061
|
+
return {
|
|
1062
|
+
found: true,
|
|
1063
|
+
sessionId: latest.sessionId,
|
|
1064
|
+
autoResumed: true,
|
|
1065
|
+
resumeSource: 'latest_by_recency',
|
|
1066
|
+
candidates,
|
|
1067
|
+
recommendedSessionId: latest.sessionId,
|
|
1068
|
+
ambiguous: false,
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const recommended = openSessions[0] || latest;
|
|
1073
|
+
if (forceRecommended && recommended) {
|
|
1074
|
+
return {
|
|
1075
|
+
found: true,
|
|
1076
|
+
sessionId: recommended.sessionId,
|
|
1077
|
+
autoResumed: true,
|
|
1078
|
+
resumeSource: openSessions[0] ? 'recommended_open_session' : 'recommended_latest',
|
|
1079
|
+
candidates,
|
|
1080
|
+
recommendedSessionId: recommended.sessionId,
|
|
1081
|
+
ambiguous: true,
|
|
451
1082
|
};
|
|
452
1083
|
}
|
|
453
|
-
|
|
1084
|
+
|
|
1085
|
+
return {
|
|
1086
|
+
found: false,
|
|
1087
|
+
ambiguous: true,
|
|
1088
|
+
candidates,
|
|
1089
|
+
recommendedSessionId: recommended?.sessionId ?? null,
|
|
1090
|
+
message: 'Multiple recent sessions found. Specify sessionId or use sessionId="auto" to accept the recommendation.',
|
|
1091
|
+
};
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
export const smartSummary = async ({
|
|
1095
|
+
action,
|
|
1096
|
+
sessionId,
|
|
1097
|
+
update,
|
|
1098
|
+
maxTokens = DEFAULT_MAX_TOKENS,
|
|
1099
|
+
event,
|
|
1100
|
+
force,
|
|
1101
|
+
retentionDays,
|
|
1102
|
+
keepLatestEventsPerSession,
|
|
1103
|
+
keepLatestMetrics,
|
|
1104
|
+
vacuum,
|
|
1105
|
+
apply,
|
|
1106
|
+
} = {}) => {
|
|
1107
|
+
const startTime = Date.now();
|
|
1108
|
+
const mutationSafety = getMutationSafetyPolicy();
|
|
1109
|
+
const shouldBlockWrites = SUMMARY_WRITE_ACTIONS.has(action) && mutationSafety.shouldBlock;
|
|
1110
|
+
const allowReadSideEffects = !mutationSafety.shouldBlock;
|
|
1111
|
+
const shouldImportLegacy = allowReadSideEffects;
|
|
1112
|
+
|
|
1113
|
+
if (shouldImportLegacy) {
|
|
1114
|
+
await importLegacyState();
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (action === 'list_sessions') {
|
|
1118
|
+
const reader = allowReadSideEffects ? withStateDb : withStateDbSnapshot;
|
|
1119
|
+
return reader((db) => {
|
|
1120
|
+
const sessions = listSessions(db, { cleanup: allowReadSideEffects });
|
|
1121
|
+
const activeSessionId = getActiveSessionId(db);
|
|
1122
|
+
|
|
1123
|
+
return addRepoSafety({
|
|
1124
|
+
action: 'list_sessions',
|
|
1125
|
+
sessions,
|
|
1126
|
+
activeSessionId,
|
|
1127
|
+
totalSessions: sessions.length,
|
|
1128
|
+
staleSessions: sessions.filter((session) => session.isStale).length,
|
|
1129
|
+
});
|
|
1130
|
+
}, allowReadSideEffects ? undefined : {});
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
if (action === 'get') {
|
|
1134
|
+
const reader = allowReadSideEffects ? withStateDb : withStateDbSnapshot;
|
|
1135
|
+
return reader(async (db) => {
|
|
1136
|
+
const suppressReadSideEffects = !allowReadSideEffects;
|
|
1137
|
+
const activeSessionId = getActiveSessionId(db);
|
|
1138
|
+
const wantsAutoResume = sessionId === undefined || sessionId === AUTO_RESUME_SESSION_ID;
|
|
1139
|
+
let targetSessionId = sessionId && sessionId !== AUTO_RESUME_SESSION_ID
|
|
1140
|
+
? sessionId
|
|
1141
|
+
: activeSessionId;
|
|
1142
|
+
let resumeMeta = activeSessionId
|
|
1143
|
+
? {
|
|
1144
|
+
autoResumed: false,
|
|
1145
|
+
resumeSource: 'active',
|
|
1146
|
+
recommendedSessionId: activeSessionId,
|
|
1147
|
+
}
|
|
1148
|
+
: null;
|
|
1149
|
+
|
|
1150
|
+
if (!targetSessionId && wantsAutoResume) {
|
|
1151
|
+
const resolution = resolveAutoResumeTarget(db, {
|
|
1152
|
+
forceRecommended: sessionId === AUTO_RESUME_SESSION_ID,
|
|
1153
|
+
cleanup: allowReadSideEffects,
|
|
1154
|
+
});
|
|
1155
|
+
if (!resolution.found) {
|
|
1156
|
+
return addRepoSafety({
|
|
1157
|
+
action: 'get',
|
|
1158
|
+
sessionId: null,
|
|
1159
|
+
found: false,
|
|
1160
|
+
autoResumed: false,
|
|
1161
|
+
ambiguous: resolution.ambiguous,
|
|
1162
|
+
candidates: resolution.candidates,
|
|
1163
|
+
recommendedSessionId: resolution.recommendedSessionId,
|
|
1164
|
+
message: resolution.message,
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
targetSessionId = resolution.sessionId;
|
|
1169
|
+
resumeMeta = resolution;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (!targetSessionId) {
|
|
1173
|
+
return addRepoSafety({
|
|
1174
|
+
action: 'get',
|
|
1175
|
+
sessionId: null,
|
|
1176
|
+
found: false,
|
|
1177
|
+
message: 'No active session found. Use action=update to create one.',
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const session = hydrateSession(getSessionRow(db, targetSessionId));
|
|
1182
|
+
if (!session) {
|
|
1183
|
+
return addRepoSafety({
|
|
1184
|
+
action: 'get',
|
|
1185
|
+
sessionId: targetSessionId,
|
|
1186
|
+
found: false,
|
|
1187
|
+
message: 'Session not found.',
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (resumeMeta?.autoResumed && allowReadSideEffects) {
|
|
1192
|
+
writeActiveSession(db, targetSessionId, session.updatedAt);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const { compressed, tokens, truncated, omitted, compressionLevel } = compressSummary(session, maxTokens);
|
|
1196
|
+
const rawTokens = countTokens(JSON.stringify(session));
|
|
1197
|
+
const summaryMetrics = buildSummaryMetrics(rawTokens, tokens);
|
|
1198
|
+
|
|
1199
|
+
if (allowReadSideEffects) {
|
|
1200
|
+
cacheSummary(db, targetSessionId, {
|
|
1201
|
+
compressed,
|
|
1202
|
+
tokens,
|
|
1203
|
+
compressionLevel,
|
|
1204
|
+
omitted,
|
|
1205
|
+
updatedAt: session.updatedAt,
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
persistMetrics({
|
|
1209
|
+
tool: 'smart_summary',
|
|
1210
|
+
action: 'get',
|
|
1211
|
+
sessionId: targetSessionId,
|
|
1212
|
+
...summaryMetrics,
|
|
1213
|
+
latencyMs: Date.now() - startTime,
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
return addRepoSafety({
|
|
1218
|
+
action: 'get',
|
|
1219
|
+
sessionId: targetSessionId,
|
|
1220
|
+
found: true,
|
|
1221
|
+
summary: compressed,
|
|
1222
|
+
tokens,
|
|
1223
|
+
truncated,
|
|
1224
|
+
omitted,
|
|
1225
|
+
compressionLevel,
|
|
1226
|
+
autoResumed: resumeMeta?.autoResumed ?? false,
|
|
1227
|
+
resumeSource: resumeMeta?.resumeSource ?? 'direct',
|
|
1228
|
+
ambiguous: resumeMeta?.ambiguous ?? false,
|
|
1229
|
+
recommendedSessionId: resumeMeta?.recommendedSessionId ?? targetSessionId,
|
|
1230
|
+
...(resumeMeta?.candidates ? { candidates: resumeMeta.candidates } : {}),
|
|
1231
|
+
...(suppressReadSideEffects ? { sideEffectsSuppressed: true } : {}),
|
|
1232
|
+
schemaVersion: session.schemaVersion ?? 1,
|
|
1233
|
+
updatedAt: session.updatedAt,
|
|
1234
|
+
});
|
|
1235
|
+
}, allowReadSideEffects ? undefined : {});
|
|
1236
|
+
}
|
|
1237
|
+
|
|
454
1238
|
if (action === 'reset') {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
};
|
|
1239
|
+
if (shouldBlockWrites) {
|
|
1240
|
+
return buildMutationBlockedResponse({
|
|
1241
|
+
action,
|
|
1242
|
+
sessionId,
|
|
1243
|
+
repoSafety: mutationSafety.repoSafety,
|
|
1244
|
+
reasons: mutationSafety.reasons,
|
|
1245
|
+
});
|
|
463
1246
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
1247
|
+
|
|
1248
|
+
return withStateDb((db) => {
|
|
1249
|
+
const targetSessionId = sessionId || getActiveSessionId(db);
|
|
1250
|
+
if (!targetSessionId) {
|
|
1251
|
+
return addRepoSafety({
|
|
1252
|
+
action: 'reset',
|
|
1253
|
+
sessionId: null,
|
|
1254
|
+
message: 'No session to reset.',
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const isActiveSession = getActiveSessionId(db) === targetSessionId;
|
|
1259
|
+
|
|
1260
|
+
db.exec('BEGIN');
|
|
1261
|
+
try {
|
|
1262
|
+
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(targetSessionId);
|
|
1263
|
+
if (isActiveSession) {
|
|
1264
|
+
db.prepare('DELETE FROM active_session WHERE scope = ?').run(ACTIVE_SESSION_SCOPE);
|
|
1265
|
+
}
|
|
1266
|
+
db.prepare('DELETE FROM summary_cache WHERE session_id = ?').run(targetSessionId);
|
|
1267
|
+
db.exec('COMMIT');
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
db.exec('ROLLBACK');
|
|
1270
|
+
throw error;
|
|
477
1271
|
}
|
|
1272
|
+
|
|
1273
|
+
return addRepoSafety({
|
|
1274
|
+
action: 'reset',
|
|
1275
|
+
sessionId: targetSessionId,
|
|
1276
|
+
message: 'Session cleared.',
|
|
1277
|
+
});
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (action === 'compact') {
|
|
1282
|
+
if (shouldBlockWrites) {
|
|
1283
|
+
return buildMutationBlockedResponse({
|
|
1284
|
+
action,
|
|
1285
|
+
sessionId,
|
|
1286
|
+
repoSafety: mutationSafety.repoSafety,
|
|
1287
|
+
reasons: mutationSafety.reasons,
|
|
1288
|
+
});
|
|
478
1289
|
}
|
|
479
|
-
|
|
480
|
-
return {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
1290
|
+
|
|
1291
|
+
return compactState({
|
|
1292
|
+
retentionDays,
|
|
1293
|
+
keepLatestEventsPerSession,
|
|
1294
|
+
keepLatestMetrics,
|
|
1295
|
+
vacuum,
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
if (action === 'cleanup_legacy') {
|
|
1300
|
+
return cleanupLegacyState({ apply });
|
|
485
1301
|
}
|
|
486
|
-
|
|
487
|
-
if (action === 'update' || action === 'append') {
|
|
1302
|
+
|
|
1303
|
+
if (action === 'update' || action === 'append' || action === 'auto_append' || action === 'checkpoint') {
|
|
488
1304
|
validateUpdateInput(update);
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1305
|
+
|
|
1306
|
+
if (shouldBlockWrites) {
|
|
1307
|
+
return buildMutationBlockedResponse({
|
|
1308
|
+
action,
|
|
1309
|
+
sessionId,
|
|
1310
|
+
repoSafety: mutationSafety.repoSafety,
|
|
1311
|
+
reasons: mutationSafety.reasons,
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
return withStateDb(async (db) => {
|
|
1316
|
+
let targetSessionId = sessionId;
|
|
1317
|
+
let existingData = {};
|
|
1318
|
+
|
|
1319
|
+
if (!targetSessionId || targetSessionId === 'new') {
|
|
1320
|
+
if (action === 'append' || action === 'auto_append' || action === 'checkpoint') {
|
|
1321
|
+
const activeSessionId = getActiveSessionId(db);
|
|
1322
|
+
if (activeSessionId) {
|
|
1323
|
+
targetSessionId = activeSessionId;
|
|
1324
|
+
existingData = hydrateSession(getSessionRow(db, activeSessionId)) ?? {};
|
|
1325
|
+
} else {
|
|
1326
|
+
if ((action === 'auto_append' || action === 'checkpoint') && !hasMeaningfulAutoAppendInput(update)) {
|
|
1327
|
+
return addRepoSafety({
|
|
1328
|
+
action,
|
|
1329
|
+
sessionId: null,
|
|
1330
|
+
skipped: true,
|
|
1331
|
+
changedFields: [],
|
|
1332
|
+
message: 'Skipped auto-append because the update had no meaningful content.',
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
targetSessionId = generateSessionId(update.goal);
|
|
1336
|
+
}
|
|
499
1337
|
} else {
|
|
500
1338
|
targetSessionId = generateSessionId(update.goal);
|
|
501
1339
|
}
|
|
502
1340
|
} else {
|
|
503
|
-
|
|
1341
|
+
existingData = hydrateSession(getSessionRow(db, targetSessionId)) ?? {};
|
|
504
1342
|
}
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
1343
|
+
|
|
1344
|
+
const mergedData = action === 'append' || action === 'auto_append' || action === 'checkpoint'
|
|
1345
|
+
? buildAppendData(existingData, update)
|
|
1346
|
+
: buildReplaceData(update);
|
|
1347
|
+
const checkpointDecision = action === 'checkpoint'
|
|
1348
|
+
? resolveCheckpointDecision({ event, force, existingData, mergedData })
|
|
1349
|
+
: null;
|
|
1350
|
+
const changedFields = action === 'auto_append'
|
|
1351
|
+
? getAutoAppendChanges(existingData, mergedData)
|
|
1352
|
+
: checkpointDecision?.changedFields ?? [];
|
|
1353
|
+
|
|
1354
|
+
if (action === 'auto_append' && changedFields.length === 0) {
|
|
1355
|
+
const currentSession = hydrateSession(getSessionRow(db, targetSessionId)) ?? mergedData;
|
|
1356
|
+
const { compressed, tokens, truncated, omitted, compressionLevel } = compressSummary(currentSession, maxTokens);
|
|
1357
|
+
const rawTokens = countTokens(JSON.stringify(currentSession));
|
|
1358
|
+
|
|
1359
|
+
persistMetrics({
|
|
1360
|
+
tool: 'smart_summary',
|
|
1361
|
+
action,
|
|
1362
|
+
sessionId: targetSessionId,
|
|
1363
|
+
...buildSummaryMetrics(rawTokens, tokens),
|
|
1364
|
+
latencyMs: Date.now() - startTime,
|
|
1365
|
+
skipped: true,
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
return addRepoSafety({
|
|
1369
|
+
action,
|
|
1370
|
+
sessionId: targetSessionId,
|
|
1371
|
+
skipped: true,
|
|
1372
|
+
changedFields,
|
|
1373
|
+
summary: compressed,
|
|
1374
|
+
tokens,
|
|
1375
|
+
truncated,
|
|
1376
|
+
omitted,
|
|
1377
|
+
compressionLevel,
|
|
1378
|
+
schemaVersion: currentSession.schemaVersion ?? SQLITE_SCHEMA_VERSION,
|
|
1379
|
+
updatedAt: currentSession.updatedAt,
|
|
1380
|
+
message: 'Skipped auto-append because no meaningful context changed.',
|
|
1381
|
+
});
|
|
509
1382
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
1383
|
+
|
|
1384
|
+
if (action === 'checkpoint' && !checkpointDecision.shouldPersist) {
|
|
1385
|
+
const currentSession = hydrateSession(getSessionRow(db, targetSessionId)) ?? mergedData;
|
|
1386
|
+
const { compressed, tokens, truncated, omitted, compressionLevel } = compressSummary(currentSession, maxTokens);
|
|
1387
|
+
const rawTokens = countTokens(JSON.stringify(currentSession));
|
|
1388
|
+
|
|
1389
|
+
persistMetrics({
|
|
1390
|
+
tool: 'smart_summary',
|
|
1391
|
+
action,
|
|
1392
|
+
sessionId: targetSessionId,
|
|
1393
|
+
...buildSummaryMetrics(rawTokens, tokens),
|
|
1394
|
+
latencyMs: Date.now() - startTime,
|
|
1395
|
+
skipped: true,
|
|
1396
|
+
checkpointEvent: checkpointDecision.event,
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
return addRepoSafety({
|
|
1400
|
+
action,
|
|
1401
|
+
sessionId: targetSessionId,
|
|
1402
|
+
skipped: true,
|
|
1403
|
+
changedFields,
|
|
1404
|
+
checkpoint: {
|
|
1405
|
+
event: checkpointDecision.event,
|
|
1406
|
+
shouldPersist: false,
|
|
1407
|
+
reason: checkpointDecision.reason,
|
|
1408
|
+
score: checkpointDecision.score,
|
|
1409
|
+
threshold: checkpointDecision.threshold,
|
|
1410
|
+
scoreByField: checkpointDecision.scoreByField,
|
|
1411
|
+
},
|
|
1412
|
+
summary: compressed,
|
|
1413
|
+
tokens,
|
|
1414
|
+
truncated,
|
|
1415
|
+
omitted,
|
|
1416
|
+
compressionLevel,
|
|
1417
|
+
schemaVersion: currentSession.schemaVersion ?? SQLITE_SCHEMA_VERSION,
|
|
1418
|
+
updatedAt: currentSession.updatedAt,
|
|
1419
|
+
message: checkpointDecision.reason,
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const savedData = saveSession(db, targetSessionId, mergedData, {
|
|
1424
|
+
action,
|
|
1425
|
+
eventPayload: action === 'auto_append' || action === 'checkpoint'
|
|
1426
|
+
? {
|
|
1427
|
+
...update,
|
|
1428
|
+
changedFields,
|
|
1429
|
+
...(action === 'checkpoint'
|
|
1430
|
+
? {
|
|
1431
|
+
checkpointEvent: checkpointDecision.event,
|
|
1432
|
+
checkpointReason: checkpointDecision.reason,
|
|
1433
|
+
}
|
|
1434
|
+
: {}),
|
|
1435
|
+
}
|
|
1436
|
+
: update,
|
|
1437
|
+
});
|
|
1438
|
+
const { compressed, tokens, truncated, omitted, compressionLevel } = compressSummary(savedData, maxTokens);
|
|
1439
|
+
const rawTokens = countTokens(JSON.stringify(savedData));
|
|
1440
|
+
const summaryMetrics = buildSummaryMetrics(rawTokens, tokens);
|
|
1441
|
+
|
|
1442
|
+
cacheSummary(db, targetSessionId, {
|
|
1443
|
+
compressed,
|
|
1444
|
+
tokens,
|
|
1445
|
+
compressionLevel,
|
|
1446
|
+
omitted,
|
|
1447
|
+
updatedAt: savedData.updatedAt,
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
persistMetrics({
|
|
1451
|
+
tool: 'smart_summary',
|
|
1452
|
+
action,
|
|
1453
|
+
sessionId: targetSessionId,
|
|
1454
|
+
...summaryMetrics,
|
|
1455
|
+
latencyMs: Date.now() - startTime,
|
|
1456
|
+
...(action === 'checkpoint' ? { checkpointEvent: checkpointDecision.event } : {}),
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
return addRepoSafety({
|
|
1460
|
+
action,
|
|
1461
|
+
sessionId: targetSessionId,
|
|
1462
|
+
skipped: false,
|
|
1463
|
+
...(action === 'auto_append' || action === 'checkpoint' ? { changedFields } : {}),
|
|
1464
|
+
...(action === 'checkpoint'
|
|
1465
|
+
? {
|
|
1466
|
+
checkpoint: {
|
|
1467
|
+
event: checkpointDecision.event,
|
|
1468
|
+
shouldPersist: true,
|
|
1469
|
+
reason: checkpointDecision.reason,
|
|
1470
|
+
score: checkpointDecision.score,
|
|
1471
|
+
threshold: checkpointDecision.threshold,
|
|
1472
|
+
scoreByField: checkpointDecision.scoreByField,
|
|
1473
|
+
},
|
|
1474
|
+
}
|
|
1475
|
+
: {}),
|
|
1476
|
+
summary: compressed,
|
|
1477
|
+
tokens,
|
|
1478
|
+
truncated,
|
|
1479
|
+
omitted,
|
|
1480
|
+
compressionLevel,
|
|
1481
|
+
schemaVersion: savedData.schemaVersion,
|
|
1482
|
+
updatedAt: savedData.updatedAt,
|
|
1483
|
+
message: action === 'append' || action === 'auto_append' || action === 'checkpoint'
|
|
1484
|
+
? 'Session updated incrementally.'
|
|
1485
|
+
: 'Session saved.',
|
|
1486
|
+
});
|
|
568
1487
|
});
|
|
569
|
-
|
|
570
|
-
return {
|
|
571
|
-
action,
|
|
572
|
-
sessionId: targetSessionId,
|
|
573
|
-
summary: compressed,
|
|
574
|
-
tokens,
|
|
575
|
-
truncated,
|
|
576
|
-
omitted,
|
|
577
|
-
compressionLevel,
|
|
578
|
-
schemaVersion: savedData.schemaVersion,
|
|
579
|
-
updatedAt: savedData.updatedAt,
|
|
580
|
-
message: action === 'append' ? 'Session updated incrementally.' : 'Session saved.',
|
|
581
|
-
};
|
|
582
1488
|
}
|
|
583
|
-
|
|
584
|
-
throw new Error(`Invalid action: ${action}. Valid actions: get, update, append, reset, list_sessions`);
|
|
1489
|
+
|
|
1490
|
+
throw new Error(`Invalid action: ${action}. Valid actions: get, update, append, auto_append, checkpoint, reset, list_sessions, compact, cleanup_legacy`);
|
|
585
1491
|
};
|