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.
- package/CHANGELOG.md +12 -0
- package/README.md +6 -3
- package/README.zh-CN.md +5 -2
- package/dist/cli/index.js +954 -72
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/memcode.js +0 -0
- package/dist/index.js +632 -27
- package/dist/index.js.map +1 -1
- package/dist/maintenance-runner.js +39 -1
- package/dist/maintenance-runner.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +12 -0
- package/dist/sdk.d.ts +1 -1
- package/dist/sdk.js +632 -27
- package/dist/sdk.js.map +1 -1
- package/dist/types.d.ts +38 -1
- package/dist/types.js.map +1 -1
- package/docs/1.2.7-CONTEXT-CONTINUITY.md +68 -0
- package/docs/API_REFERENCE.md +6 -2
- package/docs/dev-log/progress.txt +23 -0
- package/docs/hooks-architecture.md +2 -1
- package/package.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/omp/memorix/extensions/memorix.js +17 -0
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/extensions/memorix.js +17 -0
- package/plugins/pi/memorix/package.json +1 -1
- package/src/cli/capability-map.ts +1 -0
- package/src/cli/command-guide.ts +10 -0
- package/src/cli/commands/checkpoint.ts +144 -0
- package/src/cli/index.ts +3 -1
- package/src/codegraph/auto-context.ts +51 -5
- package/src/hooks/handler.ts +103 -11
- package/src/hooks/normalizer.ts +85 -1
- package/src/hooks/types.ts +16 -0
- package/src/knowledge/workset.ts +43 -2
- package/src/memory/compaction.ts +165 -0
- package/src/server/tool-profile.ts +1 -0
- package/src/server.ts +94 -0
- package/src/store/compaction-checkpoint-store.ts +397 -0
- package/src/store/sqlite-db.ts +41 -0
- 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
|
+
}
|