memorix 1.2.6 → 1.2.8

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 (53) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +6 -3
  3. package/README.zh-CN.md +5 -2
  4. package/dist/cli/index.js +1272 -105
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/dashboard/static/app.js +7 -2
  7. package/dist/index.js +809 -52
  8. package/dist/index.js.map +1 -1
  9. package/dist/maintenance-runner.d.ts +6 -0
  10. package/dist/maintenance-runner.js +204 -25
  11. package/dist/maintenance-runner.js.map +1 -1
  12. package/dist/memcode-runtime/CHANGELOG.md +20 -0
  13. package/dist/sdk.d.ts +1 -1
  14. package/dist/sdk.js +809 -52
  15. package/dist/sdk.js.map +1 -1
  16. package/dist/types.d.ts +38 -1
  17. package/dist/types.js.map +1 -1
  18. package/docs/1.2.7-CONTEXT-CONTINUITY.md +68 -0
  19. package/docs/API_REFERENCE.md +6 -2
  20. package/docs/dev-log/progress.txt +48 -0
  21. package/docs/hooks-architecture.md +2 -1
  22. package/package.json +3 -3
  23. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  24. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  25. package/plugins/copilot/memorix/plugin.json +1 -1
  26. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  27. package/plugins/omp/memorix/extensions/memorix.js +17 -0
  28. package/plugins/omp/memorix/package.json +1 -1
  29. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  30. package/plugins/pi/memorix/extensions/memorix.js +17 -0
  31. package/plugins/pi/memorix/package.json +1 -1
  32. package/src/cli/capability-map.ts +1 -0
  33. package/src/cli/command-guide.ts +10 -0
  34. package/src/cli/commands/agent-integrations.ts +102 -5
  35. package/src/cli/commands/checkpoint.ts +144 -0
  36. package/src/cli/commands/serve-http.ts +14 -3
  37. package/src/cli/commands/setup.ts +44 -0
  38. package/src/cli/index.ts +3 -1
  39. package/src/codegraph/auto-context.ts +51 -5
  40. package/src/dashboard/server.ts +11 -0
  41. package/src/hooks/handler.ts +103 -11
  42. package/src/hooks/normalizer.ts +85 -1
  43. package/src/hooks/types.ts +16 -0
  44. package/src/knowledge/workset.ts +43 -2
  45. package/src/memory/compaction.ts +165 -0
  46. package/src/memory/observations.ts +67 -20
  47. package/src/runtime/maintenance-jobs.ts +84 -4
  48. package/src/runtime/project-maintenance.ts +63 -6
  49. package/src/server/tool-profile.ts +1 -0
  50. package/src/server.ts +94 -0
  51. package/src/store/compaction-checkpoint-store.ts +397 -0
  52. package/src/store/sqlite-db.ts +41 -0
  53. package/src/types.ts +46 -0
@@ -102,6 +102,10 @@ function normalizeEmbeddingFailure(error: unknown): { key: string; message: stri
102
102
  };
103
103
  }
104
104
 
105
+ function vectorBackfillError(error: unknown): string {
106
+ return sanitizeCredentials(normalizeEmbeddingFailure(error).message).slice(0, 1_000);
107
+ }
108
+
105
109
  function queueVectorBackfill(projectId: string): void {
106
110
  const dataDir = projectDir;
107
111
  if (!dataDir) return;
@@ -1202,6 +1206,7 @@ export async function backfillVectorEmbeddings(options: {
1202
1206
  attempted: number;
1203
1207
  succeeded: number;
1204
1208
  failed: number;
1209
+ lastError?: string;
1205
1210
  }> {
1206
1211
  if (vectorBackfillRunning) {
1207
1212
  return { attempted: 0, succeeded: 0, failed: 0 };
@@ -1218,19 +1223,63 @@ export async function backfillVectorEmbeddings(options: {
1218
1223
  let succeeded = 0;
1219
1224
  let failed = 0;
1220
1225
  let lastFailure: string | undefined;
1226
+ const recordFailure = (message: string): void => {
1227
+ // A single batch may have several symptoms after one root cause. Preserve
1228
+ // the first one so an informative provider/index error is not overwritten
1229
+ // by later missing-result fallout.
1230
+ if (!lastFailure) lastFailure = message;
1231
+ };
1232
+
1233
+ const candidates: Array<{ id: number; observation: Observation; text: string }> = [];
1234
+ for (const id of ids) {
1235
+ const observation = observationById.get(id);
1236
+ if (!observation) {
1237
+ vectorMissingIds.delete(id);
1238
+ continue;
1239
+ }
1240
+ candidates.push({
1241
+ id,
1242
+ observation,
1243
+ text: [observation.title, observation.narrative, ...observation.facts].join(' '),
1244
+ });
1245
+ }
1221
1246
 
1222
1247
  try {
1223
- for (const id of ids) {
1224
- const obs = observationById.get(id);
1225
- if (!obs) {
1226
- vectorMissingIds.delete(id);
1227
- continue;
1248
+ if (candidates.length === 0) {
1249
+ return { attempted: ids.length, succeeded, failed };
1250
+ }
1251
+
1252
+ let embeddings: number[][] | null = null;
1253
+ try {
1254
+ const provider = await getEmbeddingProvider();
1255
+ if (!provider) {
1256
+ if (isEmbeddingExplicitlyDisabled()) {
1257
+ for (const candidate of candidates) vectorMissingIds.delete(candidate.id);
1258
+ } else {
1259
+ recordFailure('embedding provider unavailable');
1260
+ failed += candidates.length;
1261
+ }
1262
+ } else {
1263
+ // API providers batch and cache this efficiently; local providers can use
1264
+ // their native batch path. Index writes stay sequential because Orama is
1265
+ // process-local mutable state.
1266
+ embeddings = await provider.embedBatch(candidates.map((candidate) => candidate.text));
1228
1267
  }
1268
+ } catch (error) {
1269
+ recordFailure(vectorBackfillError(error));
1270
+ failed += candidates.length;
1271
+ }
1229
1272
 
1230
- const text = [obs.title, obs.narrative, ...obs.facts].join(' ');
1231
- try {
1232
- const embedding = await generateEmbedding(text);
1233
- if (embedding) {
1273
+ if (embeddings) {
1274
+ for (let index = 0; index < candidates.length; index++) {
1275
+ const { id, observation: obs } = candidates[index];
1276
+ const embedding = embeddings[index];
1277
+ if (!embedding || embedding.length === 0) {
1278
+ recordFailure('embedding provider returned no vector');
1279
+ failed++;
1280
+ continue;
1281
+ }
1282
+ try {
1234
1283
  if (!isVectorCompatibleWithCurrentIndex(embedding)) {
1235
1284
  await upgradeVectorSchemaAfterFirstEmbedding(embedding);
1236
1285
  }
@@ -1239,7 +1288,7 @@ export async function backfillVectorEmbeddings(options: {
1239
1288
  console.error(
1240
1289
  `[memorix] Backfill embedding mismatch for obs-${id}: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d (kept in queue)`,
1241
1290
  );
1242
- lastFailure = `dimension mismatch: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d`;
1291
+ recordFailure(`dimension mismatch: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d`);
1243
1292
  failed++;
1244
1293
  continue;
1245
1294
  }
@@ -1277,17 +1326,10 @@ export async function backfillVectorEmbeddings(options: {
1277
1326
  await insertObservation(doc);
1278
1327
  vectorMissingIds.delete(id);
1279
1328
  succeeded++;
1280
- } else if (isEmbeddingExplicitlyDisabled()) {
1281
- // Embedding explicitly off — nothing to backfill from
1282
- vectorMissingIds.delete(id);
1283
- } else {
1284
- // Provider temporarily unavailable — keep in queue for next backfill cycle
1285
- lastFailure = 'embedding provider unavailable';
1329
+ } catch (error) {
1330
+ recordFailure(vectorBackfillError(error));
1286
1331
  failed++;
1287
1332
  }
1288
- } catch (err) {
1289
- lastFailure = err instanceof Error ? err.message : String(err);
1290
- failed++;
1291
1333
  }
1292
1334
  }
1293
1335
  } finally {
@@ -1301,5 +1343,10 @@ export async function backfillVectorEmbeddings(options: {
1301
1343
  };
1302
1344
  }
1303
1345
 
1304
- return { attempted: ids.length, succeeded, failed };
1346
+ return {
1347
+ attempted: ids.length,
1348
+ succeeded,
1349
+ failed,
1350
+ ...(lastFailure ? { lastError: lastFailure } : {}),
1351
+ };
1305
1352
  }
@@ -67,7 +67,18 @@ export interface MaintenanceJobSummary {
67
67
 
68
68
  export type MaintenanceJobRunResult =
69
69
  | { action: 'complete' }
70
- | { action: 'reschedule'; delayMs: number; resetAttempts?: boolean; payload?: Record<string, unknown> };
70
+ | {
71
+ action: 'reschedule';
72
+ delayMs: number;
73
+ resetAttempts?: boolean;
74
+ payload?: Record<string, unknown>;
75
+ /** Keep recoverable work out of the immediate pending queue during a cooldown. */
76
+ status?: 'pending' | 'retry';
77
+ /** Persist a sanitized diagnostic without consuming the job's retry budget. */
78
+ lastError?: string;
79
+ /** Clear a stale transient diagnostic after the job makes progress. */
80
+ clearLastError?: boolean;
81
+ };
71
82
 
72
83
  export type MaintenanceJobHandler = (
73
84
  job: MaintenanceJob,
@@ -160,6 +171,12 @@ export class MaintenanceJobStore {
160
171
  `).get(input.projectId, input.kind, dedupeKey);
161
172
 
162
173
  if (existing) {
174
+ // A vector backfill in cooldown is a circuit-breaker state. A new MCP
175
+ // process must not pull it forward and recreate the original retry storm.
176
+ if (input.kind === 'vector-backfill' && existing.status === 'retry') {
177
+ this.commit();
178
+ return rowToJob(existing);
179
+ }
163
180
  const nextRunAfter = Math.min(Number(existing.run_after), runAfter);
164
181
  this.db.prepare(`
165
182
  UPDATE maintenance_jobs
@@ -171,6 +188,30 @@ export class MaintenanceJobStore {
171
188
  return rowToJob(updated);
172
189
  }
173
190
 
191
+ // Older releases exhausted vector jobs on transient provider/index errors.
192
+ // Reuse one failed record so the next healthy session recovers work instead
193
+ // of adding another permanent failure row for the same project.
194
+ if (input.kind === 'vector-backfill') {
195
+ const failed = this.db.prepare(`
196
+ SELECT * FROM maintenance_jobs
197
+ WHERE project_id = ? AND kind = ? AND dedupe_key = ? AND status = 'failed'
198
+ ORDER BY updated_at DESC
199
+ LIMIT 1
200
+ `).get(input.projectId, input.kind, dedupeKey);
201
+ if (failed) {
202
+ this.db.prepare(`
203
+ UPDATE maintenance_jobs
204
+ SET status = 'pending', attempts = 0, max_attempts = ?, run_after = ?,
205
+ payload_json = ?, lease_owner = NULL, lease_expires_at = NULL,
206
+ last_error = NULL, completed_at = NULL, updated_at = ?
207
+ WHERE id = ?
208
+ `).run(maxAttempts, runAfter, payloadJson, now, failed.id);
209
+ const revived = this.getRow(failed.id)!;
210
+ this.commit();
211
+ return rowToJob(revived);
212
+ }
213
+ }
214
+
174
215
  const id = randomUUID();
175
216
  this.db.prepare(`
176
217
  INSERT INTO maintenance_jobs (
@@ -361,21 +402,57 @@ export class MaintenanceJobStore {
361
402
  reschedule(
362
403
  id: string,
363
404
  workerId: string,
364
- options: { delayMs: number; resetAttempts?: boolean; payload?: Record<string, unknown>; now?: number },
405
+ options: {
406
+ delayMs: number;
407
+ resetAttempts?: boolean;
408
+ payload?: Record<string, unknown>;
409
+ status?: 'pending' | 'retry';
410
+ lastError?: string;
411
+ clearLastError?: boolean;
412
+ now?: number;
413
+ },
365
414
  ): MaintenanceJob | undefined {
366
415
  const now = options.now ?? Date.now();
367
416
  const delayMs = Number.isFinite(options.delayMs) ? Math.max(0, Math.floor(options.delayMs)) : 0;
368
417
  const payloadJson = options.payload === undefined ? null : JSON.stringify(options.payload);
418
+ const status = options.status === 'retry' ? 'retry' : 'pending';
419
+ const hasLastError = options.lastError !== undefined;
420
+ const lastError = hasLastError ? errorText(options.lastError) : null;
369
421
  this.db.prepare(`
370
422
  UPDATE maintenance_jobs
371
- SET status = 'pending', run_after = ?, attempts = CASE WHEN ? THEN 0 ELSE attempts END,
423
+ SET status = ?, run_after = ?, attempts = CASE WHEN ? THEN 0 ELSE attempts END,
372
424
  payload_json = COALESCE(?, payload_json),
425
+ last_error = CASE
426
+ WHEN ? THEN NULL
427
+ WHEN ? THEN ?
428
+ ELSE last_error
429
+ END,
373
430
  lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
374
431
  WHERE id = ? AND status = 'running' AND lease_owner = ?
375
- `).run(now + delayMs, options.resetAttempts ? 1 : 0, payloadJson, now, id, workerId);
432
+ `).run(
433
+ status,
434
+ now + delayMs,
435
+ options.resetAttempts ? 1 : 0,
436
+ payloadJson,
437
+ options.clearLastError ? 1 : 0,
438
+ hasLastError ? 1 : 0,
439
+ lastError,
440
+ now,
441
+ id,
442
+ workerId,
443
+ );
376
444
  return this.get(id);
377
445
  }
378
446
 
447
+ resolveFailedVectorBackfills(projectId: string, dedupeKey = 'vector-backfill', now = Date.now()): number {
448
+ const result = this.db.prepare(`
449
+ UPDATE maintenance_jobs
450
+ SET status = 'completed', completed_at = ?, updated_at = ?
451
+ WHERE project_id = ? AND kind = 'vector-backfill' AND dedupe_key = ? AND status = 'failed'
452
+ `).run(now, now, projectId, dedupeKey);
453
+ return Number(result.changes ?? 0);
454
+ }
455
+
379
456
  fail(id: string, workerId: string, error: unknown, now = Date.now()): MaintenanceJob | undefined {
380
457
  this.begin();
381
458
  try {
@@ -486,6 +563,9 @@ export class MaintenanceJobWorker {
486
563
  delayMs: result.delayMs,
487
564
  resetAttempts: result.resetAttempts,
488
565
  payload: result.payload,
566
+ status: result.status,
567
+ lastError: result.lastError,
568
+ clearLastError: result.clearLastError,
489
569
  now,
490
570
  });
491
571
  return { state: 'rescheduled', job: updated };
@@ -1,6 +1,7 @@
1
- import type {
2
- MaintenanceJobHandler,
3
- MaintenanceJobRunResult,
1
+ import {
2
+ MaintenanceJobStore,
3
+ type MaintenanceJobHandler,
4
+ type MaintenanceJobRunResult,
4
5
  } from './maintenance-jobs.js';
5
6
  import { runMaintenanceInChildProcess } from './isolated-maintenance.js';
6
7
  import {
@@ -18,6 +19,8 @@ const DEFAULT_CODEGRAPH_MAX_FILES = 5_000;
18
19
  const DEFAULT_CLAIM_DERIVATION_BATCH_SIZE = 100;
19
20
  const DEFAULT_OBSERVATION_QUALIFICATION_BATCH_SIZE = 100;
20
21
  const VECTOR_RETRY_DELAY_MS = 5_000;
22
+ const VECTOR_FAILURE_BASE_RETRY_DELAY_MS = 60_000;
23
+ const VECTOR_FAILURE_MAX_RETRY_DELAY_MS = 15 * 60_000;
21
24
  const DEDUP_PER_PAIR_TIMEOUT_MS = 5_000;
22
25
 
23
26
  function vectorBatchSize(payload: Record<string, unknown>): number {
@@ -26,6 +29,33 @@ function vectorBatchSize(payload: Record<string, unknown>): number {
26
29
  return Math.min(100, Math.max(1, Math.floor(value)));
27
30
  }
28
31
 
32
+ function vectorBackfillFailureStreak(payload: Record<string, unknown>): number {
33
+ const value = payload.vectorBackfillFailureStreak;
34
+ return typeof value === 'number' && Number.isFinite(value)
35
+ ? Math.min(16, Math.max(0, Math.floor(value)))
36
+ : 0;
37
+ }
38
+
39
+ function vectorBackfillRetryDelayMs(streak: number): number {
40
+ return Math.min(
41
+ VECTOR_FAILURE_MAX_RETRY_DELAY_MS,
42
+ VECTOR_FAILURE_BASE_RETRY_DELAY_MS * (2 ** Math.max(0, streak - 1)),
43
+ );
44
+ }
45
+
46
+ function withoutVectorBackfillFailureStreak(payload: Record<string, unknown>): Record<string, unknown> {
47
+ const { vectorBackfillFailureStreak: _streak, ...rest } = payload;
48
+ return rest;
49
+ }
50
+
51
+ function resolveSupersededVectorFailures(projectDir: string, projectId: string): void {
52
+ try {
53
+ new MaintenanceJobStore(projectDir).resolveFailedVectorBackfills(projectId);
54
+ } catch {
55
+ // Health cleanup must never block the live memory path.
56
+ }
57
+ }
58
+
29
59
  function retentionBatchSize(payload: Record<string, unknown>): number {
30
60
  const value = payload.limit;
31
61
  if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_RETENTION_BATCH_SIZE;
@@ -498,23 +528,50 @@ export function createProjectMaintenanceHandler(
498
528
 
499
529
  const { backfillVectorEmbeddings, getVectorStatus } = await import('../memory/observations.js');
500
530
  const before = getVectorStatus(projectId);
501
- if (before.missing === 0) return { action: 'complete' };
531
+ if (before.missing === 0) {
532
+ resolveSupersededVectorFailures(projectDir, projectId);
533
+ return { action: 'complete' };
534
+ }
502
535
 
503
536
  const result = await backfillVectorEmbeddings({
504
537
  projectId,
505
538
  limit: vectorBatchSize(job.payload),
506
539
  });
507
540
  const after = getVectorStatus(projectId);
508
- if (after.missing === 0) return { action: 'complete' };
541
+ if (after.missing === 0) {
542
+ resolveSupersededVectorFailures(projectDir, projectId);
543
+ return { action: 'complete' };
544
+ }
509
545
 
510
546
  if (result.failed > 0 && result.succeeded === 0) {
511
- throw new Error(`vector backfill made no progress (${result.failed}/${result.attempted} failed)`);
547
+ const streak = vectorBackfillFailureStreak(job.payload) + 1;
548
+ return {
549
+ action: 'reschedule',
550
+ status: 'retry',
551
+ delayMs: vectorBackfillRetryDelayMs(streak),
552
+ // This is a recoverable provider/index state, not a terminal job error.
553
+ // Keeping one retry row prevents new MCP processes from creating a storm.
554
+ resetAttempts: true,
555
+ payload: {
556
+ ...job.payload,
557
+ vectorBackfillFailureStreak: streak,
558
+ },
559
+ lastError: result.lastError ?? `vector backfill made no progress (${result.failed}/${result.attempted} failed)`,
560
+ };
512
561
  }
513
562
 
563
+ const priorFailureStreak = vectorBackfillFailureStreak(job.payload);
564
+ const payload = withoutVectorBackfillFailureStreak(job.payload);
514
565
  return {
515
566
  action: 'reschedule',
516
567
  delayMs: result.attempted === 0 ? VECTOR_RETRY_DELAY_MS : 0,
517
568
  resetAttempts: result.succeeded > 0,
569
+ ...(result.succeeded > 0 ? {
570
+ ...(priorFailureStreak > 0 ? { payload } : {}),
571
+ ...(result.failed > 0 && result.lastError
572
+ ? { lastError: result.lastError }
573
+ : priorFailureStreak > 0 ? { clearLastError: true } : {}),
574
+ } : {}),
518
575
  };
519
576
  };
520
577
  }
@@ -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
  // ============================================================