memorix 1.2.1 → 1.2.3

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 (90) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +14 -2
  3. package/README.zh-CN.md +14 -2
  4. package/dist/cli/index.js +15424 -13780
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/index.js +1337 -536
  7. package/dist/index.js.map +1 -1
  8. package/dist/maintenance-runner.d.ts +1 -1
  9. package/dist/maintenance-runner.js +8458 -8087
  10. package/dist/maintenance-runner.js.map +1 -1
  11. package/dist/memcode-runtime/CHANGELOG.md +23 -0
  12. package/dist/sdk.d.ts +7 -2
  13. package/dist/sdk.js +1365 -542
  14. package/dist/sdk.js.map +1 -1
  15. package/dist/types.d.ts +49 -1
  16. package/dist/types.js.map +1 -1
  17. package/docs/1.2.2-MEMORY-CONTROL-PLANE.md +434 -0
  18. package/docs/AGENT_OPERATOR_PLAYBOOK.md +4 -0
  19. package/docs/API_REFERENCE.md +24 -4
  20. package/docs/README.md +1 -1
  21. package/docs/dev-log/progress.txt +101 -11
  22. package/package.json +1 -1
  23. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  24. package/src/cli/command-guide.ts +192 -0
  25. package/src/cli/commands/audit.ts +9 -4
  26. package/src/cli/commands/cleanup.ts +5 -1
  27. package/src/cli/commands/codegraph.ts +15 -5
  28. package/src/cli/commands/context.ts +3 -2
  29. package/src/cli/commands/doctor.ts +4 -2
  30. package/src/cli/commands/explain.ts +9 -3
  31. package/src/cli/commands/handoff.ts +21 -7
  32. package/src/cli/commands/identity.ts +116 -0
  33. package/src/cli/commands/ingest-image.ts +5 -3
  34. package/src/cli/commands/lock.ts +11 -10
  35. package/src/cli/commands/memory.ts +58 -21
  36. package/src/cli/commands/message.ts +19 -14
  37. package/src/cli/commands/operator-shared.ts +98 -3
  38. package/src/cli/commands/poll.ts +16 -6
  39. package/src/cli/commands/reasoning.ts +17 -3
  40. package/src/cli/commands/retention.ts +9 -4
  41. package/src/cli/commands/serve-http.ts +8 -2
  42. package/src/cli/commands/session.ts +44 -10
  43. package/src/cli/commands/skills.ts +10 -5
  44. package/src/cli/commands/status.ts +4 -3
  45. package/src/cli/commands/task.ts +26 -17
  46. package/src/cli/commands/team.ts +14 -10
  47. package/src/cli/commands/transfer.ts +63 -10
  48. package/src/cli/identity.ts +89 -0
  49. package/src/cli/index.ts +96 -19
  50. package/src/cli/invocation.ts +115 -0
  51. package/src/cli/tui/chat-service.ts +41 -18
  52. package/src/cli/tui/data.ts +23 -44
  53. package/src/cli/tui/operator-context.ts +60 -0
  54. package/src/cli/tui/session-service.ts +3 -2
  55. package/src/cli/tui/views/MemoryView.tsx +10 -8
  56. package/src/codegraph/auto-context.ts +31 -2
  57. package/src/codegraph/context-pack.ts +1 -0
  58. package/src/codegraph/project-context.ts +2 -0
  59. package/src/compact/engine.ts +26 -10
  60. package/src/compact/index-format.ts +25 -2
  61. package/src/dashboard/server.ts +46 -9
  62. package/src/hooks/admission.ts +117 -0
  63. package/src/hooks/handler.ts +98 -91
  64. package/src/knowledge/context-assembly.ts +97 -0
  65. package/src/knowledge/workset.ts +179 -10
  66. package/src/memory/admission.ts +57 -0
  67. package/src/memory/consolidation.ts +13 -2
  68. package/src/memory/disclosure-policy.ts +6 -1
  69. package/src/memory/export-import.ts +11 -3
  70. package/src/memory/graph-context.ts +8 -2
  71. package/src/memory/observations.ts +162 -4
  72. package/src/memory/quality-audit.ts +2 -0
  73. package/src/memory/retention.ts +22 -2
  74. package/src/memory/session.ts +29 -11
  75. package/src/memory/visibility.ts +80 -0
  76. package/src/orchestrate/memorix-bridge.ts +38 -0
  77. package/src/runtime/control-plane-maintenance.ts +1 -0
  78. package/src/runtime/isolated-maintenance.ts +1 -0
  79. package/src/runtime/lifecycle.ts +18 -0
  80. package/src/runtime/maintenance-jobs.ts +1 -0
  81. package/src/runtime/maintenance-runner.ts +2 -0
  82. package/src/runtime/project-maintenance.ts +89 -0
  83. package/src/sdk.ts +35 -5
  84. package/src/server.ts +267 -83
  85. package/src/store/orama-store.ts +61 -6
  86. package/src/store/sqlite-db.ts +23 -1
  87. package/src/store/sqlite-store.ts +12 -2
  88. package/src/team/handoff.ts +7 -0
  89. package/src/types.ts +51 -0
  90. package/src/wiki/generator.ts +2 -0
@@ -2,15 +2,21 @@
2
2
  * Hook Handler
3
3
  *
4
4
  * Unified entry point for all agent hooks.
5
- * Architecture: Normalize → Classify → Policy → Store → Respond
5
+ * Architecture: Normalize → Classify → Admit → Store → Respond
6
6
  *
7
7
  * Design principles (inspired by claude-mem + mcp-memory-service):
8
- * - Store-first: capture generously, filter at read time
8
+ * - Candidate-first: automatic capture is never durable context by default
9
9
  * - Tool Taxonomy: declarative policies per tool category
10
10
  * - Pattern = classification only: determines observation type, not storage
11
11
  */
12
12
 
13
+ import { createHash } from 'node:crypto';
13
14
  import type { ObservationType } from '../types.js';
15
+ import {
16
+ assessHookAdmission,
17
+ type HookAdmissionDecision,
18
+ type HookCaptureCategory,
19
+ } from './admission.js';
14
20
  import { normalizeHookInput } from './normalizer.js';
15
21
  import { detectBestPattern, patternToObservationType } from './pattern-detector.js';
16
22
  import { isSignificantKnowledge, isRetrievedResult, isTrivialCommand } from './significance-filter.js';
@@ -35,6 +41,11 @@ const MIN_PROMPT_LENGTH = 20;
35
41
  /** Max content length (truncate beyond this) */
36
42
  const MAX_CONTENT_LENGTH = 4000;
37
43
 
44
+ function deriveHookActorId(input: NormalizedHookInput): string {
45
+ const material = `${input.agent ?? 'unknown'}\u0000${input.sessionId ?? 'unknown'}`;
46
+ return `hook:${createHash('sha256').update(material).digest('hex').slice(0, 24)}`;
47
+ }
48
+
38
49
  /** Truly trivial commands — standalone navigation/inspection only */
39
50
  const NOISE_COMMANDS = [
40
51
  /^(ls|dir|cd|pwd|echo|cat|type|head|tail|wc|which|where|whoami)(\s|$)/i,
@@ -49,7 +60,7 @@ const NOISE_COMMANDS = [
49
60
  // ─── Tool Taxonomy ───
50
61
 
51
62
  /** Tool categories for storage policy */
52
- type ToolCategory = 'file_modify' | 'file_read' | 'command' | 'search' | 'memorix_internal' | 'unknown';
63
+ type ToolCategory = HookCaptureCategory;
53
64
 
54
65
  /** Storage policy per tool category */
55
66
  interface StoragePolicy {
@@ -233,7 +244,12 @@ function generateTitle(input: NormalizedHookInput, patternType: string): string
233
244
  return `Activity (${patternType})`;
234
245
  }
235
246
 
236
- function buildObservation(input: NormalizedHookInput, content: string, category: ToolCategory) {
247
+ function buildObservation(
248
+ input: NormalizedHookInput,
249
+ content: string,
250
+ category: ToolCategory,
251
+ admission?: Extract<HookAdmissionDecision, { action: 'store' }>,
252
+ ) {
237
253
  const pattern = detectBestPattern(content);
238
254
  const policy = STORAGE_POLICY[category] ?? STORAGE_POLICY.unknown;
239
255
  const fallbackType = input.filePath ? 'what-changed' : policy.defaultType;
@@ -252,6 +268,15 @@ function buildObservation(input: NormalizedHookInput, content: string, category:
252
268
  ],
253
269
  concepts: pattern?.matchedKeywords ?? [],
254
270
  filesModified: input.filePath ? [input.filePath] : [],
271
+ ...(admission ? {
272
+ valueCategory: admission.valueCategory,
273
+ admissionState: admission.admissionState,
274
+ admissionReason: admission.admissionReason,
275
+ // Automatic capture is private until current code can qualify it for
276
+ // shared project delivery. The opaque actor is stable within a hook session.
277
+ visibility: 'personal' as const,
278
+ createdByAgentId: deriveHookActorId(input),
279
+ } : {}),
255
280
  };
256
281
  }
257
282
 
@@ -281,7 +306,9 @@ async function handleSessionStart(input: NormalizedHookInput): Promise<{
281
306
  const { initMiniSkillStore } = await import('../store/mini-skill-store.js');
282
307
  const { initSessionStore } = await import('../store/session-store.js');
283
308
  const { initAliasRegistry, registerAlias } = await import('../project/aliases.js');
309
+ const { MaintenanceTargetStore } = await import('../runtime/maintenance-targets.js');
284
310
  const { buildAutoProjectContext, formatAutoProjectContextPrompt } = await import('../codegraph/auto-context.js');
311
+ const { filterReadableObservations } = await import('../memory/visibility.js');
285
312
 
286
313
  const rawProject = detectProject(input.cwd || process.cwd());
287
314
  if (!rawProject) throw new Error('No .git found');
@@ -289,10 +316,18 @@ async function handleSessionStart(input: NormalizedHookInput): Promise<{
289
316
 
290
317
  initAliasRegistry(dataDir);
291
318
  const canonicalId = await registerAlias(rawProject);
319
+ new MaintenanceTargetStore(dataDir).register({
320
+ projectId: canonicalId,
321
+ projectRoot: rawProject.rootPath,
322
+ dataDir,
323
+ });
292
324
  await initObservationStore(dataDir);
293
325
  await initMiniSkillStore(dataDir);
294
326
  await initSessionStore(dataDir);
295
- const activeObservations = await getStore().loadByProject(canonicalId, { status: 'active' });
327
+ const activeObservations = filterReadableObservations(
328
+ await getStore().loadByProject(canonicalId, { status: 'active' }),
329
+ { projectId: canonicalId },
330
+ );
296
331
  const context = await buildAutoProjectContext({
297
332
  project: { ...rawProject, id: canonicalId },
298
333
  dataDir,
@@ -306,6 +341,7 @@ async function handleSessionStart(input: NormalizedHookInput): Promise<{
306
341
  maxFiles: 5_000,
307
342
  });
308
343
  }),
344
+ deliveryTarget: 'hook-session-start',
309
345
  });
310
346
  contextSummary = `\n\n${formatAutoProjectContextPrompt(context)}`;
311
347
  } catch (sessErr) {
@@ -352,8 +388,17 @@ async function handleHookEventCore(input: NormalizedHookInput): Promise<{
352
388
  if (endContent.length < 50) {
353
389
  return { observation: null, output: defaultOutput };
354
390
  }
391
+ const draft = buildObservation(input, endContent, 'unknown');
392
+ const admission = assessHookAdmission({
393
+ hook: input,
394
+ category: 'unknown',
395
+ content: endContent,
396
+ observationType: draft.type,
397
+ });
355
398
  return {
356
- observation: buildObservation(input, endContent, 'unknown'),
399
+ observation: admission.action === 'store'
400
+ ? buildObservation(input, endContent, 'unknown', admission)
401
+ : null,
357
402
  output: defaultOutput,
358
403
  };
359
404
  }
@@ -425,8 +470,17 @@ async function handleHookEventCore(input: NormalizedHookInput): Promise<{
425
470
  }
426
471
  markTriggered(cooldownKey);
427
472
 
473
+ const draft = buildObservation(input, content, category);
474
+ const admission = assessHookAdmission({
475
+ hook: input,
476
+ category,
477
+ content,
478
+ observationType: draft.type,
479
+ });
428
480
  return {
429
- observation: buildObservation(input, content, category),
481
+ observation: admission.action === 'store'
482
+ ? buildObservation(input, content, category, admission)
483
+ : null,
430
484
  output: defaultOutput,
431
485
  };
432
486
  }
@@ -440,17 +494,24 @@ async function queueCodegraphRefreshForMutation(input: NormalizedHookInput): Pro
440
494
  { getProjectDataDir },
441
495
  { initAliasRegistry, registerAlias },
442
496
  { enqueueCodegraphRefresh },
497
+ { MaintenanceTargetStore },
443
498
  ] = await Promise.all([
444
499
  import('../project/detector.js'),
445
500
  import('../store/persistence.js'),
446
501
  import('../project/aliases.js'),
447
502
  import('../runtime/lifecycle.js'),
503
+ import('../runtime/maintenance-targets.js'),
448
504
  ]);
449
505
  const project = detectProject(input.cwd || process.cwd());
450
506
  if (!project) return;
451
507
  const dataDir = await getProjectDataDir(project.id);
452
508
  initAliasRegistry(dataDir);
453
509
  const projectId = await registerAlias(project);
510
+ new MaintenanceTargetStore(dataDir).register({
511
+ projectId,
512
+ projectRoot: project.rootPath,
513
+ dataDir,
514
+ });
454
515
  enqueueCodegraphRefresh({
455
516
  dataDir,
456
517
  projectId,
@@ -465,11 +526,25 @@ async function queueCodegraphRefreshForMutation(input: NormalizedHookInput): Pro
465
526
  export async function handleHookEvent(input: NormalizedHookInput): Promise<{
466
527
  observation: ReturnType<typeof buildObservation> | null;
467
528
  output: HookOutput;
529
+ }>;
530
+ export async function handleHookEvent(input: NormalizedHookInput, options: {
531
+ deferMaintenance?: boolean;
532
+ }): Promise<{
533
+ observation: ReturnType<typeof buildObservation> | null;
534
+ output: HookOutput;
535
+ }>;
536
+ export async function handleHookEvent(input: NormalizedHookInput, options: {
537
+ deferMaintenance?: boolean;
538
+ } = {}): Promise<{
539
+ observation: ReturnType<typeof buildObservation> | null;
540
+ output: HookOutput;
468
541
  }> {
469
542
  try {
470
543
  return await handleHookEventCore(input);
471
544
  } finally {
472
- await queueCodegraphRefreshForMutation(input);
545
+ // The CLI persists an automatic observation after this function returns.
546
+ // It defers scheduling so a fast worker never scans before that write.
547
+ if (!options.deferMaintenance) await queueCodegraphRefreshForMutation(input);
473
548
  }
474
549
  }
475
550
 
@@ -570,7 +645,7 @@ export async function runHook(agentOverride?: string, eventOverride?: string): P
570
645
  }
571
646
 
572
647
  const input = normalizeHookInput(payload);
573
- const { observation, output } = await handleHookEvent(input);
648
+ const { observation, output } = await handleHookEvent(input, { deferMaintenance: true });
574
649
 
575
650
  if (observation) {
576
651
  try {
@@ -581,6 +656,7 @@ export async function runHook(agentOverride?: string, eventOverride?: string): P
581
656
  const { detectProject } = await import('../project/detector.js');
582
657
  const { getProjectDataDir } = await import('../store/persistence.js');
583
658
  const { initAliasRegistry, registerAlias } = await import('../project/aliases.js');
659
+ const { MaintenanceTargetStore } = await import('../runtime/maintenance-targets.js');
584
660
 
585
661
  const rawProject = detectProject(input.cwd || process.cwd());
586
662
  if (!rawProject) throw new Error('No .git found');
@@ -590,94 +666,20 @@ export async function runHook(agentOverride?: string, eventOverride?: string): P
590
666
  initAliasRegistry(dataDir);
591
667
  const canonicalId = await registerAlias(rawProject);
592
668
  const projectId = canonicalId;
669
+ new MaintenanceTargetStore(dataDir).register({
670
+ projectId,
671
+ projectRoot: rawProject.rootPath,
672
+ dataDir,
673
+ });
593
674
 
594
675
  await initObservationStore(dataDir);
595
676
  await initMSStore(dataDir);
596
677
  await initSessStore(dataDir);
597
678
  await initObservations(dataDir);
598
679
  await storeObservation({ ...observation, projectId, sourceDetail: 'hook' });
599
-
600
- // Shadow mode: Formation Pipeline metrics (fire-and-forget, never blocks)
601
- try {
602
- const { runFormation } = await import('../memory/formation/index.js');
603
- const formationMode = (process.env.MEMORIX_FORMATION_MODE as 'shadow' | 'active' | 'fallback') || 'shadow';
604
- const samplingRate = parseFloat(process.env.MEMORIX_FORMATION_HOOKS_SAMPLING_RATE || '0.1');
605
- const shouldSample = Math.random() < samplingRate;
606
-
607
- if (shouldSample) {
608
- const { withFreshIndex } = await import('../memory/freshness.js');
609
- const { getAllObservations } = await import('../memory/observations.js');
610
- await withFreshIndex(() => getAllObservations());
611
- }
612
-
613
- // In hooks, shadow mode by default for performance
614
- // Sampling rate controls how often we run full resolve (expensive)
615
- const searchFn = shouldSample
616
- ? async (q: string, limit: number, pid: string) => {
617
- const { compactSearch, compactDetail } = await import('../compact/engine.js');
618
- const result = await compactSearch({ query: q, limit, projectId: pid, status: 'active' });
619
- if (result.entries.length === 0) return [];
620
- const details = await compactDetail(result.entries.map(e => e.id));
621
- return details.documents.map((d, i) => ({
622
- id: Number(d.id.replace('obs-', '')),
623
- observationId: d.observationId,
624
- title: d.title,
625
- narrative: d.narrative,
626
- facts: d.facts,
627
- entityName: d.entityName,
628
- type: d.type,
629
- score: result.entries[i]?.score ?? 0,
630
- }));
631
- }
632
- : async () => []; // Skip search for speed (shadow mode)
633
-
634
- const getObsFn = shouldSample
635
- ? (id: number) => {
636
- const { getObservation } = require('../memory/observations.js');
637
- const o = getObservation(id);
638
- if (!o) return null;
639
- return {
640
- id: o.id,
641
- entityName: o.entityName,
642
- type: o.type,
643
- title: o.title,
644
- narrative: o.narrative,
645
- facts: o.facts,
646
- topicKey: o.topicKey,
647
- };
648
- }
649
- : () => null;
650
-
651
- const getEntityNamesFn = shouldSample
652
- ? () => {
653
- const { graphManager } = require('../memory/graph.js');
654
- return graphManager.getEntityNames();
655
- }
656
- : () => [];
657
-
658
- runFormation({
659
- entityName: observation.entityName,
660
- type: observation.type,
661
- title: observation.title,
662
- narrative: observation.narrative,
663
- facts: observation.facts,
664
- projectId,
665
- source: 'hook' as const,
666
- }, {
667
- mode: formationMode,
668
- useLLM: false,
669
- minValueScore: 0.3,
670
- hooksSamplingRate: samplingRate,
671
- searchMemories: searchFn,
672
- getObservation: getObsFn,
673
- getEntityNames: getEntityNamesFn,
674
- }).catch(() => {});
675
- } catch { /* Formation is optional — never break hooks */ }
676
-
677
- // Feedback: tell the agent what was saved
678
- const emoji = TYPE_EMOJI[observation.type] ?? '[PLAN]';
679
- output.systemMessage = (output.systemMessage ?? '') +
680
- `\n${emoji} Memorix saved: ${observation.title} [${observation.type}]`;
680
+ // Automatic capture is deliberately quiet. Candidate state and later
681
+ // qualification are visible through Memorix inspection, not injected as
682
+ // a stream of status messages into the host agent's context.
681
683
  } catch (storeErr) {
682
684
  // Diagnostic log — hooks must never break the agent, but silent
683
685
  // swallow makes end-to-end debugging impossible.
@@ -685,6 +687,11 @@ export async function runHook(agentOverride?: string, eventOverride?: string): P
685
687
  }
686
688
  }
687
689
 
690
+ // A candidate must be durable before a Code Memory refresh can qualify it.
691
+ // Keep direct handleHookEvent() backward-compatible, but make the real CLI
692
+ // hook path explicitly capture first and schedule second.
693
+ await queueCodegraphRefreshForMutation(input);
694
+
688
695
  // Build hookSpecificOutput — Claude Code only supports it for 3 event types:
689
696
  // PreToolUse, UserPromptSubmit, PostToolUse
690
697
  // Other events (SessionStart, Stop, PreCompact) must NOT include hookSpecificOutput.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared metadata contract for every bounded context delivery surface.
3
+ * The rendered Workset stays agent-facing; this receipt is for JSON, explain,
4
+ * diagnostics, and future handoff adapters.
5
+ */
6
+ export type ContextDeliveryTarget =
7
+ | 'project-context'
8
+ | 'context-pack'
9
+ | 'hook-session-start'
10
+ | 'session-handoff';
11
+
12
+ export type ContextCandidateKind =
13
+ | 'task'
14
+ | 'current-fact'
15
+ | 'code-state'
16
+ | 'semantic-code'
17
+ | 'start-here'
18
+ | 'memory'
19
+ | 'claim'
20
+ | 'knowledge-page'
21
+ | 'workflow'
22
+ | 'verification'
23
+ | 'caution';
24
+
25
+ export type ContextCandidateFreshness = 'current' | 'suspect' | 'stale' | 'unknown';
26
+ export type ContextCandidateTrust = 'source-backed' | 'derived' | 'historical';
27
+
28
+ export interface ContextReceiptSelection {
29
+ kind: ContextCandidateKind;
30
+ /** Stable source id when the candidate has one; never raw content. */
31
+ id?: string;
32
+ reason: string;
33
+ freshness?: ContextCandidateFreshness;
34
+ trust?: ContextCandidateTrust;
35
+ }
36
+
37
+ export interface ContextReceiptOmission {
38
+ kind: ContextCandidateKind;
39
+ reason: 'token-budget' | 'hidden-by-task-lens' | 'unavailable';
40
+ count: number;
41
+ }
42
+
43
+ export interface ContextReceipt {
44
+ version: '1.2.2';
45
+ target: ContextDeliveryTarget;
46
+ elapsedMs: number;
47
+ budget: {
48
+ maxTokens: number;
49
+ tokenCount: number;
50
+ };
51
+ selected: ContextReceiptSelection[];
52
+ omitted: ContextReceiptOmission[];
53
+ scheduledActions: string[];
54
+ }
55
+
56
+ function displayTarget(target: ContextDeliveryTarget): string {
57
+ switch (target) {
58
+ case 'project-context': return 'Project Context';
59
+ case 'context-pack': return 'Context Pack';
60
+ case 'hook-session-start': return 'SessionStart hook';
61
+ case 'session-handoff': return 'session handoff';
62
+ }
63
+ }
64
+
65
+ /** A human-facing diagnostic formatter. Do not append this to agent context. */
66
+ export function formatContextReceipt(receipt: ContextReceipt): string {
67
+ const lines = [
68
+ 'Context delivery receipt',
69
+ `- Target: ${displayTarget(receipt.target)}`,
70
+ `- Budget: ${receipt.budget.tokenCount}/${receipt.budget.maxTokens} tokens`,
71
+ `- Assembly: ${receipt.elapsedMs} ms`,
72
+ `- Selected: ${receipt.selected.length} item(s)`,
73
+ ];
74
+
75
+ if (receipt.selected.length > 0) {
76
+ lines.push('', 'Selected evidence');
77
+ for (const item of receipt.selected.slice(0, 20)) {
78
+ const id = item.id ? ` ${item.id}` : '';
79
+ const qualifiers = [item.trust, item.freshness].filter(Boolean).join(', ');
80
+ lines.push(`- ${item.kind}${id}: ${item.reason}${qualifiers ? ` (${qualifiers})` : ''}`);
81
+ }
82
+ }
83
+
84
+ if (receipt.omitted.length > 0) {
85
+ lines.push('', 'Withheld by budget');
86
+ for (const item of receipt.omitted) {
87
+ lines.push(`- ${item.kind}: ${item.count} item(s) (${item.reason})`);
88
+ }
89
+ }
90
+
91
+ if (receipt.scheduledActions.length > 0) {
92
+ lines.push('', 'Scheduled follow-up');
93
+ for (const action of receipt.scheduledActions) lines.push(`- ${action}`);
94
+ }
95
+
96
+ return lines.join('\n');
97
+ }