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.
@@ -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 SESSION_SCHEMA_VERSION = 2;
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 getSessionsDir = () => path.join(projectRoot, '.devctx', 'sessions');
14
- const getActiveSessionFile = () => path.join(getSessionsDir(), 'active.json');
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 ensureSessionsDir = () => {
17
- const sessionsDir = getSessionsDir();
18
- if (!fs.existsSync(sessionsDir)) {
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 buildResumeSummary = (data) => {
224
- const status = normalizeStatus(data.status);
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(data.whyBlocked) ? data.whyBlocked : (data.blockers || []).find(isMeaningfulString))
608
+ ? (isMeaningfulString(session.whyBlocked) ? session.whyBlocked : (session.blockers || []).find(isMeaningfulString))
227
609
  : undefined;
228
- const completed = mergeUniqueStrings(data.completed);
229
- const decisions = mergeUniqueStrings(data.decisions);
230
- const touchedFiles = mergeUniqueStrings(data.touchedFiles);
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(data.nextStep) ? data.nextStep : undefined,
235
- pinnedContext: uniqueHead(data.pinnedContext, 3),
236
- unresolvedQuestions: uniqueHead(data.unresolvedQuestions, 3),
237
- currentFocus: isMeaningfulString(data.currentFocus) ? data.currentFocus : undefined,
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(data.goal) ? data.goal : undefined,
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: data.completedCount ?? completed.length,
244
- decisionsCount: data.decisionsCount ?? decisions.length,
245
- touchedFilesCount: data.touchedFilesCount ?? touchedFiles.length,
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 = buildResumeSummary(data);
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
- export const smartSummary = async ({ action, sessionId, update, maxTokens = DEFAULT_MAX_TOKENS }) => {
387
- const startTime = Date.now();
388
-
389
- ensureSessionsDir();
390
-
391
- if (action === 'list_sessions') {
392
- const sessions = listSessions();
393
- const activeSession = getActiveSession();
394
-
395
- return {
396
- action: 'list_sessions',
397
- sessions,
398
- activeSessionId: activeSession?.sessionId || null,
399
- totalSessions: sessions.length,
400
- staleSessions: sessions.filter(s => s.isStale).length,
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
- if (action === 'get') {
405
- const targetSessionId = sessionId || getActiveSession()?.sessionId;
406
-
407
- if (!targetSessionId) {
408
- return {
409
- action: 'get',
410
- sessionId: null,
411
- found: false,
412
- message: 'No active session found. Use action=update to create one.',
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 session = loadSession(targetSessionId);
417
-
418
- if (!session) {
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
- const { compressed, tokens, truncated, omitted, compressionLevel } = compressSummary(session, maxTokens);
428
-
429
- const rawTokens = countTokens(JSON.stringify(session));
430
- const summaryMetrics = buildSummaryMetrics(rawTokens, tokens);
431
-
432
- persistMetrics({
433
- tool: 'smart_summary',
434
- action: 'get',
435
- sessionId: targetSessionId,
436
- ...summaryMetrics,
437
- latencyMs: Date.now() - startTime,
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
- summary: compressed,
445
- tokens,
446
- truncated,
447
- omitted,
448
- compressionLevel,
449
- schemaVersion: session.schemaVersion ?? 1,
450
- updatedAt: session.updatedAt,
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
- const targetSessionId = sessionId || getActiveSession()?.sessionId;
456
-
457
- if (!targetSessionId) {
458
- return {
459
- action: 'reset',
460
- sessionId: null,
461
- message: 'No session to reset.',
462
- };
1239
+ if (shouldBlockWrites) {
1240
+ return buildMutationBlockedResponse({
1241
+ action,
1242
+ sessionId,
1243
+ repoSafety: mutationSafety.repoSafety,
1244
+ reasons: mutationSafety.reasons,
1245
+ });
463
1246
  }
464
-
465
- const activeSession = getActiveSession();
466
- const isActiveSession = activeSession?.sessionId === targetSessionId;
467
-
468
- const sessionPath = getSessionPath(targetSessionId);
469
- if (fs.existsSync(sessionPath)) {
470
- fs.unlinkSync(sessionPath);
471
- }
472
-
473
- if (isActiveSession) {
474
- const activeSessionFile = getActiveSessionFile();
475
- if (fs.existsSync(activeSessionFile)) {
476
- fs.unlinkSync(activeSessionFile);
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
- action: 'reset',
482
- sessionId: targetSessionId,
483
- message: 'Session cleared.',
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
- let targetSessionId = sessionId;
491
- let existingData = {};
492
-
493
- if (!targetSessionId || targetSessionId === 'new') {
494
- if (action === 'append') {
495
- const activeSession = getActiveSession();
496
- if (activeSession) {
497
- targetSessionId = activeSession.sessionId;
498
- existingData = activeSession;
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
- targetSessionId = generateSessionId(update.goal);
1341
+ existingData = hydrateSession(getSessionRow(db, targetSessionId)) ?? {};
504
1342
  }
505
- } else {
506
- const existing = loadSession(targetSessionId);
507
- if (existing) {
508
- existingData = existing;
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
- const resolvedStatus = normalizeStatus(update.status, normalizeStatus(existingData.status));
513
- const completed = action === 'append'
514
- ? mergeUniqueStrings(existingData.completed, update.completed)
515
- : mergeUniqueStrings(update.completed);
516
- const decisions = action === 'append'
517
- ? mergeUniqueStrings(existingData.decisions, update.decisions)
518
- : mergeUniqueStrings(update.decisions);
519
- const touchedFiles = action === 'append'
520
- ? mergeUniqueStrings(existingData.touchedFiles, update.touchedFiles)
521
- : mergeUniqueStrings(update.touchedFiles);
522
- const mergedData = action === 'append'
523
- ? {
524
- goal: update.goal || existingData.goal || 'Untitled session',
525
- status: resolvedStatus,
526
- pinnedContext: mergeUniqueStrings(existingData.pinnedContext, update.pinnedContext),
527
- unresolvedQuestions: mergeUniqueStrings(existingData.unresolvedQuestions, update.unresolvedQuestions),
528
- currentFocus: update.currentFocus || existingData.currentFocus || '',
529
- whyBlocked: update.whyBlocked || existingData.whyBlocked || '',
530
- completed,
531
- decisions,
532
- blockers: update.blockers !== undefined ? mergeUniqueStrings(update.blockers) : (existingData.blockers || []),
533
- nextStep: update.nextStep || existingData.nextStep || '',
534
- touchedFiles,
535
- completedCount: completed.length,
536
- decisionsCount: decisions.length,
537
- touchedFilesCount: touchedFiles.length,
538
- }
539
- : {
540
- goal: update.goal || 'Untitled session',
541
- status: normalizeStatus(update.status),
542
- pinnedContext: mergeUniqueStrings(update.pinnedContext),
543
- unresolvedQuestions: mergeUniqueStrings(update.unresolvedQuestions),
544
- currentFocus: update.currentFocus ?? '',
545
- whyBlocked: update.whyBlocked ?? '',
546
- completed,
547
- decisions,
548
- blockers: mergeUniqueStrings(update.blockers),
549
- nextStep: update.nextStep ?? '',
550
- touchedFiles,
551
- completedCount: completed.length,
552
- decisionsCount: decisions.length,
553
- touchedFilesCount: touchedFiles.length,
554
- };
555
-
556
- const savedData = saveSession(targetSessionId, mergedData);
557
- const { compressed, tokens, truncated, omitted, compressionLevel } = compressSummary(savedData, maxTokens);
558
-
559
- const rawTokens = countTokens(JSON.stringify(savedData));
560
- const summaryMetrics = buildSummaryMetrics(rawTokens, tokens);
561
-
562
- persistMetrics({
563
- tool: 'smart_summary',
564
- action,
565
- sessionId: targetSessionId,
566
- ...summaryMetrics,
567
- latencyMs: Date.now() - startTime,
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
  };