@jbarket/loomai 0.5.0 → 0.6.0

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 (94) hide show
  1. package/CHANGELOG.md +114 -1
  2. package/README.md +97 -0
  3. package/dist/backends/dedup.d.ts +23 -0
  4. package/dist/backends/dedup.js +100 -0
  5. package/dist/backends/embedding-runtime.js +38 -9
  6. package/dist/backends/episodes.js +1 -1
  7. package/dist/backends/fastembed.js +30 -4
  8. package/dist/backends/migrations.js +200 -0
  9. package/dist/backends/mmr.d.ts +8 -0
  10. package/dist/backends/mmr.js +18 -4
  11. package/dist/backends/people.d.ts +192 -0
  12. package/dist/backends/people.js +824 -0
  13. package/dist/backends/proposals.js +1 -1
  14. package/dist/backends/recorder-health.d.ts +34 -0
  15. package/dist/backends/recorder-health.js +117 -0
  16. package/dist/backends/salience.d.ts +39 -0
  17. package/dist/backends/salience.js +52 -4
  18. package/dist/backends/scope.d.ts +56 -0
  19. package/dist/backends/scope.js +75 -0
  20. package/dist/backends/sqlite-knowledge.d.ts +24 -4
  21. package/dist/backends/sqlite-knowledge.js +253 -91
  22. package/dist/backends/sqlite-vec.d.ts +32 -1
  23. package/dist/backends/sqlite-vec.js +394 -75
  24. package/dist/backends/types.d.ts +203 -3
  25. package/dist/categories.d.ts +2 -0
  26. package/dist/categories.js +2 -0
  27. package/dist/cli/args.d.ts +16 -1
  28. package/dist/cli/args.js +45 -1
  29. package/dist/cli/index.d.ts +7 -0
  30. package/dist/cli/index.js +57 -3
  31. package/dist/cli/knowledge.js +4 -1
  32. package/dist/cli/memory.js +2 -2
  33. package/dist/cli/person.d.ts +2 -0
  34. package/dist/cli/person.js +116 -0
  35. package/dist/cli/recall.js +2 -1
  36. package/dist/cli/remember.js +10 -0
  37. package/dist/cli/remote.d.ts +36 -0
  38. package/dist/cli/remote.js +173 -0
  39. package/dist/cli/serve.d.ts +7 -0
  40. package/dist/cli/serve.js +94 -5
  41. package/dist/cli/subcommands.d.ts +1 -1
  42. package/dist/cli/subcommands.js +1 -1
  43. package/dist/cli/update.js +30 -11
  44. package/dist/clients.js +7 -4
  45. package/dist/config.d.ts +14 -0
  46. package/dist/config.js +25 -2
  47. package/dist/index.js +22 -7
  48. package/dist/injection/harnesses.js +9 -3
  49. package/dist/install/harnesses.js +7 -1
  50. package/dist/server.d.ts +8 -1
  51. package/dist/server.js +461 -374
  52. package/dist/tools/dossier.js +5 -1
  53. package/dist/tools/eurorack-import.js +3 -0
  54. package/dist/tools/identity.d.ts +2 -0
  55. package/dist/tools/identity.js +35 -2
  56. package/dist/tools/knowledge-history.d.ts +6 -1
  57. package/dist/tools/knowledge-history.js +26 -8
  58. package/dist/tools/knowledge-maintain.d.ts +5 -0
  59. package/dist/tools/knowledge-maintain.js +92 -5
  60. package/dist/tools/knowledge-recall.d.ts +7 -1
  61. package/dist/tools/knowledge-recall.js +53 -3
  62. package/dist/tools/knowledge-verify.d.ts +11 -1
  63. package/dist/tools/knowledge-verify.js +17 -4
  64. package/dist/tools/knowledge-write.d.ts +8 -1
  65. package/dist/tools/knowledge-write.js +27 -9
  66. package/dist/tools/memory-history.d.ts +5 -0
  67. package/dist/tools/memory-history.js +52 -0
  68. package/dist/tools/memory-list.js +2 -1
  69. package/dist/tools/memory-merge.d.ts +2 -0
  70. package/dist/tools/memory-merge.js +40 -0
  71. package/dist/tools/memory-revision-restore.d.ts +5 -0
  72. package/dist/tools/memory-revision-restore.js +33 -0
  73. package/dist/tools/person.d.ts +32 -0
  74. package/dist/tools/person.js +111 -0
  75. package/dist/tools/prune.js +1 -1
  76. package/dist/tools/recall.js +7 -1
  77. package/dist/tools/remember.js +18 -2
  78. package/dist/tools/update.js +5 -1
  79. package/dist/transport/cli-endpoint.d.ts +13 -0
  80. package/dist/transport/cli-endpoint.js +97 -0
  81. package/dist/transport/guards.d.ts +14 -0
  82. package/dist/transport/guards.js +26 -0
  83. package/dist/transport/http-server.d.ts +28 -1
  84. package/dist/transport/http-server.js +156 -18
  85. package/dist/transport/knowledge-server.d.ts +11 -0
  86. package/dist/transport/knowledge-server.js +30 -0
  87. package/dist/transport/knowledge-tools.d.ts +50 -0
  88. package/dist/transport/knowledge-tools.js +290 -0
  89. package/dist/transport/mcp-app-hello.d.ts +1 -1
  90. package/dist/widgets/display-modes.html +62 -56
  91. package/dist/widgets/hello.html +62 -56
  92. package/dist/widgets/proposals.html +62 -56
  93. package/dist/widgets/top-of-mind.html +60 -54
  94. package/package.json +7 -5
@@ -5,6 +5,19 @@
5
5
  * storage backend. Any backend that implements MemoryBackend can be
6
6
  * swapped in; v0.3.1 ships SqliteVecBackend as the single opinion.
7
7
  */
8
+ /**
9
+ * How a memory was sourced — mirrors the knowledge wing's sourcing field.
10
+ * Displayed at injection time so readers can calibrate confidence.
11
+ *
12
+ * - `observed` — directly witnessed by this body: measured, tested, read from source.
13
+ * - `relayed` — learned from another person/body: conversation, letter, episode.
14
+ * - `inferred` — AI-generated reasoning/synthesis: extrapolation, pattern-matching.
15
+ * Treat with appropriate skepticism — not ground truth.
16
+ * - `system` — auto-written by the harness (episode fallback, lane output, etc.).
17
+ */
18
+ export type MemorySourcing = 'observed' | 'relayed' | 'inferred' | 'system';
19
+ export type { MemoryScope } from './scope.js';
20
+ import type { MemoryScope } from './scope.js';
8
21
  export interface MemoryInput {
9
22
  category: string;
10
23
  title: string;
@@ -13,6 +26,29 @@ export interface MemoryInput {
13
26
  metadata?: Record<string, unknown>;
14
27
  /** Optional time-to-live. Parsed durations like "7d", "30d", or "permanent". */
15
28
  ttl?: string;
29
+ /**
30
+ * How this memory was sourced. Displayed at injection so the reader can
31
+ * calibrate confidence — 'inferred' flags AI-generated reasoning that should
32
+ * not be treated as ground truth.
33
+ */
34
+ sourcing?: MemorySourcing;
35
+ /**
36
+ * Free-form origin: surface, body, session, or person. E.g. "wake:w-74fccc
37
+ * 2026-09-07", "Jonathan, Discord #loom", "lane:letters". For episodes,
38
+ * prefer metadata.where (already conventional); this carries additional detail.
39
+ */
40
+ provenance?: string;
41
+ /**
42
+ * Explicit scope override — 'project' (only recallable while scoped to
43
+ * this memory's project) or 'global' (recallable regardless of project).
44
+ * Omit to take the category default (see backends/scope.ts): 'user' and
45
+ * 'reference' default global, everything else defaults project when a
46
+ * project is attached, global when it isn't. Set this explicitly to flag
47
+ * a genuinely cross-project fact learned in a project context — e.g. a
48
+ * git/security/tool-workflow practice that happened to surface while
49
+ * working on one project but applies everywhere (t-337).
50
+ */
51
+ scope?: MemoryScope;
16
52
  }
17
53
  export interface MemoryRef {
18
54
  ref: string;
@@ -66,13 +102,38 @@ export interface MemoryMatch {
66
102
  project?: string;
67
103
  created: string;
68
104
  content: string;
105
+ /**
106
+ * Final ranking score for this result's position in the returned list.
107
+ * Equals `similarity` unless MMR re-ranking (recall's `diversity` > 0)
108
+ * traded this pick off against ones already selected — in that case this
109
+ * is the blended score (λ·similarity − (1−λ)·max similarity-to-selected)
110
+ * that actually placed it, not its raw similarity. See `similarity` for
111
+ * the pre-MMR number alongside it (t-337a; modeled on MemoriLabs/Memori's
112
+ * FactSearchResult.{similarity, rank_score} split).
113
+ */
69
114
  relevance: number;
115
+ /** Raw cosine similarity (1 − distance) to the query, before any MMR re-ranking. */
116
+ similarity: number;
117
+ /** Effective scope this memory was recalled under — see backends/scope.ts (t-337b). */
118
+ scope: MemoryScope;
70
119
  /** ISO timestamp of last recall hit, if tracked */
71
120
  lastAccessed?: string;
72
121
  /** TTL value if set (e.g. "7d", "permanent") */
73
122
  ttl?: string;
74
123
  /** ISO timestamp when this memory expires, if TTL is set */
75
124
  expiresAt?: string;
125
+ /** Sourcing tier — how the memory was produced. Null on legacy records. */
126
+ sourcing?: MemorySourcing;
127
+ /** Free-form origin text (surface / person / session). Null on legacy records. */
128
+ provenance?: string;
129
+ /**
130
+ * Evidence-backed confidence (0.3-0.9), feedback memories only (t-338).
131
+ * Decayed toward neutral by category half-life — see salience.ts
132
+ * decayedConfidence(). Undefined for non-feedback memories.
133
+ */
134
+ confidence?: number;
135
+ /** Number of observations (creation + confirm/contradict) behind `confidence`. */
136
+ evidenceCount?: number;
76
137
  }
77
138
  export interface ForgetInput {
78
139
  /** Direct reference (category/filename) for single deletion */
@@ -97,6 +158,13 @@ export interface UpdateInput {
97
158
  content?: string;
98
159
  /** Metadata fields to add or update */
99
160
  metadata?: Record<string, unknown>;
161
+ /**
162
+ * Record an observation against this memory's confidence (feedback
163
+ * memories only, t-338): 'confirm' raises it, 'contradict' lowers it.
164
+ * Computed in code (see salience.ts applyObservation) — never a prompt.
165
+ * Ignored on non-feedback memories.
166
+ */
167
+ observation?: 'confirm' | 'contradict';
100
168
  }
101
169
  export interface ForgetResult {
102
170
  /** Refs that were successfully deleted */
@@ -133,6 +201,10 @@ export interface UpdateResult {
133
201
  ref?: string;
134
202
  /** Id of the body snapshot taken before content was overwritten (if any). */
135
203
  snapshotId?: number;
204
+ /** Raw (undecayed) confidence after applying `observation`, when one was given. */
205
+ confidence?: number;
206
+ /** Evidence count after applying `observation`, when one was given. */
207
+ evidenceCount?: number;
136
208
  }
137
209
  /** Revision listing entry — metadata only, no body payload. */
138
210
  export interface MemoryRevisionMeta {
@@ -173,8 +245,31 @@ export interface MemorySupersededResult {
173
245
  /** True when old_ref was archived by this call; false if already archived. */
174
246
  archived: boolean;
175
247
  }
248
+ export interface MemoryMergeInput {
249
+ /** Refs of the losing memories to fold into the target (all must be active). */
250
+ source_refs: string[];
251
+ /** Ref of the canonical memory that survives the merge (must already exist, active). */
252
+ target_ref: string;
253
+ /** Optional note about this merge, stored in supersession tombstones on the losers. */
254
+ note?: string;
255
+ /** Hard-delete losers after archiving/superseding them (default false). */
256
+ hard_delete_losers?: boolean;
257
+ /** Append loser bodies to the target content under section markers (default false). */
258
+ append_loser_bodies?: boolean;
259
+ }
260
+ export interface MemoryMergeResult {
261
+ target_ref: string;
262
+ sources_merged: number;
263
+ /** Target's times_seen after summing in each merged source's count. */
264
+ times_seen: number;
265
+ losers: {
266
+ ref: string;
267
+ title: string;
268
+ content: string;
269
+ }[];
270
+ }
176
271
  export interface PruneResult {
177
- /** Memories that were deleted because their TTL expired */
272
+ /** Memories that were archived (moved to archive tier) because their TTL expired */
178
273
  expired: string[];
179
274
  /** Memories that haven't been accessed within the stale threshold */
180
275
  stale: string[];
@@ -190,6 +285,14 @@ export interface MemoryEntry {
190
285
  category: string;
191
286
  project?: string;
192
287
  created: string;
288
+ /** Sourcing tier — how the memory was produced. Null on legacy records. */
289
+ sourcing?: MemorySourcing;
290
+ /** Free-form origin text (surface / person / session). Null on legacy records. */
291
+ provenance?: string;
292
+ /** Decayed confidence (feedback memories only, t-338). */
293
+ confidence?: number;
294
+ /** Observation count behind `confidence`. */
295
+ evidenceCount?: number;
193
296
  }
194
297
  export interface FindSimilarInput {
195
298
  /** Anchor on an existing memory's embedding. Exactly one of `ref`/`text`. */
@@ -239,7 +342,7 @@ export interface AuditReport {
239
342
  stale: AuditStaleEntry[];
240
343
  /** Pairs of memories whose vector similarity ≥ `similarityThreshold`. */
241
344
  duplicates: DuplicatePair[];
242
- /** Memories whose TTL has expired (would be removed by `prune`). */
345
+ /** Memories whose TTL has expired (would be archived by `prune`). */
243
346
  expired: string[];
244
347
  }
245
348
  export interface MemoryBackend {
@@ -269,6 +372,13 @@ export interface MemoryBackend {
269
372
  restoreRevision(input: MemoryRevisionRestoreInput): Promise<MemoryRevisionRestoreResult>;
270
373
  /** Archive old_ref with a tombstone and record that new_ref supersedes it. */
271
374
  supersede(input: MemorySupersededInput): Promise<MemorySupersededResult>;
375
+ /**
376
+ * Consolidate 2+ memories into one canonical row. Acts on an audit()
377
+ * duplicate-finding: sources are archived with a supersession pointer to
378
+ * the target (parity with supersede(), but N:1), times_seen is summed
379
+ * onto the target, and bodies are optionally concatenated.
380
+ */
381
+ mergeMemories(input: MemoryMergeInput): Promise<MemoryMergeResult>;
272
382
  /** Release the underlying store handle. Cached backends evict on close. */
273
383
  close(): void;
274
384
  }
@@ -291,6 +401,14 @@ export interface KnowledgePageInput {
291
401
  verified_at?: string;
292
402
  /** The version/date the claims are valid as-of, e.g. "Syntakt OS 1.21" or "as of 2026-05". */
293
403
  freshness_anchor?: string;
404
+ /**
405
+ * Machine-readable SLA class. When omitted, derived automatically: a
406
+ * version-anchored page (freshness_anchor matches /v?\d+\.\d+/) → 'software';
407
+ * device-domain page (domain starts with 'music/gear' or 'monitor/hardware') →
408
+ * 'device'; all others → 'default'. SLAs: device=7d, software=14d, default=30d.
409
+ * Pass explicitly to override the derived value.
410
+ */
411
+ sla_class?: 'device' | 'software' | 'default';
294
412
  citations?: KnowledgeCitationInput[];
295
413
  /**
296
414
  * How `body` combines with an existing page on upsert.
@@ -308,6 +426,15 @@ export interface KnowledgePageInput {
308
426
  * (e.g. "v2", "2026-08-19", "t-81"). Preserved across upserts when omitted.
309
427
  */
310
428
  version?: string;
429
+ /**
430
+ * Caller's loom identity, resolved server-side (never a free-text tool
431
+ * argument — that would let any caller claim any authorship). Stamped as
432
+ * pages.author only on creation (the identity that wrote v1 — never
433
+ * overwritten by later upserts) and as page_revisions.actor on any
434
+ * snapshot this call produces. Omitted means "unknown" (legacy direct
435
+ * callers, tests) rather than a hard requirement.
436
+ */
437
+ actor?: string;
311
438
  }
312
439
  export interface KnowledgeCitationInput {
313
440
  claim: string;
@@ -334,10 +461,22 @@ export interface KnowledgePage {
334
461
  verified_at?: string | null;
335
462
  /** The version/date the claims are valid as-of, e.g. "Syntakt OS 1.21". */
336
463
  freshness_anchor?: string | null;
464
+ /**
465
+ * Machine-readable SLA class: 'device' (7d), 'software' (14d), 'default' (30d).
466
+ * Derived from freshness_anchor shape and domain on write; may be overridden
467
+ * explicitly via KnowledgePageInput.sla_class.
468
+ */
469
+ sla_class: string;
337
470
  /** ours/ class: who created or last owned this artifact (e.g. "art", "jonathan"). */
338
471
  created_by?: string | null;
339
472
  /** ours/ class: artifact version or revision tag (e.g. "v2", "2026-08-19"). */
340
473
  version?: string | null;
474
+ /**
475
+ * Identity that wrote v1 of this page — set once at creation, never
476
+ * changed by later upserts. Null for pages created before this column
477
+ * existed, or written by a direct backend caller that omitted actor.
478
+ */
479
+ author?: string | null;
341
480
  }
342
481
  export interface KnowledgeCitation {
343
482
  id: number;
@@ -369,6 +508,19 @@ export interface KnowledgeQueryInput {
369
508
  * never-verified pages first. Overrides the default hit-count ordering.
370
509
  */
371
510
  sortByVerified?: boolean;
511
+ /**
512
+ * Who is reading (t-677). When stampAccess fires, this identity's row in
513
+ * knowledge_access is upserted alongside the shared pages.hit_count /
514
+ * last_accessed aggregate. Defaults to 'unknown' when omitted — callers
515
+ * that care about attribution should always pass the acting identity.
516
+ */
517
+ identity?: string;
518
+ }
519
+ /** One reader's access stats on a page (t-677 per-identity attribution). */
520
+ export interface KnowledgeAccessStat {
521
+ identity: string;
522
+ hit_count: number;
523
+ last_accessed: string | null;
372
524
  }
373
525
  export interface KnowledgePageRef {
374
526
  uuid: string;
@@ -481,14 +633,41 @@ export interface KnowledgeVerifyInput {
481
633
  freshness_anchor?: string;
482
634
  /** Optional note — appended to the body as a "## Verification — <date>" section (single-page mode only). */
483
635
  note?: string;
636
+ /**
637
+ * Caller's loom identity, resolved server-side (never a free-text tool
638
+ * argument). Recorded as verifications.verifier for every page stamped
639
+ * by this call. Omitted means "unknown" (legacy direct callers, tests).
640
+ */
641
+ verifier?: string;
642
+ /** What the verification found. Defaults to 'confirmed' (the claims still hold). */
643
+ outcome?: 'confirmed' | 'stale' | 'corrected';
644
+ /** Whether the verifier actually checked the page's citations against their sources. Defaults to false. */
645
+ citation_checked?: boolean;
484
646
  }
485
647
  export interface KnowledgeVerifyResult {
486
648
  verified: number;
487
649
  slugs: string[];
650
+ /** The verification time recorded in the audit trail. */
488
651
  verified_at: string;
652
+ /**
653
+ * True when pages.verified_at was advanced to `verified_at`. False for an
654
+ * outcome of 'stale' — a failed verification is audited but does not
655
+ * renew the page's successful-freshness stamp.
656
+ */
657
+ stamped: boolean;
489
658
  /** True when a note section was appended to the body. */
490
659
  noted: boolean;
491
660
  }
661
+ /** A verification record — who verified a page, when, and what they found. */
662
+ export interface KnowledgeVerificationRecord {
663
+ id: number;
664
+ page_id: number;
665
+ /** Resolved loom identity of the verifier; null for pre-attribution rows. */
666
+ verifier: string | null;
667
+ verified_at: string;
668
+ outcome: string;
669
+ citation_checked: boolean;
670
+ }
492
671
  /** Revision listing entry — metadata only, no body payload. */
493
672
  export interface KnowledgeRevisionMeta {
494
673
  id: number;
@@ -499,6 +678,8 @@ export interface KnowledgeRevisionMeta {
499
678
  op: string;
500
679
  replaced_at: string;
501
680
  body_length: number;
681
+ /** Resolved loom identity that performed the action, null for pre-attribution rows. */
682
+ actor: string | null;
502
683
  }
503
684
  export interface KnowledgeRevision extends Omit<KnowledgeRevisionMeta, 'body_length'> {
504
685
  body: string;
@@ -506,6 +687,8 @@ export interface KnowledgeRevision extends Omit<KnowledgeRevisionMeta, 'body_len
506
687
  export interface KnowledgeRevisionRestoreInput {
507
688
  slug: string;
508
689
  revision_id: number;
690
+ /** Caller's loom identity, resolved server-side — recorded on the snapshot this restore takes of the displaced body. */
691
+ actor?: string;
509
692
  }
510
693
  export interface KnowledgeRevisionRestoreResult {
511
694
  slug: string;
@@ -527,9 +710,14 @@ export interface KnowledgePurgeResult {
527
710
  export interface KnowledgeBackend {
528
711
  /** Upsert an entity page by slug; create or append. */
529
712
  writePage(input: KnowledgePageInput): Promise<KnowledgeWriteResult>;
530
- /** Get a single page by slug. opts.stampAccess marks the fetch as a real read (last_accessed / hit_count). */
713
+ /**
714
+ * Get a single page by slug. opts.stampAccess marks the fetch as a real
715
+ * read (last_accessed / hit_count); opts.identity attributes that read
716
+ * to a reader (t-677) — defaults to 'unknown' when omitted.
717
+ */
531
718
  getPage(slug: string, opts?: {
532
719
  stampAccess?: boolean;
720
+ identity?: string;
533
721
  }): Promise<KnowledgePageWithCitations | null>;
534
722
  /** List pages with optional filters. */
535
723
  listPages(input?: KnowledgeQueryInput): Promise<KnowledgePageWithCitations[]>;
@@ -551,12 +739,24 @@ export interface KnowledgeBackend {
551
739
  purgePages(input: KnowledgePurgeInput): Promise<KnowledgePurgeResult>;
552
740
  /** Stamp verified_at / freshness_anchor without touching the body (optional appended note in single-page mode). */
553
741
  verifyPages(input: KnowledgeVerifyInput): Promise<KnowledgeVerifyResult>;
742
+ /** List verification records for a page, newest first. */
743
+ getVerifications(slug: string): Promise<KnowledgeVerificationRecord[]>;
554
744
  /** List body snapshots for a page, newest first. Metadata only — no body payloads. */
555
745
  listRevisions(slug: string): Promise<KnowledgeRevisionMeta[]>;
556
746
  /** Fetch one revision including its full body. */
557
747
  getRevision(revisionId: number): Promise<KnowledgeRevision | null>;
558
748
  /** Restore a revision's body onto its page; the displaced body is snapshotted first. */
559
749
  restoreRevision(input: KnowledgeRevisionRestoreInput): Promise<KnowledgeRevisionRestoreResult>;
750
+ /**
751
+ * Per-identity read stats for a page (t-677), newest-touched first. This
752
+ * is the fine-grained "who's reading this" breakdown; it is NOT consulted
753
+ * by knowledge_maintain's cold/expansion ranking, which deliberately keeps
754
+ * using the shared pages.hit_count / last_accessed aggregate — that
755
+ * aggregate already answers "has anyone read this recently" correctly
756
+ * across readers, so splitting it per-identity there would only make the
757
+ * cold list miss pages a *different* reader keeps alive.
758
+ */
759
+ getAccessStats(pageId: number): Promise<KnowledgeAccessStat[]>;
560
760
  /** Close the underlying SQLite connection. */
561
761
  close(): void;
562
762
  }
@@ -12,5 +12,7 @@ export declare const MEMORY_CATEGORIES: readonly ['user', 'project', 'self', 'fe
12
12
  export declare const EPISODE_CATEGORY = "episode";
13
13
  /** Episodes expire by default — they are a tape, not the stack. */
14
14
  export declare const EPISODE_DEFAULT_TTL = "48h";
15
+ /** The category that carries evidence-backed confidence (t-338). */
16
+ export declare const FEEDBACK_CATEGORY = "feedback";
15
17
  export type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
16
18
  export declare function isMemoryCategory(value: string): value is MemoryCategory;
@@ -24,6 +24,8 @@ export const MEMORY_CATEGORIES = [
24
24
  export const EPISODE_CATEGORY = 'episode';
25
25
  /** Episodes expire by default — they are a tape, not the stack. */
26
26
  export const EPISODE_DEFAULT_TTL = '48h';
27
+ /** The category that carries evidence-backed confidence (t-338). */
28
+ export const FEEDBACK_CATEGORY = 'feedback';
27
29
  export function isMemoryCategory(value) {
28
30
  return MEMORY_CATEGORIES.includes(value);
29
31
  }
@@ -9,11 +9,26 @@ export interface RawGlobalFlags {
9
9
  client?: string;
10
10
  model?: string;
11
11
  json?: boolean;
12
+ /** Remote loom-http to run against instead of a local stack (src/cli/remote.ts). */
13
+ url?: string;
12
14
  }
13
15
  export declare function resolveEnv(flags: RawGlobalFlags, processEnv: NodeJS.ProcessEnv): ResolvedEnv;
16
+ /**
17
+ * Index of the first token in `argv` that is neither a leading global flag
18
+ * nor a global flag's value — i.e. where the subcommand is, if there is
19
+ * one. Returns `argv.length` when argv holds nothing but globals.
20
+ *
21
+ * The help text calls --context-dir/--client/--model/--json GLOBAL, which
22
+ * has to mean they are accepted before the subcommand as well as after.
23
+ * Both entry points resolve the subcommand through this so that promise
24
+ * holds: src/index.ts to decide CLI-vs-MCP, runCli to dispatch. Scanning
25
+ * only over *leading* globals (rather than searching all of argv for a
26
+ * known verb) is what keeps a flag's value from being mistaken for one.
27
+ */
28
+ export declare function firstNonGlobalIndex(argv: string[]): number;
14
29
  /**
15
30
  * Extracts global flags from an argv slice, returning the remaining argv.
16
- * Recognizes: --context-dir, --client, --model, --json.
31
+ * Recognizes: --context-dir, --client, --model, --url, --json — in any position.
17
32
  */
18
33
  export declare function extractGlobalFlags(argv: string[]): {
19
34
  flags: RawGlobalFlags;
package/dist/cli/args.js CHANGED
@@ -16,9 +16,49 @@ export function resolveEnv(flags, processEnv) {
16
16
  json: Boolean(flags.json),
17
17
  };
18
18
  }
19
+ /**
20
+ * Global flags that consume the NEXT argv token as their value. Shared by
21
+ * `extractGlobalFlags` and `firstNonGlobalIndex` so the two cannot drift:
22
+ * a flag that takes a value must be skipped in pairs when we are looking
23
+ * past the globals for a subcommand, or `--context-dir recall` reads as an
24
+ * invocation of `recall`.
25
+ */
26
+ const VALUED_GLOBAL_FLAGS = new Set([
27
+ '--context-dir', '--client', '--model', '--url',
28
+ ]);
29
+ /** Global flags that stand alone. */
30
+ const BARE_GLOBAL_FLAGS = new Set(['--json']);
31
+ /**
32
+ * Index of the first token in `argv` that is neither a leading global flag
33
+ * nor a global flag's value — i.e. where the subcommand is, if there is
34
+ * one. Returns `argv.length` when argv holds nothing but globals.
35
+ *
36
+ * The help text calls --context-dir/--client/--model/--json GLOBAL, which
37
+ * has to mean they are accepted before the subcommand as well as after.
38
+ * Both entry points resolve the subcommand through this so that promise
39
+ * holds: src/index.ts to decide CLI-vs-MCP, runCli to dispatch. Scanning
40
+ * only over *leading* globals (rather than searching all of argv for a
41
+ * known verb) is what keeps a flag's value from being mistaken for one.
42
+ */
43
+ export function firstNonGlobalIndex(argv) {
44
+ let i = 0;
45
+ while (i < argv.length) {
46
+ const a = argv[i];
47
+ if (VALUED_GLOBAL_FLAGS.has(a)) {
48
+ i += 2;
49
+ continue;
50
+ }
51
+ if (BARE_GLOBAL_FLAGS.has(a)) {
52
+ i += 1;
53
+ continue;
54
+ }
55
+ return i;
56
+ }
57
+ return argv.length;
58
+ }
19
59
  /**
20
60
  * Extracts global flags from an argv slice, returning the remaining argv.
21
- * Recognizes: --context-dir, --client, --model, --json.
61
+ * Recognizes: --context-dir, --client, --model, --url, --json — in any position.
22
62
  */
23
63
  export function extractGlobalFlags(argv) {
24
64
  const flags = {};
@@ -37,6 +77,10 @@ export function extractGlobalFlags(argv) {
37
77
  flags.model = argv[++i];
38
78
  continue;
39
79
  }
80
+ if (a === '--url') {
81
+ flags.url = argv[++i];
82
+ continue;
83
+ }
40
84
  if (a === '--json') {
41
85
  flags.json = true;
42
86
  continue;
@@ -1,2 +1,9 @@
1
1
  import type { IOStreams } from './io.js';
2
2
  export declare function runCli(argv: string[], io?: IOStreams): Promise<number>;
3
+ /**
4
+ * The CLI with no remote mode on its path at all: what POST /cli runs
5
+ * (transport/cli-endpoint.ts). A request arriving over HTTP must never be
6
+ * able to send the server on to another URL, and that holds by structure
7
+ * here, not only because the endpoint refuses --url.
8
+ */
9
+ export declare function runCliLocal(argv: string[], io: IOStreams): Promise<number>;
package/dist/cli/index.js CHANGED
@@ -7,6 +7,8 @@ import { join } from 'node:path';
7
7
  import { resolveRepoRoot } from '../config.js';
8
8
  import { realStreams } from './io.js';
9
9
  import { SUBCOMMANDS } from './subcommands.js';
10
+ import { extractGlobalFlags, firstNonGlobalIndex } from './args.js';
11
+ import { REMOTE_VERBS, runRemote } from './remote.js';
10
12
  const TOP_HELP = `Usage: loom <command> [options]
11
13
 
12
14
  Commands:
@@ -19,6 +21,7 @@ Commands:
19
21
  Browse / clean / find duplicates / health report
20
22
  knowledge write|recall|maintain
21
23
  Write / search / health-report the knowledge store
24
+ person show|list Read person cards (condensed current state per person)
22
25
  update-identity Edit preferences.md / self-model.md sections
23
26
  bootstrap Initialize a fresh agent
24
27
  serve Explicit MCP stdio startup (same as no args)
@@ -32,6 +35,10 @@ Global flags:
32
35
  --context-dir <path> Agent context dir (default: $LOOM_CONTEXT_DIR or ~/.config/loom/default)
33
36
  --client <name> Harness adapter hint (default: $LOOM_CLIENT)
34
37
  --model <name> Model manifest hint (default: $LOOM_MODEL)
38
+ --url <url> Run against a loom-http server (default: $LOOM_URL), e.g.
39
+ http://host:8787/mcp. Bearer token from $LOOM_TOKEN_FILE.
40
+ Applies to wake, recall, remember, update, forget,
41
+ memory, knowledge, person; other commands stay local.
35
42
  --json Machine-readable output
36
43
  --help, -h Show help
37
44
  --version, -V Print loom version
@@ -43,7 +50,47 @@ async function readVersion() {
43
50
  return pkg.version;
44
51
  }
45
52
  export async function runCli(argv, io = realStreams()) {
46
- const first = argv[0];
53
+ const resolved = await resolveSubcommand(argv, io);
54
+ if (typeof resolved === 'number')
55
+ return resolved;
56
+ const { sub, rest } = resolved;
57
+ // Remote mode: the store verbs go to loom-http when a URL is set.
58
+ const url = extractGlobalFlags(rest).flags.url;
59
+ if (rest.includes('--url') && !url) {
60
+ io.stderr(`--url needs a value (a loom-http URL, e.g. http://host:8787/mcp).\n`);
61
+ return 2;
62
+ }
63
+ if (REMOTE_VERBS.has(sub)) {
64
+ const target = url ?? io.env.LOOM_URL;
65
+ if (target)
66
+ return runRemote(sub, rest, target, io);
67
+ }
68
+ else if (url !== undefined) {
69
+ io.stderr(`--url only applies to ${[...REMOTE_VERBS].join(', ')}; ${sub} runs locally.\n`);
70
+ return 2;
71
+ }
72
+ return dispatch(sub, rest, io);
73
+ }
74
+ /**
75
+ * The CLI with no remote mode on its path at all: what POST /cli runs
76
+ * (transport/cli-endpoint.ts). A request arriving over HTTP must never be
77
+ * able to send the server on to another URL, and that holds by structure
78
+ * here, not only because the endpoint refuses --url.
79
+ */
80
+ export async function runCliLocal(argv, io) {
81
+ const resolved = await resolveSubcommand(argv, io);
82
+ if (typeof resolved === 'number')
83
+ return resolved;
84
+ return dispatch(resolved.sub, resolved.rest, io);
85
+ }
86
+ /** The subcommand and the argv it gets, or the exit code when there's nothing to dispatch. */
87
+ async function resolveSubcommand(argv, io) {
88
+ // The subcommand is the first token that is not a leading global flag,
89
+ // so the flags the help text calls global work in front of it too. When
90
+ // it is already at argv[0] — the overwhelmingly common form — subIdx is
91
+ // 0 and every line below behaves exactly as it did before.
92
+ const subIdx = firstNonGlobalIndex(argv);
93
+ const first = argv[subIdx];
47
94
  if (first === '--help' || first === '-h' || first === undefined) {
48
95
  io.stdout(TOP_HELP);
49
96
  return 0;
@@ -57,8 +104,11 @@ export async function runCli(argv, io = realStreams()) {
57
104
  io.stderr(TOP_HELP);
58
105
  return 2;
59
106
  }
60
- const sub = first;
61
- const rest = argv.slice(1);
107
+ // Drop only the subcommand token; the globals that preceded it stay in
108
+ // argv so each command's own extractGlobalFlags still sees them.
109
+ return { sub: first, rest: [...argv.slice(0, subIdx), ...argv.slice(subIdx + 1)] };
110
+ }
111
+ async function dispatch(sub, rest, io) {
62
112
  switch (sub) {
63
113
  case 'wake': {
64
114
  const { run } = await import('./wake.js');
@@ -76,6 +126,10 @@ export async function runCli(argv, io = realStreams()) {
76
126
  const { run } = await import('./knowledge.js');
77
127
  return run(rest, io);
78
128
  }
129
+ case 'person': {
130
+ const { run } = await import('./person.js');
131
+ return run(rest, io);
132
+ }
79
133
  case 'forget': {
80
134
  const { run } = await import('./forget.js');
81
135
  return run(rest, io);
@@ -6,6 +6,7 @@ import { knowledgeWrite } from '../tools/knowledge-write.js';
6
6
  import { knowledgeRecall } from '../tools/knowledge-recall.js';
7
7
  import { knowledgeMaintain } from '../tools/knowledge-maintain.js';
8
8
  import { createKnowledgeBackend } from '../backends/index.js';
9
+ import { resolveIdentityName } from '../config.js';
9
10
  import { extractGlobalFlags, resolveEnv } from './args.js';
10
11
  import { renderJson } from './io.js';
11
12
  const USAGE = `Usage: loom knowledge <subcommand> [options]
@@ -13,7 +14,7 @@ const USAGE = `Usage: loom knowledge <subcommand> [options]
13
14
  Subcommands:
14
15
  write Upsert an entity page (create or append/revise body in place)
15
16
  recall Search knowledge pages (LIKE over title+body+domain)
16
- maintain Read-only health report (expansion candidates, cold pages, misfile audit)
17
+ maintain Read-only health report (expansion candidates, cold pages, misfile audit, person-page check)
17
18
 
18
19
  Options (write):
19
20
  --slug <slug> Entity key for upsert (default: derived from title)
@@ -112,6 +113,7 @@ export async function run(argv, io) {
112
113
  body,
113
114
  sourcing,
114
115
  citations,
116
+ actor: resolveIdentityName(env.contextDir),
115
117
  });
116
118
  renderJson(io, result);
117
119
  return 0;
@@ -210,6 +212,7 @@ export async function run(argv, io) {
210
212
  expansionHitThreshold,
211
213
  thinBodyThreshold,
212
214
  coldDays,
215
+ personCards: true,
213
216
  });
214
217
  io.stdout(text.endsWith('\n') ? text : text + '\n');
215
218
  return 0;
@@ -20,7 +20,7 @@ const USAGE = `Usage: loom memory <subcommand> [options]
20
20
 
21
21
  Subcommands:
22
22
  list Browse memories (table or --json)
23
- prune Report / remove expired and stale memories
23
+ prune Report / archive expired memories; report stale ones
24
24
  similar Surface memories semantically near a ref or text
25
25
  audit One-shot health report (counts, stale, duplicates, expired)
26
26
  archive Soft-retire a memory with a tombstone (recoverable)
@@ -42,7 +42,7 @@ Options (list):
42
42
 
43
43
  Options (prune):
44
44
  --stale-days <n> Stale threshold in days
45
- --dry-run Report what would be pruned, don't delete
45
+ --dry-run Report what would be archived, don't archive
46
46
  --json Emit PruneResult
47
47
 
48
48
  Options (similar):
@@ -0,0 +1,2 @@
1
+ import type { IOStreams } from './io.js';
2
+ export declare function run(argv: string[], io: IOStreams): Promise<number>;