memorix 1.2.8 → 1.2.10

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 (42) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +2 -2
  3. package/dist/cli/index.js +19505 -19136
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/cli/memcode.js +0 -0
  6. package/dist/index.js +7849 -7628
  7. package/dist/index.js.map +1 -1
  8. package/dist/maintenance-jobs-o1rYSFcM.d.ts +36 -0
  9. package/dist/maintenance-runner.d.ts +1 -34
  10. package/dist/maintenance-runner.js +5892 -5692
  11. package/dist/maintenance-runner.js.map +1 -1
  12. package/dist/memcode-runtime/CHANGELOG.md +16 -0
  13. package/dist/sdk.js +7782 -7561
  14. package/dist/sdk.js.map +1 -1
  15. package/dist/vector-backfill-runner.d.ts +31 -0
  16. package/dist/vector-backfill-runner.js +12670 -0
  17. package/dist/vector-backfill-runner.js.map +1 -0
  18. package/docs/AGENT_OPERATOR_PLAYBOOK.md +2 -2
  19. package/docs/DEVELOPMENT.md +7 -1
  20. package/docs/INTEGRATIONS.md +1 -1
  21. package/docs/SETUP.md +6 -4
  22. package/docs/hooks-architecture.md +1 -1
  23. package/package.json +6 -2
  24. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  25. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  26. package/plugins/copilot/memorix/plugin.json +1 -1
  27. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  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/package.json +1 -1
  31. package/server.json +38 -0
  32. package/src/cli/commands/agent-integrations.ts +12 -6
  33. package/src/cli/commands/operator-shared.ts +4 -1
  34. package/src/cli/commands/serve-http.ts +44 -47
  35. package/src/cli/commands/setup.ts +200 -23
  36. package/src/dashboard/server.ts +43 -63
  37. package/src/memory/observations.ts +108 -79
  38. package/src/memory/retention.ts +106 -28
  39. package/src/memory/session.ts +71 -12
  40. package/src/runtime/vector-backfill-runner.ts +144 -0
  41. package/src/search/intent-detector.ts +39 -1
  42. package/src/server.ts +5 -5
@@ -49,6 +49,17 @@ let observations: Observation[] = [];
49
49
  let nextId = 1;
50
50
  let projectDir: string | null = null;
51
51
  let searchIndexPrepared = false;
52
+ export type ObservationEmbeddingWriteMode = 'background' | 'deferred';
53
+
54
+ export interface ObservationRuntimeOptions {
55
+ /** Defer remote/local embedding to a detached worker for short-lived CLI writes. */
56
+ embeddingWriteMode?: ObservationEmbeddingWriteMode;
57
+ /** Git root used by the detached vector worker. */
58
+ projectRoot?: string;
59
+ }
60
+
61
+ let embeddingWriteMode: ObservationEmbeddingWriteMode = 'background';
62
+ let embeddingWorkerProjectRoot: string | undefined;
52
63
 
53
64
  // ── Vector-missing tracking ──────────────────────────────────────
54
65
  // Tracks observation IDs whose async embedding write failed or was skipped.
@@ -106,7 +117,10 @@ function vectorBackfillError(error: unknown): string {
106
117
  return sanitizeCredentials(normalizeEmbeddingFailure(error).message).slice(0, 1_000);
107
118
  }
108
119
 
109
- function queueVectorBackfill(projectId: string): void {
120
+ function queueVectorBackfill(
121
+ projectId: string,
122
+ options: { detachedWorker?: boolean; projectRoot?: string } = {},
123
+ ): void {
110
124
  const dataDir = projectDir;
111
125
  if (!dataDir) return;
112
126
  void import('../runtime/maintenance-jobs.js')
@@ -117,6 +131,19 @@ function queueVectorBackfill(projectId: string): void {
117
131
  dedupeKey: 'vector-backfill',
118
132
  payload: { limit: 12 },
119
133
  });
134
+ if (options.detachedWorker && options.projectRoot) {
135
+ void import('../runtime/vector-backfill-runner.js')
136
+ .then(({ launchDetachedVectorBackfill }) => {
137
+ launchDetachedVectorBackfill({
138
+ projectId,
139
+ projectRoot: options.projectRoot!,
140
+ dataDir,
141
+ });
142
+ })
143
+ .catch(() => {
144
+ // The durable queue remains available to a later MCP session.
145
+ });
146
+ }
120
147
  })
121
148
  .catch(() => {
122
149
  // Memory writes remain durable even when the optional maintenance queue is unavailable.
@@ -199,7 +226,12 @@ async function upgradeVectorSchemaAfterFirstEmbedding(embedding: number[]): Prom
199
226
  * Initialize the observations manager with a project directory.
200
227
  * Auto-initializes the ObservationStore if not already set.
201
228
  */
202
- export async function initObservations(dir: string): Promise<void> {
229
+ export async function initObservations(
230
+ dir: string,
231
+ options: ObservationRuntimeOptions = {},
232
+ ): Promise<void> {
233
+ embeddingWriteMode = options.embeddingWriteMode ?? 'background';
234
+ embeddingWorkerProjectRoot = options.projectRoot;
203
235
  if (projectDir === dir) return;
204
236
  await initObservationStore(dir);
205
237
  const store = getObservationStore();
@@ -210,6 +242,70 @@ export async function initObservations(dir: string): Promise<void> {
210
242
  vectorSchemaUpgradePromise = null;
211
243
  }
212
244
 
245
+ function scheduleObservationEmbedding(input: {
246
+ observationId: number;
247
+ projectId: string;
248
+ searchableText: string;
249
+ doc: MemorixDocument;
250
+ }): void {
251
+ const { observationId, projectId, searchableText, doc } = input;
252
+ vectorMissingIds.add(observationId);
253
+
254
+ // A network fetch keeps Node's event loop alive even when its Promise is not
255
+ // awaited. One-shot CLI commands therefore persist first and let a detached
256
+ // worker consume the same durable queue.
257
+ if (embeddingWriteMode === 'deferred') {
258
+ if (isEmbeddingExplicitlyDisabled()) {
259
+ vectorMissingIds.delete(observationId);
260
+ return;
261
+ }
262
+ queueVectorBackfill(projectId, {
263
+ detachedWorker: true,
264
+ projectRoot: embeddingWorkerProjectRoot,
265
+ });
266
+ return;
267
+ }
268
+
269
+ void generateEmbedding(searchableText).then(async (embedding) => {
270
+ if (embedding) {
271
+ if (!isVectorCompatibleWithCurrentIndex(embedding)) {
272
+ await upgradeVectorSchemaAfterFirstEmbedding(embedding);
273
+ }
274
+ if (!isVectorCompatibleWithCurrentIndex(embedding)) {
275
+ const vectorDimensions = getVectorDimensions();
276
+ console.error(
277
+ `[memorix] Embedding dimension mismatch for obs-${observationId}: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d (kept in backfill queue)`,
278
+ );
279
+ queueVectorBackfill(projectId);
280
+ return;
281
+ }
282
+ try {
283
+ await removeObservation(makeOramaObservationId(projectId, observationId));
284
+ await insertObservation(Object.assign({}, doc, { embedding }));
285
+ vectorMissingIds.delete(observationId);
286
+ } catch {
287
+ console.error(`[memorix] Embedding index update failed for obs-${observationId} (kept in backfill queue)`);
288
+ queueVectorBackfill(projectId);
289
+ }
290
+ } else if (isEmbeddingExplicitlyDisabled()) {
291
+ vectorMissingIds.delete(observationId);
292
+ } else {
293
+ queueVectorBackfill(projectId);
294
+ logEmbeddingFailureOnce(
295
+ 'provider-unavailable',
296
+ `[memorix] Embedding provider unavailable (using BM25 until embedding recovers; queued obs-${observationId} for retry)`,
297
+ );
298
+ }
299
+ }).catch((err) => {
300
+ queueVectorBackfill(projectId);
301
+ const failure = normalizeEmbeddingFailure(err);
302
+ logEmbeddingFailureOnce(
303
+ failure.key,
304
+ `[memorix] Async embedding failed (using BM25 until embedding recovers; queued obs-${observationId} for retry): ${failure.message}`,
305
+ );
306
+ });
307
+ }
308
+
213
309
  /**
214
310
  * Check cross-process freshness and reload if another process has written.
215
311
  *
@@ -507,49 +603,11 @@ export async function storeObservation(input: {
507
603
  queueClaimDerivation(observation);
508
604
  }
509
605
 
510
- // Generate embedding async (fire-and-forget) — never blocks MCP response
511
- // Track in vectorMissingIds until embedding is successfully written.
512
- const obsId = observation.id;
513
- vectorMissingIds.add(obsId);
514
- const searchableText = [input.title, input.narrative, ...(input.facts ?? [])].join(' ');
515
- generateEmbedding(searchableText).then(async (embedding) => {
516
- if (embedding) {
517
- if (!isVectorCompatibleWithCurrentIndex(embedding)) {
518
- await upgradeVectorSchemaAfterFirstEmbedding(embedding);
519
- }
520
- if (!isVectorCompatibleWithCurrentIndex(embedding)) {
521
- const vectorDimensions = getVectorDimensions();
522
- console.error(
523
- `[memorix] Embedding dimension mismatch for obs-${obsId}: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d (kept in backfill queue)`,
524
- );
525
- queueVectorBackfill(input.projectId);
526
- return;
527
- }
528
- try {
529
- const { removeObservation: removeObs } = await import('../store/orama-store.js');
530
- await removeObs(makeOramaObservationId(input.projectId, obsId));
531
- await insertObservation(Object.assign({}, doc, { embedding }));
532
- vectorMissingIds.delete(obsId);
533
- } catch {
534
- console.error(`[memorix] Embedding index update failed for obs-${obsId} (kept in backfill queue)`);
535
- queueVectorBackfill(input.projectId);
536
- }
537
- } else if (isEmbeddingExplicitlyDisabled()) {
538
- vectorMissingIds.delete(obsId);
539
- } else {
540
- queueVectorBackfill(input.projectId);
541
- logEmbeddingFailureOnce(
542
- 'provider-unavailable',
543
- `[memorix] Embedding provider unavailable (using BM25 until embedding recovers; queued obs-${obsId} for retry)`,
544
- );
545
- }
546
- }).catch((err) => {
547
- queueVectorBackfill(input.projectId);
548
- const failure = normalizeEmbeddingFailure(err);
549
- logEmbeddingFailureOnce(
550
- failure.key,
551
- `[memorix] Async embedding failed (using BM25 until embedding recovers; queued obs-${obsId} for retry): ${failure.message}`,
552
- );
606
+ scheduleObservationEmbedding({
607
+ observationId: observation.id,
608
+ projectId: input.projectId,
609
+ searchableText: [input.title, input.narrative, ...(input.facts ?? [])].join(' '),
610
+ doc,
553
611
  });
554
612
 
555
613
  return { observation, upserted: false };
@@ -678,40 +736,11 @@ async function upsertObservation(
678
736
  queueClaimDerivation(existing);
679
737
  }
680
738
 
681
- // Generate embedding async (fire-and-forget) — never blocks MCP response
682
- const searchableText = [input.title, input.narrative, ...(input.facts ?? [])].join(' ');
683
- const obsId = existing.id;
684
- vectorMissingIds.add(obsId);
685
- generateEmbedding(searchableText).then(async (embedding) => {
686
- if (embedding) {
687
- if (!isVectorCompatibleWithCurrentIndex(embedding)) {
688
- await upgradeVectorSchemaAfterFirstEmbedding(embedding);
689
- }
690
- if (!isVectorCompatibleWithCurrentIndex(embedding)) {
691
- queueVectorBackfill(existing.projectId);
692
- return;
693
- }
694
- try {
695
- const { removeObservation: removeObs } = await import('../store/orama-store.js');
696
- await removeObs(makeOramaObservationId(existing.projectId, obsId));
697
- await insertObservation(Object.assign({}, doc, { embedding }));
698
- vectorMissingIds.delete(obsId);
699
- } catch {
700
- // Embedding index update failed — observation still persisted without vector
701
- queueVectorBackfill(existing.projectId);
702
- }
703
- } else if (isEmbeddingExplicitlyDisabled()) {
704
- vectorMissingIds.delete(obsId);
705
- } else {
706
- queueVectorBackfill(existing.projectId);
707
- }
708
- }).catch((err) => {
709
- queueVectorBackfill(existing.projectId);
710
- const failure = normalizeEmbeddingFailure(err);
711
- logEmbeddingFailureOnce(
712
- failure.key,
713
- `[memorix] Async embedding failed (using BM25 until embedding recovers; queued obs-${obsId} for retry): ${failure.message}`,
714
- );
739
+ scheduleObservationEmbedding({
740
+ observationId: existing.id,
741
+ projectId: existing.projectId,
742
+ searchableText: [input.title, input.narrative, ...(input.facts ?? [])].join(' '),
743
+ doc,
715
744
  });
716
745
 
717
746
  return existing;
@@ -319,6 +319,40 @@ export interface RetentionExplanation {
319
319
  summary: string;
320
320
  }
321
321
 
322
+ /** Access metadata maintained outside the persisted observation record. */
323
+ export type ObservationAccessMap = ReadonlyMap<number, {
324
+ accessCount: number;
325
+ lastAccessedAt: string;
326
+ }>;
327
+
328
+ /**
329
+ * One canonical retention result for a persisted observation.
330
+ *
331
+ * Consumers should use this projection instead of recreating a local decay
332
+ * formula. That keeps the UI, HTTP control plane, and archive worker aligned
333
+ * on the same retention state.
334
+ */
335
+ export interface RetentionProjection extends RetentionExplanation {
336
+ relevance: RelevanceScore;
337
+ /** A backwards-compatible 0-10 display value derived from relevance. */
338
+ displayScore: number;
339
+ ageHours: number;
340
+ accessCount: number;
341
+ lastAccessedAt: string;
342
+ }
343
+
344
+ export interface ObservationRetentionProjectionOptions {
345
+ referenceTime?: Date;
346
+ accessMap?: ObservationAccessMap;
347
+ }
348
+
349
+ export interface RetentionProjectionSummary {
350
+ active: number;
351
+ stale: number;
352
+ archiveCandidates: number;
353
+ immune: number;
354
+ }
355
+
322
356
  /**
323
357
  * Produce a structured explanation of why an observation has its current
324
358
  * retention posture. Designed for operator-facing reporting.
@@ -371,27 +405,16 @@ export function explainRetention(
371
405
  };
372
406
  }
373
407
 
374
- // ── Auto-Archive ────────────────────────────────────────────────────
375
-
376
- export interface ArchiveExpiredBatchOptions {
377
- projectId: string;
378
- afterId?: number;
379
- limit?: number;
380
- referenceTime?: Date;
381
- accessMap?: Map<number, { accessCount: number; lastAccessedAt: string }>;
382
- /** Omit only for trusted background maintenance. */
383
- reader?: ObservationReader;
384
- }
385
-
386
- export interface ArchiveExpiredBatchResult {
387
- archived: number;
388
- scanned: number;
389
- nextCursor?: number;
390
- }
391
-
392
- function toRetentionDocument(
408
+ /**
409
+ * Convert a stored observation into the search/retention document shape.
410
+ *
411
+ * Observation fields such as concepts are arrays, while the retrieval schema
412
+ * intentionally stores them as strings. Centralising that conversion prevents
413
+ * callers from accidentally feeding raw observations into retention helpers.
414
+ */
415
+ export function toRetentionDocument(
393
416
  obs: Observation,
394
- accessMap?: Map<number, { accessCount: number; lastAccessedAt: string }>,
417
+ accessMap?: ObservationAccessMap,
395
418
  ): MemorixDocument {
396
419
  const access = accessMap?.get(obs.id);
397
420
  return {
@@ -418,6 +441,62 @@ function toRetentionDocument(
418
441
  };
419
442
  }
420
443
 
444
+ /** Build the canonical retention result used by every Observation consumer. */
445
+ export function projectObservationRetention(
446
+ observation: Observation,
447
+ options: ObservationRetentionProjectionOptions = {},
448
+ ): RetentionProjection {
449
+ const document = toRetentionDocument(observation, options.accessMap);
450
+ const relevance = calculateRelevance(document, options.referenceTime);
451
+ const explanation = explainRetention(document, options.referenceTime);
452
+
453
+ return {
454
+ ...explanation,
455
+ relevance,
456
+ displayScore: Math.round(Math.min(10, relevance.totalScore * 10) * 100) / 100,
457
+ ageHours: Math.round(relevance.ageDays * 24 * 10) / 10,
458
+ accessCount: document.accessCount,
459
+ lastAccessedAt: document.lastAccessedAt,
460
+ };
461
+ }
462
+
463
+ /** Summarise canonical projections without reimplementing retention thresholds. */
464
+ export function summarizeRetentionProjections(
465
+ projections: readonly Pick<RetentionProjection, 'zone' | 'immune'>[],
466
+ ): RetentionProjectionSummary {
467
+ let active = 0;
468
+ let stale = 0;
469
+ let archiveCandidates = 0;
470
+ let immune = 0;
471
+
472
+ for (const projection of projections) {
473
+ if (projection.zone === 'active') active++;
474
+ else if (projection.zone === 'stale') stale++;
475
+ else archiveCandidates++;
476
+ if (projection.immune) immune++;
477
+ }
478
+
479
+ return { active, stale, archiveCandidates, immune };
480
+ }
481
+
482
+ // ── Auto-Archive ────────────────────────────────────────────────────
483
+
484
+ export interface ArchiveExpiredBatchOptions {
485
+ projectId: string;
486
+ afterId?: number;
487
+ limit?: number;
488
+ referenceTime?: Date;
489
+ accessMap?: ObservationAccessMap;
490
+ /** Omit only for trusted background maintenance. */
491
+ reader?: ObservationReader;
492
+ }
493
+
494
+ export interface ArchiveExpiredBatchResult {
495
+ archived: number;
496
+ scanned: number;
497
+ nextCursor?: number;
498
+ }
499
+
421
500
  /**
422
501
  * Archive one bounded page of a project's active memories. The caller owns the
423
502
  * cursor, which makes the operation safe to run from a durable maintenance job.
@@ -438,10 +517,10 @@ export async function archiveExpiredBatch(
438
517
  const scanned = hasMore ? page.slice(0, limit) : page;
439
518
  const candidateIds = scanned
440
519
  .filter((observation) => !options.reader || canManageObservation(observation, options.reader))
441
- .filter((observation) => getRetentionZone(
442
- toRetentionDocument(observation, options.accessMap),
443
- options.referenceTime,
444
- ) === 'archive-candidate')
520
+ .filter((observation) => projectObservationRetention(observation, {
521
+ accessMap: options.accessMap,
522
+ referenceTime: options.referenceTime,
523
+ }).zone === 'archive-candidate')
445
524
  .map((observation) => observation.id);
446
525
 
447
526
  const archived = candidateIds.length === 0
@@ -474,7 +553,7 @@ export async function archiveExpiredBatch(
474
553
  export async function archiveExpired(
475
554
  projectDir: string,
476
555
  referenceTime?: Date,
477
- accessMap?: Map<number, { accessCount: number; lastAccessedAt: string }>,
556
+ accessMap?: ObservationAccessMap,
478
557
  projectId?: string,
479
558
  reader?: ObservationReader,
480
559
  ): Promise<{ archived: number; remaining: number }> {
@@ -512,9 +591,8 @@ export async function archiveExpired(
512
591
  const archivedIds: number[] = [];
513
592
 
514
593
  for (const obs of activeObs) {
515
- const doc = toRetentionDocument(obs, accessMap);
516
- const zone = getRetentionZone(doc, referenceTime);
517
- if (zone === 'archive-candidate') {
594
+ const retention = projectObservationRetention(obs, { accessMap, referenceTime });
595
+ if (retention.zone === 'archive-candidate') {
518
596
  archivedIds.push(obs.id);
519
597
  }
520
598
  }
@@ -21,7 +21,18 @@ import { KnowledgeGraphManager } from './graph.js';
21
21
  import { redactCredentials, sanitizeCredentials } from './secret-filter.js';
22
22
  import { canReadObservation } from './visibility.js';
23
23
 
24
- const PRIORITY_TYPES = new Set(['gotcha', 'decision', 'problem-solution', 'trade-off', 'discovery']);
24
+ // Types eligible for L2 "Key Project Memories" injection. Session requests carry
25
+ // the original goal and change records carry the latest outcome, so both matter
26
+ // when an agent resumes work after a handoff.
27
+ const PRIORITY_TYPES = new Set([
28
+ 'gotcha',
29
+ 'decision',
30
+ 'problem-solution',
31
+ 'trade-off',
32
+ 'discovery',
33
+ 'session-request',
34
+ 'what-changed',
35
+ ]);
25
36
  const RESUME_TYPES = new Set([
26
37
  'gotcha',
27
38
  'decision',
@@ -31,7 +42,6 @@ const RESUME_TYPES = new Set([
31
42
  'how-it-works',
32
43
  'what-changed',
33
44
  'reasoning',
34
- 'session-request',
35
45
  ]);
36
46
  const TYPE_EMOJI: Record<string, string> = {
37
47
  'gotcha': '[DISCOVERY]',
@@ -50,19 +60,24 @@ const TYPE_WEIGHTS: Record<string, number> = {
50
60
  'decision': 5.5,
51
61
  'problem-solution': 5.25,
52
62
  'trade-off': 4.75,
63
+ 'session-request': 4.5,
53
64
  'discovery': 4.25,
65
+ 'what-changed': 4,
54
66
  };
67
+ // Markers that identify a memory as a demo/scratch artifact rather than real
68
+ // working context. These are TITLE conventions — see isNoiseObservation.
69
+ //
70
+ // Deliberately excluded: 验证 / 兼容 / compat / 交接 / handoff. Those are ordinary
71
+ // engineering vocabulary, not noise signals. Matching them silently dropped real
72
+ // memories: any Chinese note about verification or compatibility, and any handoff
73
+ // note that merely named a handoff tool, disappeared from session context with no
74
+ // diagnostic. A noise filter must not veto the words its users normally write.
55
75
  const NOISE_PATTERNS = [
56
76
  /\[测试\]/i,
57
77
  /\[test\]/i,
58
- /验证/i,
59
- /兼容/i,
60
- /\bcompat(?:ibility)?\b/i,
61
78
  /\bdemo\b/i,
62
79
  /展示/i,
63
80
  /全能力/i,
64
- /handoff/i,
65
- /交接/i,
66
81
  /for_memmcp_test/i,
67
82
  /\bbenchmark\b/i,
68
83
  /\bsandbox\b/i,
@@ -180,8 +195,13 @@ function isCommandTrace(obs: Observation): boolean {
180
195
  }
181
196
 
182
197
  function isNoiseObservation(obs: Observation): boolean {
183
- const text = stringifyObservation(obs, false);
184
- return NOISE_PATTERNS.some((pattern) => pattern.test(text)) || isCommandTrace(obs);
198
+ // Match the title only. Demo/scratch markers are a titling convention, so scanning
199
+ // the narrative/facts/concepts produced false positives on real memories that merely
200
+ // *mentioned* one of these words in prose — and because this filter runs before the
201
+ // PRIORITY_TYPES check and drops the observation outright (not a score penalty), a
202
+ // single unlucky word in a long narrative made the whole memory unreachable.
203
+ const title = obs.title ?? '';
204
+ return NOISE_PATTERNS.some((pattern) => pattern.test(title)) || isCommandTrace(obs);
185
205
  }
186
206
 
187
207
  function isSystemSelfObservation(obs: Observation): boolean {
@@ -321,6 +341,42 @@ export async function getSessionResumeBrief(
321
341
  };
322
342
  }
323
343
 
344
+ const AUTO_RESUME_SUMMARY_LIMIT = 480;
345
+ const AUTO_RESUME_TITLE_LIMIT = 180;
346
+
347
+ function compactResumeText(text: string, limit: number): string {
348
+ const normalized = text.replace(/\s+/g, ' ').trim();
349
+ return normalized.length <= limit ? normalized : `${normalized.slice(0, Math.max(0, limit - 3)).trimEnd()}...`;
350
+ }
351
+
352
+ /**
353
+ * Render the bounded automatic delivery form of a resume brief.
354
+ *
355
+ * Full session context remains available through getSessionContext() and
356
+ * individual memory details remain on demand. Automatic session start gets an
357
+ * index, not a transcript, so it cannot crowd out the new task's context.
358
+ */
359
+ export function renderSessionResumeCard(brief: SessionResumeBrief): string {
360
+ if (!brief.previousSession && brief.memories.length === 0) return '';
361
+
362
+ const lines = ['## Memorix Resume'];
363
+ if (brief.previousSession) {
364
+ const agent = brief.previousSession.agent ? ` (${brief.previousSession.agent})` : '';
365
+ lines.push(`Previous session: ${brief.previousSession.id}${agent}`);
366
+ lines.push(compactResumeText(brief.previousSession.summary, AUTO_RESUME_SUMMARY_LIMIT));
367
+ }
368
+
369
+ if (brief.memories.length > 0) {
370
+ lines.push('', 'Relevant memory references:');
371
+ for (const memory of brief.memories) {
372
+ lines.push(`- #${memory.id} [${memory.type}] ${compactResumeText(memory.title, AUTO_RESUME_TITLE_LIMIT)}`);
373
+ }
374
+ lines.push('Read a listed memory only when its title is relevant to the current task.');
375
+ }
376
+
377
+ return lines.join('\n');
378
+ }
379
+
324
380
  export function scoreObservationForSessionContext(obs: Observation, projectTokens: string[], now = Date.now()): number {
325
381
  let score = TYPE_WEIGHTS[obs.type] ?? 1;
326
382
  const text = stringifyObservation(obs);
@@ -384,7 +440,7 @@ export function scoreObservationForSessionContext(obs: Observation, projectToken
384
440
  * so the agent can resume work without re-explaining everything.
385
441
  */
386
442
  export async function startSession(
387
- projectDir: string,
443
+ _projectDir: string,
388
444
  projectId: string,
389
445
  opts?: { sessionId?: string; agent?: string; reader?: ObservationReader },
390
446
  ): Promise<{ session: Session; previousContext: string }> {
@@ -399,8 +455,11 @@ export async function startSession(
399
455
  agent: opts?.agent,
400
456
  };
401
457
 
402
- // Load previous context before creating new session
403
- const previousContext = await getSessionContext(projectDir, projectId, 3, opts?.reader);
458
+ // Automatic delivery stays intentionally small. The explicit context tool can
459
+ // still provide the expanded packet when an agent chooses to inspect it.
460
+ const previousContext = renderSessionResumeCard(
461
+ await getSessionResumeBrief(projectId, undefined, opts?.reader),
462
+ );
404
463
 
405
464
  // Atomic rollover: complete all active sessions for this project's aliases
406
465
  // and insert the new session in a single SQLite transaction.
@@ -0,0 +1,144 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { loadDotenv } from '../config/dotenv-loader.js';
7
+ import { initProjectRoot } from '../config/yaml-loader.js';
8
+ import { prepareSearchIndex, initObservations } from '../memory/observations.js';
9
+ import { sanitizeCredentials } from '../memory/secret-filter.js';
10
+ import { initObservationStore } from '../store/obs-store.js';
11
+ import { getDeferredCachedVectorHydration } from '../store/orama-store.js';
12
+ import { closeAllDatabases } from '../store/sqlite-db.js';
13
+ import { MaintenanceJobStore, MaintenanceJobWorker } from './maintenance-jobs.js';
14
+ import { createProjectMaintenanceHandler } from './project-maintenance.js';
15
+
16
+ export interface VectorBackfillRequest {
17
+ projectId: string;
18
+ projectRoot: string;
19
+ dataDir: string;
20
+ }
21
+
22
+ export interface VectorBackfillLauncherOptions {
23
+ runnerPath?: string;
24
+ exists?: (path: string) => boolean;
25
+ spawn?: typeof spawn;
26
+ }
27
+
28
+ function isNonEmptyString(value: unknown): value is string {
29
+ return typeof value === 'string' && value.trim().length > 0;
30
+ }
31
+
32
+ /** Resolve the standalone worker next to either the library or CLI bundle. */
33
+ export function resolveVectorBackfillRunnerPath(moduleUrl = import.meta.url): string {
34
+ const moduleDir = path.dirname(fileURLToPath(moduleUrl));
35
+ const distDir = path.basename(moduleDir) === 'cli'
36
+ ? path.dirname(moduleDir)
37
+ : path.basename(moduleDir) === 'runtime' && path.basename(path.dirname(moduleDir)) === 'src'
38
+ ? path.join(path.dirname(path.dirname(moduleDir)), 'dist')
39
+ : moduleDir;
40
+ return path.join(distDir, 'vector-backfill-runner.js');
41
+ }
42
+
43
+ /** Parse the internal request passed from a short-lived CLI process. */
44
+ export function parseVectorBackfillRequest(raw: string): VectorBackfillRequest {
45
+ let value: unknown;
46
+ try {
47
+ value = JSON.parse(raw);
48
+ } catch {
49
+ throw new Error('Vector backfill runner received invalid JSON input');
50
+ }
51
+
52
+ if (
53
+ !value ||
54
+ typeof value !== 'object' ||
55
+ !isNonEmptyString((value as VectorBackfillRequest).projectId) ||
56
+ !isNonEmptyString((value as VectorBackfillRequest).projectRoot) ||
57
+ !isNonEmptyString((value as VectorBackfillRequest).dataDir) ||
58
+ !path.isAbsolute((value as VectorBackfillRequest).projectRoot) ||
59
+ !path.isAbsolute((value as VectorBackfillRequest).dataDir)
60
+ ) {
61
+ throw new Error('Vector backfill runner received an invalid request');
62
+ }
63
+
64
+ return {
65
+ projectId: (value as VectorBackfillRequest).projectId,
66
+ projectRoot: (value as VectorBackfillRequest).projectRoot,
67
+ dataDir: (value as VectorBackfillRequest).dataDir,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Start a detached one-shot worker. The caller has already persisted the
73
+ * observation and durable vector job, so failure to start is recoverable by a
74
+ * later MCP or control-plane session.
75
+ */
76
+ export function launchDetachedVectorBackfill(
77
+ request: VectorBackfillRequest,
78
+ options: VectorBackfillLauncherOptions = {},
79
+ ): boolean {
80
+ const runnerPath = options.runnerPath ?? resolveVectorBackfillRunnerPath();
81
+ const exists = options.exists ?? existsSync;
82
+ if (!exists(runnerPath)) return false;
83
+
84
+ try {
85
+ const child = (options.spawn ?? spawn)(process.execPath, [runnerPath], {
86
+ cwd: request.projectRoot,
87
+ detached: true,
88
+ stdio: 'ignore',
89
+ // The request contains only local project metadata, never credentials.
90
+ // Environment transport avoids a live stdin pipe keeping the CLI alive.
91
+ env: { ...process.env, MEMORIX_VECTOR_BACKFILL_REQUEST: JSON.stringify(request) },
92
+ windowsHide: true,
93
+ }) as ChildProcess;
94
+ child.once?.('error', () => {});
95
+ child.unref();
96
+ return true;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ /** Run one durable vector-backfill job without sharing the CLI event loop. */
103
+ export async function executeVectorBackfill(request: VectorBackfillRequest) {
104
+ initProjectRoot(request.projectRoot);
105
+ loadDotenv(request.projectRoot);
106
+ await initObservationStore(request.dataDir);
107
+ await initObservations(request.dataDir);
108
+ await prepareSearchIndex();
109
+ await getDeferredCachedVectorHydration()?.catch(() => {});
110
+
111
+ const worker = new MaintenanceJobWorker(
112
+ new MaintenanceJobStore(request.dataDir),
113
+ createProjectMaintenanceHandler(request.projectId, request.dataDir, request.projectRoot),
114
+ { projectId: request.projectId, kinds: ['vector-backfill'] },
115
+ );
116
+ return worker.runOnce();
117
+ }
118
+
119
+ async function readStdin(): Promise<string> {
120
+ return new Promise((resolve, reject) => {
121
+ let raw = '';
122
+ process.stdin.setEncoding('utf8');
123
+ process.stdin.on('data', (chunk) => { raw += chunk; });
124
+ process.stdin.once('error', reject);
125
+ process.stdin.once('end', () => resolve(raw));
126
+ });
127
+ }
128
+
129
+ export async function main(): Promise<void> {
130
+ try {
131
+ const raw = process.env.MEMORIX_VECTOR_BACKFILL_REQUEST ?? await readStdin();
132
+ await executeVectorBackfill(parseVectorBackfillRequest(raw));
133
+ } catch (error) {
134
+ const detail = sanitizeCredentials(error instanceof Error ? error.message : String(error));
135
+ process.stderr.write(`[memorix] vector backfill worker failed: ${detail}\n`);
136
+ process.exitCode = 1;
137
+ } finally {
138
+ closeAllDatabases();
139
+ }
140
+ }
141
+
142
+ if (process.argv[1] && process.argv[1].endsWith('vector-backfill-runner.js')) {
143
+ void main();
144
+ }