memorix 1.2.6 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +6 -3
  3. package/README.zh-CN.md +5 -2
  4. package/dist/cli/index.js +954 -72
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/cli/memcode.js +0 -0
  7. package/dist/index.js +632 -27
  8. package/dist/index.js.map +1 -1
  9. package/dist/maintenance-runner.js +39 -1
  10. package/dist/maintenance-runner.js.map +1 -1
  11. package/dist/memcode-runtime/CHANGELOG.md +12 -0
  12. package/dist/sdk.d.ts +1 -1
  13. package/dist/sdk.js +632 -27
  14. package/dist/sdk.js.map +1 -1
  15. package/dist/types.d.ts +38 -1
  16. package/dist/types.js.map +1 -1
  17. package/docs/1.2.7-CONTEXT-CONTINUITY.md +68 -0
  18. package/docs/API_REFERENCE.md +6 -2
  19. package/docs/dev-log/progress.txt +23 -0
  20. package/docs/hooks-architecture.md +2 -1
  21. package/package.json +1 -1
  22. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  23. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  24. package/plugins/copilot/memorix/plugin.json +1 -1
  25. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  26. package/plugins/omp/memorix/extensions/memorix.js +17 -0
  27. package/plugins/omp/memorix/package.json +1 -1
  28. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  29. package/plugins/pi/memorix/extensions/memorix.js +17 -0
  30. package/plugins/pi/memorix/package.json +1 -1
  31. package/src/cli/capability-map.ts +1 -0
  32. package/src/cli/command-guide.ts +10 -0
  33. package/src/cli/commands/checkpoint.ts +144 -0
  34. package/src/cli/index.ts +3 -1
  35. package/src/codegraph/auto-context.ts +51 -5
  36. package/src/hooks/handler.ts +103 -11
  37. package/src/hooks/normalizer.ts +85 -1
  38. package/src/hooks/types.ts +16 -0
  39. package/src/knowledge/workset.ts +43 -2
  40. package/src/memory/compaction.ts +165 -0
  41. package/src/server/tool-profile.ts +1 -0
  42. package/src/server.ts +94 -0
  43. package/src/store/compaction-checkpoint-store.ts +397 -0
  44. package/src/store/sqlite-db.ts +41 -0
  45. package/src/types.ts +46 -0
@@ -0,0 +1,165 @@
1
+ import type { CompactionCheckpoint } from '../types.js';
2
+ import type { NormalizedHookInput } from '../hooks/types.js';
3
+ import { countTextTokens, truncateToTokenBudget } from '../compact/token-budget.js';
4
+ import { sanitizeCredentials } from './secret-filter.js';
5
+ import { CompactionCheckpointStore } from '../store/compaction-checkpoint-store.js';
6
+
7
+ const DEFAULT_WORKSET_BUDGET = 420;
8
+ const MIN_WORKSET_BUDGET = 48;
9
+
10
+ export interface CompactionWorkset {
11
+ checkpointId: string;
12
+ text: string;
13
+ tokens: number;
14
+ }
15
+
16
+ export interface BuildCompactionWorksetOptions {
17
+ task?: string;
18
+ maxTokens?: number;
19
+ }
20
+
21
+ function normalizeBudget(value: number | undefined): number {
22
+ if (!Number.isFinite(value) || value == null) return DEFAULT_WORKSET_BUDGET;
23
+ return Math.max(MIN_WORKSET_BUDGET, Math.min(2_000, Math.floor(value)));
24
+ }
25
+
26
+ function sourceLabel(checkpoint: CompactionCheckpoint): string {
27
+ if (checkpoint.captureKind === 'native-summary') return 'native host summary';
28
+ if (checkpoint.captureKind === 'preflight') return 'pre-compact lifecycle marker';
29
+ return 'host lifecycle marker';
30
+ }
31
+
32
+ /**
33
+ * Render a small, source-aware continuation packet. It never replays a full
34
+ * transcript and never promotes a native summary into durable knowledge.
35
+ */
36
+ export function buildCompactionWorkset(
37
+ checkpoint: CompactionCheckpoint,
38
+ options: BuildCompactionWorksetOptions = {},
39
+ ): CompactionWorkset {
40
+ const budget = normalizeBudget(options.maxTokens);
41
+ const task = options.task?.trim()
42
+ ? truncateToTokenBudget(sanitizeCredentials(options.task.trim()), Math.max(8, Math.floor(budget * 0.25)))
43
+ : '';
44
+ const headerLines = [
45
+ '## Compact Continuation',
46
+ `- Host: ${checkpoint.agent} (${sourceLabel(checkpoint)})`,
47
+ `- Trigger: ${checkpoint.reason}`,
48
+ task ? `- Task now: ${task}` : '',
49
+ '- Current code remains authoritative.',
50
+ ].filter(Boolean);
51
+ const header = `${headerLines.join('\n')}\n\n`;
52
+ const fallback = checkpoint.captureKind === 'native-summary'
53
+ ? ''
54
+ : 'The host did not expose a native compact summary. Reconstruct only what the current task needs from current code and Memorix evidence.';
55
+ const source = sanitizeCredentials(checkpoint.summary?.trim() || fallback);
56
+ const available = Math.max(0, budget - countTextTokens(header) - 4);
57
+ let excerpt = source ? truncateToTokenBudget(source, available) : '';
58
+ let text = `${header}${excerpt ? `### Host checkpoint\n${excerpt}` : ''}`.trim();
59
+ while (excerpt && countTextTokens(text) > budget) {
60
+ const nextExcerptBudget = Math.max(1, Math.floor(countTextTokens(excerpt) * 0.8));
61
+ excerpt = truncateToTokenBudget(excerpt, nextExcerptBudget);
62
+ text = `${header}${excerpt ? `### Host checkpoint\n${excerpt}` : ''}`.trim();
63
+ }
64
+ if (countTextTokens(text) > budget) text = truncateToTokenBudget(text, budget);
65
+ return {
66
+ checkpointId: checkpoint.id,
67
+ text,
68
+ tokens: countTextTokens(text),
69
+ };
70
+ }
71
+
72
+ function sourceEvent(input: NormalizedHookInput): string {
73
+ const raw = input.raw;
74
+ const event = raw.hook_event_name ?? raw.event ?? raw.type;
75
+ return typeof event === 'string' && event ? event : input.event;
76
+ }
77
+
78
+ /**
79
+ * Persist only the host lifecycle evidence available in a normalized hook.
80
+ * The caller owns error handling so an unavailable local DB never blocks a
81
+ * host-native compaction.
82
+ */
83
+ export async function captureCompactionCheckpoint(
84
+ input: NormalizedHookInput,
85
+ ): Promise<CompactionCheckpoint | null> {
86
+ const isCompactResume = input.event === 'session_start'
87
+ && input.sessionStartReason?.trim().toLowerCase() === 'compact';
88
+ if (input.event !== 'pre_compact' && input.event !== 'post_compact' && !isCompactResume) return null;
89
+ if (!input.sessionId || !input.cwd) return null;
90
+
91
+ const [
92
+ { detectProject },
93
+ { getProjectDataDir },
94
+ { initAliasRegistry, registerAlias },
95
+ ] = await Promise.all([
96
+ import('../project/detector.js'),
97
+ import('../store/persistence.js'),
98
+ import('../project/aliases.js'),
99
+ ]);
100
+ const project = detectProject(input.cwd);
101
+ if (!project) return null;
102
+ const dataDir = await getProjectDataDir(project.id);
103
+ initAliasRegistry(dataDir);
104
+ const projectId = await registerAlias(project);
105
+ const store = new CompactionCheckpointStore(dataDir);
106
+
107
+ if (input.event === 'pre_compact') {
108
+ return store.recordPreflight({
109
+ projectId,
110
+ sessionId: input.sessionId,
111
+ agent: input.agent,
112
+ reason: input.compaction?.reason,
113
+ sourceEvent: sourceEvent(input),
114
+ transcriptAvailable: Boolean(input.transcriptPath),
115
+ capturedAt: input.timestamp,
116
+ });
117
+ }
118
+
119
+ return store.complete({
120
+ projectId,
121
+ sessionId: input.sessionId,
122
+ agent: input.agent,
123
+ reason: input.compaction?.reason,
124
+ sourceEvent: sourceEvent(input),
125
+ sourceKey: input.compaction?.sourceKey,
126
+ summary: input.compaction?.summary,
127
+ tokensBefore: input.compaction?.tokensBefore,
128
+ firstKeptEntryId: input.compaction?.firstKeptEntryId,
129
+ details: input.compaction?.details,
130
+ completedAt: input.timestamp,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Take the latest undelivered compact checkpoint for an exact host session.
136
+ * Delivery is opt-in at adapter boundaries; this helper never injects content
137
+ * into a host by itself.
138
+ */
139
+ export async function consumeCompactionWorkset(
140
+ input: NormalizedHookInput,
141
+ options: BuildCompactionWorksetOptions = {},
142
+ ): Promise<CompactionWorkset | null> {
143
+ if (!input.sessionId || !input.cwd) return null;
144
+ const [
145
+ { detectProject },
146
+ { getProjectDataDir },
147
+ { initAliasRegistry, registerAlias },
148
+ ] = await Promise.all([
149
+ import('../project/detector.js'),
150
+ import('../store/persistence.js'),
151
+ import('../project/aliases.js'),
152
+ ]);
153
+ const project = detectProject(input.cwd);
154
+ if (!project) return null;
155
+ const dataDir = await getProjectDataDir(project.id);
156
+ initAliasRegistry(dataDir);
157
+ const projectId = await registerAlias(project);
158
+ const store = new CompactionCheckpointStore(dataDir);
159
+ const checkpoint = store.findUndelivered(projectId, input.sessionId, input.agent);
160
+ if (!checkpoint) return null;
161
+ const workset = buildCompactionWorkset(checkpoint, options);
162
+ if (!workset.text) return null;
163
+ store.markDelivered(checkpoint.id);
164
+ return workset;
165
+ }
@@ -68,6 +68,7 @@ export const TOOL_PROFILES: Record<string, ReadonlyArray<ToolProfile>> = Object.
68
68
  memorix_rules_sync: ['full'],
69
69
  memorix_workspace_sync: ['full'],
70
70
  memorix_ingest_image: ['full'],
71
+ memorix_compaction_checkpoint: ['full'],
71
72
 
72
73
  // ── MCP Official Memory Server compatibility (KG tools) ──────────
73
74
  // These are only useful to users specifically migrating from the
package/src/server.ts CHANGED
@@ -3974,6 +3974,100 @@ export async function createMemorixServer(
3974
3974
  },
3975
3975
  );
3976
3976
 
3977
+ /**
3978
+ * memorix_compaction_checkpoint — Advanced inspection of native host
3979
+ * compaction checkpoints. Automatic recovery does not need this tool; it is
3980
+ * deliberately full-profile only so normal MCP startup stays compact.
3981
+ */
3982
+ server.registerTool(
3983
+ 'memorix_compaction_checkpoint',
3984
+ {
3985
+ title: 'Compact Continuity Checkpoints',
3986
+ description:
3987
+ 'Inspect, preview, or archive bounded checkpoints recorded around a host-native context compaction. ' +
3988
+ 'Use only to debug or explicitly review compaction continuity. These are lifecycle records, not durable memories or transcript backups. ' +
3989
+ 'CLI equivalent: memorix checkpoint list|show|context|archive.',
3990
+ inputSchema: {
3991
+ action: z.enum(['list', 'show', 'context', 'archive']).default('list'),
3992
+ id: z.string().optional().describe('Checkpoint ID for show, context, or archive'),
3993
+ sessionId: z.string().optional().describe('Optional host session ID filter'),
3994
+ agent: z.string().optional().describe('Optional host agent filter'),
3995
+ task: z.string().optional().describe('Current task for a bounded context preview'),
3996
+ maxTokens: z.number().optional().describe('Maximum tokens for action=context (default: 420)'),
3997
+ limit: z.number().optional().describe('Maximum records for action=list (default: 20)'),
3998
+ includeArchived: z.boolean().optional().default(false).describe('Include archived records in action=list'),
3999
+ },
4000
+ },
4001
+ async ({ action, id, sessionId, agent, task, maxTokens, limit, includeArchived }) => {
4002
+ const unresolved = requireResolvedProject('inspect compact continuity checkpoints');
4003
+ if (unresolved) return unresolved;
4004
+
4005
+ const [{ CompactionCheckpointStore }, { buildCompactionWorkset }] = await Promise.all([
4006
+ import('./store/compaction-checkpoint-store.js'),
4007
+ import('./memory/compaction.js'),
4008
+ ]);
4009
+ const store = new CompactionCheckpointStore(projectDir);
4010
+ const assertCheckpoint = (checkpointId: string) => {
4011
+ const checkpoint = store.get(checkpointId);
4012
+ if (!checkpoint || checkpoint.projectId !== project.id) return null;
4013
+ return checkpoint;
4014
+ };
4015
+
4016
+ if (action === 'list') {
4017
+ const checkpoints = store.list({
4018
+ projectId: project.id,
4019
+ sessionId: sessionId?.trim() || undefined,
4020
+ agent: agent?.trim() || undefined,
4021
+ includeArchived: Boolean(includeArchived),
4022
+ limit: limit != null ? Math.max(1, Math.min(100, coerceNumber(limit, 20))) : 20,
4023
+ });
4024
+ return {
4025
+ content: [{ type: 'text' as const, text: JSON.stringify({ projectId: project.id, checkpoints }, null, 2) }],
4026
+ };
4027
+ }
4028
+
4029
+ if (!id?.trim()) {
4030
+ return {
4031
+ content: [{ type: 'text' as const, text: 'id is required for this checkpoint action.' }],
4032
+ isError: true,
4033
+ };
4034
+ }
4035
+ const checkpoint = assertCheckpoint(id.trim());
4036
+ if (!checkpoint) {
4037
+ return {
4038
+ content: [{ type: 'text' as const, text: `Checkpoint "${id.trim()}" was not found for the current project.` }],
4039
+ isError: true,
4040
+ };
4041
+ }
4042
+
4043
+ if (action === 'show') {
4044
+ return {
4045
+ content: [{ type: 'text' as const, text: JSON.stringify({ projectId: project.id, checkpoint }, null, 2) }],
4046
+ };
4047
+ }
4048
+ if (action === 'context') {
4049
+ const workset = buildCompactionWorkset(checkpoint, {
4050
+ task: task?.trim(),
4051
+ maxTokens: maxTokens != null ? coerceNumber(maxTokens, 420) : 420,
4052
+ });
4053
+ return {
4054
+ content: [{ type: 'text' as const, text: workset.text }],
4055
+ };
4056
+ }
4057
+
4058
+ const archived = store.archive(checkpoint.id);
4059
+ if (!archived) {
4060
+ return {
4061
+ content: [{ type: 'text' as const, text: `Checkpoint "${checkpoint.id}" is already archived.` }],
4062
+ isError: true,
4063
+ };
4064
+ }
4065
+ return {
4066
+ content: [{ type: 'text' as const, text: `Archived compact checkpoint ${archived.id}.` }],
4067
+ };
4068
+ },
4069
+ );
4070
+
3977
4071
  // ============================================================
3978
4072
  // Export / Import
3979
4073
  // ============================================================
@@ -0,0 +1,397 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import type {
3
+ CompactionCaptureKind,
4
+ CompactionCheckpoint,
5
+ CompactionCheckpointStatus,
6
+ CompactionReason,
7
+ } from '../types.js';
8
+ import { sanitizeCredentials } from '../memory/secret-filter.js';
9
+ import { getDatabase } from './sqlite-db.js';
10
+
11
+ const MAX_SUMMARY_CHARS = 24_000;
12
+ const MAX_DETAILS_CHARS = 4_000;
13
+
14
+ export interface RecordCompactionPreflightInput {
15
+ projectId: string;
16
+ sessionId: string;
17
+ agent: string;
18
+ reason?: CompactionReason;
19
+ sourceEvent: string;
20
+ transcriptAvailable?: boolean;
21
+ capturedAt?: string;
22
+ }
23
+
24
+ export interface CompleteCompactionCheckpointInput {
25
+ projectId: string;
26
+ sessionId: string;
27
+ agent: string;
28
+ reason?: CompactionReason;
29
+ sourceEvent: string;
30
+ sourceKey?: string;
31
+ summary?: string;
32
+ tokensBefore?: number;
33
+ firstKeptEntryId?: string;
34
+ details?: Record<string, unknown>;
35
+ completedAt?: string;
36
+ }
37
+
38
+ export interface ListCompactionCheckpointsOptions {
39
+ projectId: string;
40
+ sessionId?: string;
41
+ agent?: string;
42
+ includeArchived?: boolean;
43
+ limit?: number;
44
+ }
45
+
46
+ export interface FindLatestCompletedCheckpointOptions {
47
+ /**
48
+ * A Hook may already have delivered this exact host session's checkpoint via
49
+ * an official native context channel. Do not put it back into its generic
50
+ * Autopilot brief on a later event from the same session.
51
+ */
52
+ excludeSession?: {
53
+ sessionId: string;
54
+ agent: string;
55
+ };
56
+ }
57
+
58
+ function optionalText(value: unknown): string | undefined {
59
+ return typeof value === 'string' && value ? value : undefined;
60
+ }
61
+
62
+ function optionalNumber(value: unknown): number | undefined {
63
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
64
+ }
65
+
66
+ function parseDetails(value: unknown): Record<string, unknown> | undefined {
67
+ if (typeof value !== 'string' || !value) return undefined;
68
+ try {
69
+ const parsed = JSON.parse(value);
70
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
71
+ ? parsed as Record<string, unknown>
72
+ : undefined;
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ function sanitizeSummary(value: string | undefined): string | undefined {
79
+ if (!value?.trim()) return undefined;
80
+ return sanitizeCredentials(value).slice(0, MAX_SUMMARY_CHARS).trim() || undefined;
81
+ }
82
+
83
+ function sanitizeDetails(value: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
84
+ if (!value) return undefined;
85
+ try {
86
+ const sanitized = sanitizeCredentials(JSON.stringify(value));
87
+ if (sanitized.length > MAX_DETAILS_CHARS) return { truncated: true };
88
+ const parsed = JSON.parse(sanitized);
89
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
90
+ ? parsed as Record<string, unknown>
91
+ : undefined;
92
+ } catch {
93
+ return { unavailable: true };
94
+ }
95
+ }
96
+
97
+ function sourceKeyFor(input: CompleteCompactionCheckpointInput, summary?: string): string {
98
+ if (input.sourceKey?.trim()) return input.sourceKey.trim().slice(0, 300);
99
+ const material = [
100
+ input.projectId,
101
+ input.sessionId,
102
+ input.agent,
103
+ input.sourceEvent,
104
+ summary ?? '',
105
+ input.tokensBefore ?? '',
106
+ input.firstKeptEntryId ?? '',
107
+ ].join('\u0000');
108
+ return `derived:${createHash('sha256').update(material).digest('hex').slice(0, 32)}`;
109
+ }
110
+
111
+ function rowToCheckpoint(row: any): CompactionCheckpoint {
112
+ return {
113
+ id: row.id,
114
+ projectId: row.project_id,
115
+ sessionId: row.session_id,
116
+ agent: row.agent,
117
+ phase: row.phase,
118
+ captureKind: row.capture_kind,
119
+ reason: row.reason,
120
+ sourceEvent: row.source_event,
121
+ sourceKey: row.source_key,
122
+ ...(optionalText(row.summary) ? { summary: row.summary } : {}),
123
+ ...(optionalNumber(row.tokens_before) !== undefined ? { tokensBefore: Number(row.tokens_before) } : {}),
124
+ ...(optionalText(row.first_kept_entry_id) ? { firstKeptEntryId: row.first_kept_entry_id } : {}),
125
+ ...(parseDetails(row.details_json) ? { details: parseDetails(row.details_json) } : {}),
126
+ transcriptAvailable: Boolean(row.transcript_available),
127
+ status: row.status as CompactionCheckpointStatus,
128
+ preCapturedAt: row.pre_captured_at,
129
+ ...(optionalText(row.completed_at) ? { completedAt: row.completed_at } : {}),
130
+ ...(optionalText(row.delivered_at) ? { deliveredAt: row.delivered_at } : {}),
131
+ deliveryCount: Number(row.delivery_count ?? 0),
132
+ createdAt: row.created_at,
133
+ updatedAt: row.updated_at,
134
+ };
135
+ }
136
+
137
+ function safeReason(value: CompactionReason | undefined): CompactionReason {
138
+ return value === 'manual' || value === 'auto' ? value : 'unknown';
139
+ }
140
+
141
+ function safeLimit(value: number | undefined, fallback: number): number {
142
+ if (!Number.isFinite(value) || value == null) return fallback;
143
+ return Math.max(1, Math.min(500, Math.floor(value)));
144
+ }
145
+
146
+ /**
147
+ * SQLite persistence for host-native compaction lifecycle records.
148
+ *
149
+ * The store deliberately keeps evidence separate from Observations so a
150
+ * compactor's summary cannot silently become durable project knowledge.
151
+ */
152
+ export class CompactionCheckpointStore {
153
+ private readonly db: any;
154
+
155
+ constructor(dataDir: string) {
156
+ this.db = getDatabase(dataDir);
157
+ }
158
+
159
+ recordPreflight(input: RecordCompactionPreflightInput): CompactionCheckpoint {
160
+ const pending = this.db.prepare(`
161
+ SELECT * FROM compaction_checkpoints
162
+ WHERE project_id = ? AND session_id = ? AND agent = ?
163
+ AND phase = 'pre' AND status = 'active'
164
+ ORDER BY pre_captured_at DESC, created_at DESC
165
+ LIMIT 1
166
+ `).get(input.projectId, input.sessionId, input.agent);
167
+ if (pending) return rowToCheckpoint(pending);
168
+
169
+ const now = input.capturedAt ?? new Date().toISOString();
170
+ const checkpoint: CompactionCheckpoint = {
171
+ id: randomUUID(),
172
+ projectId: input.projectId,
173
+ sessionId: input.sessionId,
174
+ agent: input.agent,
175
+ phase: 'pre',
176
+ captureKind: 'preflight',
177
+ reason: safeReason(input.reason),
178
+ sourceEvent: input.sourceEvent,
179
+ sourceKey: `pre:${randomUUID()}`,
180
+ transcriptAvailable: Boolean(input.transcriptAvailable),
181
+ status: 'active',
182
+ preCapturedAt: now,
183
+ deliveryCount: 0,
184
+ createdAt: now,
185
+ updatedAt: now,
186
+ };
187
+ this.insert(checkpoint);
188
+ return checkpoint;
189
+ }
190
+
191
+ complete(input: CompleteCompactionCheckpointInput): CompactionCheckpoint {
192
+ const completedAt = input.completedAt ?? new Date().toISOString();
193
+ const summary = sanitizeSummary(input.summary);
194
+ const pending = this.db.prepare(`
195
+ SELECT * FROM compaction_checkpoints
196
+ WHERE project_id = ? AND session_id = ? AND agent = ?
197
+ AND phase = 'pre' AND status = 'active'
198
+ ORDER BY pre_captured_at DESC, created_at DESC
199
+ LIMIT 1
200
+ `).get(input.projectId, input.sessionId, input.agent);
201
+
202
+ // Hosts such as Codex and Claude expose a post-compact lifecycle signal
203
+ // but no event ID. Correlate it with the newest preflight marker so two
204
+ // compactions in one long session remain distinct. With no fresh marker,
205
+ // treat another identical lifecycle signal as a duplicate rather than
206
+ // creating a stream of empty checkpoints.
207
+ if (!input.sourceKey?.trim() && !summary && input.tokensBefore === undefined && !input.firstKeptEntryId && !pending) {
208
+ const priorLifecycle = this.db.prepare(`
209
+ SELECT * FROM compaction_checkpoints
210
+ WHERE project_id = ? AND session_id = ? AND agent = ?
211
+ AND phase = 'complete' AND capture_kind = 'lifecycle'
212
+ AND source_event = ? AND status = 'active'
213
+ ORDER BY completed_at DESC, created_at DESC
214
+ LIMIT 1
215
+ `).get(input.projectId, input.sessionId, input.agent, input.sourceEvent);
216
+ if (priorLifecycle) return rowToCheckpoint(priorLifecycle);
217
+ }
218
+
219
+ const sourceKey = input.sourceKey?.trim()
220
+ ? input.sourceKey.trim().slice(0, 300)
221
+ : pending
222
+ ? `lifecycle:${pending.id}`
223
+ : sourceKeyFor(input, summary);
224
+ const existing = this.db.prepare(`
225
+ SELECT * FROM compaction_checkpoints
226
+ WHERE project_id = ? AND source_key = ?
227
+ LIMIT 1
228
+ `).get(input.projectId, sourceKey);
229
+ if (existing) return rowToCheckpoint(existing);
230
+
231
+ const captureKind: CompactionCaptureKind = summary ? 'native-summary' : 'lifecycle';
232
+ const details = sanitizeDetails(input.details);
233
+
234
+ if (pending) {
235
+ const checkpoint = rowToCheckpoint(pending);
236
+ const nextReason = safeReason(input.reason) === 'unknown'
237
+ ? checkpoint.reason
238
+ : safeReason(input.reason);
239
+ this.db.prepare(`
240
+ UPDATE compaction_checkpoints
241
+ SET phase = 'complete', capture_kind = ?, reason = ?, source_event = ?, source_key = ?,
242
+ summary = ?, tokens_before = ?, first_kept_entry_id = ?, details_json = ?,
243
+ completed_at = ?, updated_at = ?
244
+ WHERE id = ?
245
+ `).run(
246
+ captureKind,
247
+ nextReason,
248
+ input.sourceEvent,
249
+ sourceKey,
250
+ summary ?? null,
251
+ input.tokensBefore ?? null,
252
+ input.firstKeptEntryId ?? null,
253
+ JSON.stringify(details ?? {}),
254
+ completedAt,
255
+ completedAt,
256
+ checkpoint.id,
257
+ );
258
+ return this.get(checkpoint.id)!;
259
+ }
260
+
261
+ const checkpoint: CompactionCheckpoint = {
262
+ id: randomUUID(),
263
+ projectId: input.projectId,
264
+ sessionId: input.sessionId,
265
+ agent: input.agent,
266
+ phase: 'complete',
267
+ captureKind,
268
+ reason: safeReason(input.reason),
269
+ sourceEvent: input.sourceEvent,
270
+ sourceKey,
271
+ ...(summary ? { summary } : {}),
272
+ ...(input.tokensBefore !== undefined ? { tokensBefore: input.tokensBefore } : {}),
273
+ ...(input.firstKeptEntryId ? { firstKeptEntryId: input.firstKeptEntryId } : {}),
274
+ ...(details ? { details } : {}),
275
+ transcriptAvailable: false,
276
+ status: 'active',
277
+ preCapturedAt: completedAt,
278
+ completedAt,
279
+ deliveryCount: 0,
280
+ createdAt: completedAt,
281
+ updatedAt: completedAt,
282
+ };
283
+ this.insert(checkpoint);
284
+ return checkpoint;
285
+ }
286
+
287
+ get(id: string): CompactionCheckpoint | undefined {
288
+ const row = this.db.prepare('SELECT * FROM compaction_checkpoints WHERE id = ?').get(id);
289
+ return row ? rowToCheckpoint(row) : undefined;
290
+ }
291
+
292
+ list(options: ListCompactionCheckpointsOptions): CompactionCheckpoint[] {
293
+ const conditions = ['project_id = ?'];
294
+ const values: unknown[] = [options.projectId];
295
+ if (options.sessionId) {
296
+ conditions.push('session_id = ?');
297
+ values.push(options.sessionId);
298
+ }
299
+ if (options.agent) {
300
+ conditions.push('agent = ?');
301
+ values.push(options.agent);
302
+ }
303
+ if (!options.includeArchived) {
304
+ conditions.push("status = 'active'");
305
+ }
306
+ const rows = this.db.prepare(`
307
+ SELECT * FROM compaction_checkpoints
308
+ WHERE ${conditions.join(' AND ')}
309
+ ORDER BY COALESCE(completed_at, pre_captured_at) DESC, created_at DESC
310
+ LIMIT ?
311
+ `).all(...values, safeLimit(options.limit, 20));
312
+ return rows.map(rowToCheckpoint);
313
+ }
314
+
315
+ findUndelivered(projectId: string, sessionId: string, agent: string): CompactionCheckpoint | undefined {
316
+ const row = this.db.prepare(`
317
+ SELECT * FROM compaction_checkpoints
318
+ WHERE project_id = ? AND session_id = ? AND agent = ?
319
+ AND phase = 'complete' AND status = 'active' AND delivered_at IS NULL
320
+ ORDER BY completed_at DESC, created_at DESC
321
+ LIMIT 1
322
+ `).get(projectId, sessionId, agent);
323
+ return row ? rowToCheckpoint(row) : undefined;
324
+ }
325
+
326
+ findLatestCompleted(
327
+ projectId: string,
328
+ options: FindLatestCompletedCheckpointOptions = {},
329
+ ): CompactionCheckpoint | undefined {
330
+ const excludeSession = options.excludeSession;
331
+ const exclusion = excludeSession
332
+ ? ' AND NOT (session_id = ? AND agent = ? )'
333
+ : '';
334
+ const values = excludeSession
335
+ ? [projectId, excludeSession.sessionId, excludeSession.agent]
336
+ : [projectId];
337
+ const row = this.db.prepare(`
338
+ SELECT * FROM compaction_checkpoints
339
+ WHERE project_id = ? AND phase = 'complete' AND status = 'active'
340
+ ${exclusion}
341
+ ORDER BY completed_at DESC, created_at DESC
342
+ LIMIT 1
343
+ `).get(...values);
344
+ return row ? rowToCheckpoint(row) : undefined;
345
+ }
346
+
347
+ archive(id: string, archivedAt = new Date().toISOString()): CompactionCheckpoint | undefined {
348
+ const result = this.db.prepare(`
349
+ UPDATE compaction_checkpoints
350
+ SET status = 'archived', updated_at = ?
351
+ WHERE id = ? AND status = 'active'
352
+ `).run(archivedAt, id);
353
+ return Number(result.changes ?? 0) > 0 ? this.get(id) : undefined;
354
+ }
355
+
356
+ markDelivered(id: string, deliveredAt = new Date().toISOString()): CompactionCheckpoint | undefined {
357
+ const result = this.db.prepare(`
358
+ UPDATE compaction_checkpoints
359
+ SET delivered_at = ?, delivery_count = delivery_count + 1, updated_at = ?
360
+ WHERE id = ? AND phase = 'complete' AND status = 'active' AND delivered_at IS NULL
361
+ `).run(deliveredAt, deliveredAt, id);
362
+ return Number(result.changes ?? 0) > 0 ? this.get(id) : undefined;
363
+ }
364
+
365
+ private insert(checkpoint: CompactionCheckpoint): void {
366
+ this.db.prepare(`
367
+ INSERT INTO compaction_checkpoints (
368
+ id, project_id, session_id, agent, phase, capture_kind, reason,
369
+ source_event, source_key, summary, tokens_before, first_kept_entry_id,
370
+ details_json, transcript_available, status, pre_captured_at, completed_at,
371
+ delivered_at, delivery_count, created_at, updated_at
372
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
373
+ `).run(
374
+ checkpoint.id,
375
+ checkpoint.projectId,
376
+ checkpoint.sessionId,
377
+ checkpoint.agent,
378
+ checkpoint.phase,
379
+ checkpoint.captureKind,
380
+ checkpoint.reason,
381
+ checkpoint.sourceEvent,
382
+ checkpoint.sourceKey,
383
+ checkpoint.summary ?? null,
384
+ checkpoint.tokensBefore ?? null,
385
+ checkpoint.firstKeptEntryId ?? null,
386
+ JSON.stringify(checkpoint.details ?? {}),
387
+ checkpoint.transcriptAvailable ? 1 : 0,
388
+ checkpoint.status,
389
+ checkpoint.preCapturedAt,
390
+ checkpoint.completedAt ?? null,
391
+ checkpoint.deliveredAt ?? null,
392
+ checkpoint.deliveryCount,
393
+ checkpoint.createdAt,
394
+ checkpoint.updatedAt,
395
+ );
396
+ }
397
+ }