@jbarket/loomai 0.5.0 → 0.7.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 (96) hide show
  1. package/CHANGELOG.md +129 -1
  2. package/README.md +115 -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 +81 -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/pieces.d.ts +24 -0
  36. package/dist/cli/pieces.js +139 -0
  37. package/dist/cli/recall.js +2 -1
  38. package/dist/cli/remember.js +10 -0
  39. package/dist/cli/remote.d.ts +36 -0
  40. package/dist/cli/remote.js +173 -0
  41. package/dist/cli/serve.d.ts +7 -0
  42. package/dist/cli/serve.js +94 -5
  43. package/dist/cli/subcommands.d.ts +1 -1
  44. package/dist/cli/subcommands.js +2 -2
  45. package/dist/cli/update.js +30 -11
  46. package/dist/clients.js +7 -4
  47. package/dist/config.d.ts +14 -0
  48. package/dist/config.js +25 -2
  49. package/dist/index.js +28 -8
  50. package/dist/injection/harnesses.js +9 -3
  51. package/dist/install/harnesses.js +7 -1
  52. package/dist/server.d.ts +8 -1
  53. package/dist/server.js +461 -374
  54. package/dist/tools/dossier.js +5 -1
  55. package/dist/tools/eurorack-import.js +3 -0
  56. package/dist/tools/identity.d.ts +2 -0
  57. package/dist/tools/identity.js +35 -2
  58. package/dist/tools/knowledge-history.d.ts +6 -1
  59. package/dist/tools/knowledge-history.js +26 -8
  60. package/dist/tools/knowledge-maintain.d.ts +5 -0
  61. package/dist/tools/knowledge-maintain.js +92 -5
  62. package/dist/tools/knowledge-recall.d.ts +7 -1
  63. package/dist/tools/knowledge-recall.js +53 -3
  64. package/dist/tools/knowledge-verify.d.ts +11 -1
  65. package/dist/tools/knowledge-verify.js +17 -4
  66. package/dist/tools/knowledge-write.d.ts +8 -1
  67. package/dist/tools/knowledge-write.js +27 -9
  68. package/dist/tools/memory-history.d.ts +5 -0
  69. package/dist/tools/memory-history.js +52 -0
  70. package/dist/tools/memory-list.js +2 -1
  71. package/dist/tools/memory-merge.d.ts +2 -0
  72. package/dist/tools/memory-merge.js +40 -0
  73. package/dist/tools/memory-revision-restore.d.ts +5 -0
  74. package/dist/tools/memory-revision-restore.js +33 -0
  75. package/dist/tools/person.d.ts +32 -0
  76. package/dist/tools/person.js +111 -0
  77. package/dist/tools/prune.js +1 -1
  78. package/dist/tools/recall.js +7 -1
  79. package/dist/tools/remember.js +18 -2
  80. package/dist/tools/update.js +5 -1
  81. package/dist/transport/cli-endpoint.d.ts +13 -0
  82. package/dist/transport/cli-endpoint.js +97 -0
  83. package/dist/transport/guards.d.ts +14 -0
  84. package/dist/transport/guards.js +26 -0
  85. package/dist/transport/http-server.d.ts +28 -1
  86. package/dist/transport/http-server.js +156 -18
  87. package/dist/transport/knowledge-server.d.ts +11 -0
  88. package/dist/transport/knowledge-server.js +30 -0
  89. package/dist/transport/knowledge-tools.d.ts +50 -0
  90. package/dist/transport/knowledge-tools.js +290 -0
  91. package/dist/transport/mcp-app-hello.d.ts +1 -1
  92. package/dist/widgets/display-modes.html +62 -56
  93. package/dist/widgets/hello.html +62 -56
  94. package/dist/widgets/proposals.html +62 -56
  95. package/dist/widgets/top-of-mind.html +60 -54
  96. package/package.json +7 -5
@@ -43,7 +43,7 @@ function hasTable(db, table) {
43
43
  function openProposalsDb(contextDir) {
44
44
  const dbPath = resolveSqliteDbPath(contextDir);
45
45
  const db = new BetterSqlite3(dbPath);
46
- db.pragma('busy_timeout = 0');
46
+ db.pragma('busy_timeout = 5000');
47
47
  if (!hasTable(db, 'proposals'))
48
48
  runMigrations(db, { strict: false });
49
49
  return db;
@@ -0,0 +1,34 @@
1
+ export interface RecorderHealth {
2
+ /** How many episode write-or-read attempts have failed in a row since lastSuccess. */
3
+ consecutiveFailures: number;
4
+ /** ISO timestamp of the first failure in the current streak, null when healthy. */
5
+ failingSince: string | null;
6
+ /** Scrubbed error string from the last failure (≤200 chars, no absolute paths). */
7
+ lastError: string | null;
8
+ /** ISO timestamp of the last successful episode write, null if never recorded. */
9
+ lastSuccess: string | null;
10
+ }
11
+ /** Number of consecutive failures before identity.ts injects the warning block. */
12
+ export declare const FAILURE_THRESHOLD = 3;
13
+ export declare function ledgerPath(contextDir: string): string;
14
+ /** Read the health ledger synchronously. Returns HEALTHY when absent or unreadable. */
15
+ export declare function readHealth(contextDir: string): RecorderHealth;
16
+ /**
17
+ * Record a successful episode write. Resets the consecutive failure streak.
18
+ * The lastError field is preserved so diagnostics survive a single success.
19
+ */
20
+ export declare function recordSuccess(contextDir: string): void;
21
+ /**
22
+ * Record a failed episode write or tape-read attempt. Increments the streak.
23
+ * failingSince is set to the first failure's timestamp and never moved forward
24
+ * until a success resets the streak.
25
+ */
26
+ export declare function recordFailure(contextDir: string, error: unknown): void;
27
+ /**
28
+ * Build the warning block to inject into the boot context, or null when healthy.
29
+ *
30
+ * Returned string is a complete markdown section. Callers append it BELOW the
31
+ * rest of the identity payload — placed last so it is freshest in the model's
32
+ * working memory when its first reply fires.
33
+ */
34
+ export declare function warningBlock(health: RecorderHealth): string | null;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Recorder health ledger (t-562) — tracks consecutive episode-write failures.
3
+ *
4
+ * Written by:
5
+ * - the episode write path (remember.ts) on success and failure
6
+ * - the tape-read path (identity.ts catch) on failure
7
+ * Read by:
8
+ * - identity.ts, which appends a warning block below the identity payload
9
+ * when the recorder has failed FAILURE_THRESHOLD consecutive times.
10
+ *
11
+ * Inspired by observer-health.ts in claude-mem @ fd0ecf0. The ledger is a
12
+ * small JSON file in the context dir alongside memories.db — one per agent.
13
+ * All reads/writes are synchronous and never throw: the health ledger must
14
+ * never cascade into a deeper failure.
15
+ */
16
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ /** Number of consecutive failures before identity.ts injects the warning block. */
19
+ export const FAILURE_THRESHOLD = 3;
20
+ const LEDGER_FILENAME = 'recorder-health.json';
21
+ const HEALTHY = {
22
+ consecutiveFailures: 0,
23
+ failingSince: null,
24
+ lastError: null,
25
+ lastSuccess: null,
26
+ };
27
+ export function ledgerPath(contextDir) {
28
+ return join(contextDir, LEDGER_FILENAME);
29
+ }
30
+ /** Read the health ledger synchronously. Returns HEALTHY when absent or unreadable. */
31
+ export function readHealth(contextDir) {
32
+ try {
33
+ const path = ledgerPath(contextDir);
34
+ if (!existsSync(path))
35
+ return { ...HEALTHY };
36
+ const raw = readFileSync(path, 'utf-8');
37
+ const parsed = JSON.parse(raw);
38
+ return {
39
+ consecutiveFailures: typeof parsed.consecutiveFailures === 'number' ? parsed.consecutiveFailures : 0,
40
+ failingSince: typeof parsed.failingSince === 'string' ? parsed.failingSince : null,
41
+ lastError: typeof parsed.lastError === 'string' ? parsed.lastError : null,
42
+ lastSuccess: typeof parsed.lastSuccess === 'string' ? parsed.lastSuccess : null,
43
+ };
44
+ }
45
+ catch {
46
+ return { ...HEALTHY };
47
+ }
48
+ }
49
+ /** Scrub a raw error for the ledger: trim to 200 chars, redact absolute paths. */
50
+ function scrubError(error) {
51
+ const raw = error instanceof Error ? error.message : String(error);
52
+ return raw.replace(/\/[^\s:,'"]+/g, '<path>').slice(0, 200);
53
+ }
54
+ /** Write the health ledger synchronously. Silently swallows write errors. */
55
+ function writeHealth(contextDir, health) {
56
+ try {
57
+ writeFileSync(ledgerPath(contextDir), JSON.stringify(health, null, 2), 'utf-8');
58
+ }
59
+ catch {
60
+ // writing the health ledger must never block or throw
61
+ }
62
+ }
63
+ /**
64
+ * Record a successful episode write. Resets the consecutive failure streak.
65
+ * The lastError field is preserved so diagnostics survive a single success.
66
+ */
67
+ export function recordSuccess(contextDir) {
68
+ const prev = readHealth(contextDir);
69
+ writeHealth(contextDir, {
70
+ consecutiveFailures: 0,
71
+ failingSince: null,
72
+ lastError: prev.lastError,
73
+ lastSuccess: new Date().toISOString(),
74
+ });
75
+ }
76
+ /**
77
+ * Record a failed episode write or tape-read attempt. Increments the streak.
78
+ * failingSince is set to the first failure's timestamp and never moved forward
79
+ * until a success resets the streak.
80
+ */
81
+ export function recordFailure(contextDir, error) {
82
+ const prev = readHealth(contextDir);
83
+ const now = new Date().toISOString();
84
+ writeHealth(contextDir, {
85
+ consecutiveFailures: prev.consecutiveFailures + 1,
86
+ failingSince: prev.failingSince ?? now,
87
+ lastError: scrubError(error),
88
+ lastSuccess: prev.lastSuccess,
89
+ });
90
+ }
91
+ /**
92
+ * Build the warning block to inject into the boot context, or null when healthy.
93
+ *
94
+ * Returned string is a complete markdown section. Callers append it BELOW the
95
+ * rest of the identity payload — placed last so it is freshest in the model's
96
+ * working memory when its first reply fires.
97
+ */
98
+ export function warningBlock(health) {
99
+ if (health.consecutiveFailures < FAILURE_THRESHOLD)
100
+ return null;
101
+ const since = health.failingSince ? ` since ${health.failingSince}` : '';
102
+ const errLine = health.lastError
103
+ ? `\n\nLast error: \`${health.lastError}\``
104
+ : '';
105
+ const successLine = health.lastSuccess
106
+ ? `\nLast success: ${health.lastSuccess}`
107
+ : '\nNo successful write on record.';
108
+ return (`# ⚠️ Recorder Health Warning\n\n` +
109
+ `The episode recorder has failed **${health.consecutiveFailures} consecutive` +
110
+ ` time${health.consecutiveFailures === 1 ? '' : 's'}**${since}.\n` +
111
+ `Episodes may not be reaching the tape — future bodies will wake without context.\n` +
112
+ successLine +
113
+ errLine +
114
+ `\n\n` +
115
+ `Check \`~/.local/state/art-episodes/hook.log\` and the loom episode store. ` +
116
+ `Call \`mcp__loom__remember\` with category \`episode\` to exercise the write path.`);
117
+ }
@@ -10,6 +10,41 @@ export declare const DEFAULT_HALF_LIFE_DAYS = 30;
10
10
  export declare function temperature(lastTouchIso: string, category: string, nowMs: number): number;
11
11
  export type Tier = 'Hot' | 'Warm' | 'Cool';
12
12
  export declare function tierOf(temp: number): Tier;
13
+ /**
14
+ * Feedback confidence model (t-338, parent t-325).
15
+ *
16
+ * Stolen from affaan-m/ECC's instinct design (confidence range 0.3-0.9,
17
+ * evidence-backed, +0.05 per confirming observation, -0.1 per contradicting
18
+ * one) — deliberately NOT their implementation. In ECC that arithmetic lives
19
+ * only as prose in a prompt (agents/observer.md) fed to an LLM; instinct-cli.py
20
+ * never computes, adjusts, or decays the number anywhere. Here the arithmetic
21
+ * is plain functions loom's own code calls from remember()/update() — never a
22
+ * prompt. Decay reuses `temperature()` above (same HALF_LIVES table) instead
23
+ * of a second, separate -0.02/week rule: one decay mechanism, not two.
24
+ */
25
+ export declare const FEEDBACK_CONFIDENCE_MIN = 0.3;
26
+ export declare const FEEDBACK_CONFIDENCE_MAX = 0.9;
27
+ /** Starting value for a newly observed feedback memory, and the value decay relaxes toward. */
28
+ export declare const FEEDBACK_CONFIDENCE_DEFAULT = 0.6;
29
+ export declare const FEEDBACK_CONFIRM_DELTA = 0.05;
30
+ export declare const FEEDBACK_CONTRADICT_DELTA = -0.1;
31
+ export declare function clampConfidence(value: number): number;
32
+ /** Apply one confirming or contradicting observation to a stored confidence value. */
33
+ export declare function applyObservation(current: number, kind: 'confirm' | 'contradict'): number;
34
+ /**
35
+ * Confidence after decay: relax the evidence-weighted raw value toward the
36
+ * neutral default as the memory cools, scaled by the SAME exponential
37
+ * half-life curve as salience (`temperature()`, same HALF_LIVES table) — a
38
+ * fully-decayed (temp→0) memory reads as neutral, a freshly-touched one
39
+ * (temp=1) reads as its raw evidence-backed value.
40
+ *
41
+ * `lastTouchIso` should be the last OBSERVATION time (the row's
42
+ * `observed_at`, falling back to `created`) — not `updated` and not
43
+ * `last_accessed`. A recall, an edit, an archive or a restore isn't
44
+ * evidence and mustn't reheat confidence; only a confirm/contradict (which
45
+ * goes through update() and stamps `observed_at`) is.
46
+ */
47
+ export declare function decayedConfidence(rawConfidence: number, lastTouchIso: string, category: string, nowMs: number): number;
13
48
  /**
14
49
  * Recompute and STORE the salience scalar for every non-archived memory. Called
15
50
  * by the consolidation lane (stored + lane-refresh, not computed-on-read).
@@ -21,6 +56,8 @@ export interface DigestRow {
21
56
  category: string;
22
57
  content: string;
23
58
  salience: number;
59
+ /** Sourcing tier — null on legacy records without the column. */
60
+ sourcing: string | null;
24
61
  }
25
62
  export interface DigestOptions {
26
63
  /** Approximate token budget for the whole digest. */
@@ -43,6 +80,8 @@ export interface DigestAtom {
43
80
  content: string;
44
81
  salience: number;
45
82
  tier: Tier;
83
+ /** Sourcing tier — null on legacy records. */
84
+ sourcing: string | null;
46
85
  }
47
86
  /**
48
87
  * Structured salience digest for a UI widget: the hottest authored atoms,
@@ -48,6 +48,49 @@ export function tierOf(temp) {
48
48
  return 'Warm';
49
49
  return 'Cool';
50
50
  }
51
+ /**
52
+ * Feedback confidence model (t-338, parent t-325).
53
+ *
54
+ * Stolen from affaan-m/ECC's instinct design (confidence range 0.3-0.9,
55
+ * evidence-backed, +0.05 per confirming observation, -0.1 per contradicting
56
+ * one) — deliberately NOT their implementation. In ECC that arithmetic lives
57
+ * only as prose in a prompt (agents/observer.md) fed to an LLM; instinct-cli.py
58
+ * never computes, adjusts, or decays the number anywhere. Here the arithmetic
59
+ * is plain functions loom's own code calls from remember()/update() — never a
60
+ * prompt. Decay reuses `temperature()` above (same HALF_LIVES table) instead
61
+ * of a second, separate -0.02/week rule: one decay mechanism, not two.
62
+ */
63
+ export const FEEDBACK_CONFIDENCE_MIN = 0.3;
64
+ export const FEEDBACK_CONFIDENCE_MAX = 0.9;
65
+ /** Starting value for a newly observed feedback memory, and the value decay relaxes toward. */
66
+ export const FEEDBACK_CONFIDENCE_DEFAULT = 0.6;
67
+ export const FEEDBACK_CONFIRM_DELTA = 0.05;
68
+ export const FEEDBACK_CONTRADICT_DELTA = -0.1;
69
+ export function clampConfidence(value) {
70
+ return Math.min(FEEDBACK_CONFIDENCE_MAX, Math.max(FEEDBACK_CONFIDENCE_MIN, value));
71
+ }
72
+ /** Apply one confirming or contradicting observation to a stored confidence value. */
73
+ export function applyObservation(current, kind) {
74
+ const delta = kind === 'confirm' ? FEEDBACK_CONFIRM_DELTA : FEEDBACK_CONTRADICT_DELTA;
75
+ return clampConfidence(current + delta);
76
+ }
77
+ /**
78
+ * Confidence after decay: relax the evidence-weighted raw value toward the
79
+ * neutral default as the memory cools, scaled by the SAME exponential
80
+ * half-life curve as salience (`temperature()`, same HALF_LIVES table) — a
81
+ * fully-decayed (temp→0) memory reads as neutral, a freshly-touched one
82
+ * (temp=1) reads as its raw evidence-backed value.
83
+ *
84
+ * `lastTouchIso` should be the last OBSERVATION time (the row's
85
+ * `observed_at`, falling back to `created`) — not `updated` and not
86
+ * `last_accessed`. A recall, an edit, an archive or a restore isn't
87
+ * evidence and mustn't reheat confidence; only a confirm/contradict (which
88
+ * goes through update() and stamps `observed_at`) is.
89
+ */
90
+ export function decayedConfidence(rawConfidence, lastTouchIso, category, nowMs) {
91
+ const temp = temperature(lastTouchIso, category, nowMs);
92
+ return FEEDBACK_CONFIDENCE_DEFAULT + (rawConfidence - FEEDBACK_CONFIDENCE_DEFAULT) * temp;
93
+ }
51
94
  /** Most-recent-touch timestamp for a row (recall/write reheat through these). */
52
95
  function lastTouch(r) {
53
96
  return r.last_accessed ?? r.updated ?? r.created;
@@ -77,7 +120,10 @@ const DEFAULT_CHARS_PER_TOKEN = 4;
77
120
  function atomLine(r) {
78
121
  const body = r.content.replace(/\s+/g, ' ').trim();
79
122
  const clipped = body.length > 200 ? body.slice(0, 197).trimEnd() + '…' : body;
80
- return `- **${r.title}** (${r.category}) — ${clipped}`;
123
+ // Show sourcing tag when present so readers calibrate confidence on inject.
124
+ // 'inferred' is the critical one: AI-generated reasoning, not ground truth.
125
+ const tag = r.sourcing ? `, ${r.sourcing}` : '';
126
+ return `- **${r.title}** (${r.category}${tag}) — ${clipped}`;
81
127
  }
82
128
  /**
83
129
  * Assemble the boot digest from already-recomputed salience: order hottest-first,
@@ -120,7 +166,7 @@ function openMemoriesDb(contextDir, readonly) {
120
166
  if (!existsSync(dbPath))
121
167
  return null;
122
168
  const db = new BetterSqlite3(dbPath, { readonly });
123
- db.pragma('busy_timeout = 0');
169
+ db.pragma('busy_timeout = 5000');
124
170
  return db;
125
171
  }
126
172
  /** Lane entry point: recompute + store salience for a context. Returns count (or null if no store). */
@@ -153,7 +199,7 @@ export function digestData(contextDir, limit = 40) {
153
199
  return { atoms: [], total: 0 };
154
200
  const total = db.prepare('SELECT COUNT(*) AS n FROM memories WHERE archived = 0').get().n;
155
201
  const rows = db
156
- .prepare('SELECT title, category, content, salience FROM memories WHERE archived = 0 AND category != ? ORDER BY salience DESC LIMIT ?')
202
+ .prepare('SELECT title, category, content, salience, sourcing FROM memories WHERE archived = 0 AND category != ? ORDER BY salience DESC LIMIT ?')
157
203
  .all(EPISODE_CATEGORY, limit);
158
204
  const atoms = rows.map((r) => ({ ...r, tier: tierOf(r.salience) }));
159
205
  return { atoms, total };
@@ -176,7 +222,9 @@ export function digestForContext(contextDir, opts = {}) {
176
222
  .prepare(
177
223
  // Episodes are the tape, not the digest: a fresh episode is always
178
224
  // salience 1.0 and would crowd Top of Mind with what just happened.
179
- 'SELECT title, category, content, salience FROM memories WHERE archived = 0 AND category != ? ORDER BY salience DESC LIMIT 100')
225
+ // sourcing: LEFT JOIN-style null-safe select — old stores without the
226
+ // column return NULL which DigestRow.sourcing accepts.
227
+ 'SELECT title, category, content, salience, sourcing FROM memories WHERE archived = 0 AND category != ? ORDER BY salience DESC LIMIT 100')
180
228
  .all(EPISODE_CATEGORY);
181
229
  return assembleDigest(rows, opts);
182
230
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Memory scope discipline (t-337b, from the 2026-09-01 affaan-m/ECC teardown).
3
+ *
4
+ * loom has always carried a `project` tag on memories, but nothing stopped a
5
+ * preference learned while working on one project from surfacing while the
6
+ * agent is working on an unrelated one — the tag was metadata, not a filter.
7
+ *
8
+ * Every memory now has an *effective scope*: 'project' (only visible when
9
+ * recalling within the project it was written for) or 'global' (visible
10
+ * everywhere). ECC's instinct-promotion model is the source: default to the
11
+ * narrower scope, promote to global only on deliberate signal — here, an
12
+ * explicit `scope` at write time, since loom has no cross-project id to
13
+ * watch for repetition against.
14
+ *
15
+ * The category split below follows ECC's scope-decision table as far as
16
+ * loom's own category vocabulary lets it: 'user' (a fact about the person)
17
+ * and 'reference' (a pointer to an external system) are inherently
18
+ * cross-project, so they default global even when a project happens to be
19
+ * attached. Every other category defaults to 'project' — including 'self'
20
+ * and 'feedback', where ECC's real split lives (language/framework/style/
21
+ * error-handling feedback is project-specific; security/git/tool-workflow
22
+ * feedback is universal). Category alone can't tell those apart; that's a
23
+ * per-memory authoring call, made with `scope: 'global'` at write time, not
24
+ * a product decision this module can bake in — see t-337's comment thread
25
+ * for the open question on backfilling existing records.
26
+ */
27
+ /** A memory's visibility: 'project' (scoped) or 'global' (everywhere). */
28
+ export type MemoryScope = 'project' | 'global';
29
+ /** Default scope for a category, 'project' for anything not in the table (safe default). */
30
+ export declare function defaultScopeForCategory(category: string): MemoryScope;
31
+ /** The subset of a memory record that scope decisions need. */
32
+ export interface ScopableMemory {
33
+ category: string;
34
+ project?: string | null;
35
+ /** Explicit author override from write time, if any. Anything else is ignored (treated as unset). */
36
+ scope?: string | null;
37
+ }
38
+ /**
39
+ * The scope actually in force for a memory: the explicit override if one was
40
+ * set at write time, else the category default — but only when the memory
41
+ * is tagged to a project at all. A memory with no project has nothing to
42
+ * scope against and is global regardless of category.
43
+ */
44
+ export declare function effectiveScope(mem: ScopableMemory): MemoryScope;
45
+ /**
46
+ * Recall-time enforcement: is `mem` visible to a caller currently working
47
+ * within `recallProject`?
48
+ *
49
+ * `recallProject` null/undefined means the caller isn't scoping their recall
50
+ * to a project — there is nothing to enforce, so every memory stays visible
51
+ * (unchanged pre-t-337 behaviour: an unscoped recall searches everything).
52
+ * Once a project is given: memories tagged to that exact project are always
53
+ * visible, everything else is visible only if its effective scope is
54
+ * 'global'.
55
+ */
56
+ export declare function isVisibleInProject(mem: ScopableMemory, recallProject: string | null | undefined): boolean;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Memory scope discipline (t-337b, from the 2026-09-01 affaan-m/ECC teardown).
3
+ *
4
+ * loom has always carried a `project` tag on memories, but nothing stopped a
5
+ * preference learned while working on one project from surfacing while the
6
+ * agent is working on an unrelated one — the tag was metadata, not a filter.
7
+ *
8
+ * Every memory now has an *effective scope*: 'project' (only visible when
9
+ * recalling within the project it was written for) or 'global' (visible
10
+ * everywhere). ECC's instinct-promotion model is the source: default to the
11
+ * narrower scope, promote to global only on deliberate signal — here, an
12
+ * explicit `scope` at write time, since loom has no cross-project id to
13
+ * watch for repetition against.
14
+ *
15
+ * The category split below follows ECC's scope-decision table as far as
16
+ * loom's own category vocabulary lets it: 'user' (a fact about the person)
17
+ * and 'reference' (a pointer to an external system) are inherently
18
+ * cross-project, so they default global even when a project happens to be
19
+ * attached. Every other category defaults to 'project' — including 'self'
20
+ * and 'feedback', where ECC's real split lives (language/framework/style/
21
+ * error-handling feedback is project-specific; security/git/tool-workflow
22
+ * feedback is universal). Category alone can't tell those apart; that's a
23
+ * per-memory authoring call, made with `scope: 'global'` at write time, not
24
+ * a product decision this module can bake in — see t-337's comment thread
25
+ * for the open question on backfilling existing records.
26
+ */
27
+ /**
28
+ * Default scope by category, applied only when a memory carries a project
29
+ * tag. An untagged memory has nothing to scope against and is global by
30
+ * construction — see `effectiveScope`.
31
+ */
32
+ const CATEGORY_DEFAULT_SCOPE = {
33
+ user: 'global',
34
+ reference: 'global',
35
+ project: 'project',
36
+ pursuit: 'project',
37
+ episode: 'project',
38
+ self: 'project',
39
+ feedback: 'project',
40
+ };
41
+ /** Default scope for a category, 'project' for anything not in the table (safe default). */
42
+ export function defaultScopeForCategory(category) {
43
+ return CATEGORY_DEFAULT_SCOPE[category] ?? 'project';
44
+ }
45
+ /**
46
+ * The scope actually in force for a memory: the explicit override if one was
47
+ * set at write time, else the category default — but only when the memory
48
+ * is tagged to a project at all. A memory with no project has nothing to
49
+ * scope against and is global regardless of category.
50
+ */
51
+ export function effectiveScope(mem) {
52
+ if (mem.scope === 'project' || mem.scope === 'global')
53
+ return mem.scope;
54
+ if (!mem.project)
55
+ return 'global';
56
+ return defaultScopeForCategory(mem.category);
57
+ }
58
+ /**
59
+ * Recall-time enforcement: is `mem` visible to a caller currently working
60
+ * within `recallProject`?
61
+ *
62
+ * `recallProject` null/undefined means the caller isn't scoping their recall
63
+ * to a project — there is nothing to enforce, so every memory stays visible
64
+ * (unchanged pre-t-337 behaviour: an unscoped recall searches everything).
65
+ * Once a project is given: memories tagged to that exact project are always
66
+ * visible, everything else is visible only if its effective scope is
67
+ * 'global'.
68
+ */
69
+ export function isVisibleInProject(mem, recallProject) {
70
+ if (!recallProject)
71
+ return true;
72
+ if ((mem.project ?? null) === recallProject)
73
+ return true;
74
+ return effectiveScope(mem) === 'global';
75
+ }
@@ -1,7 +1,13 @@
1
- import type { KnowledgeBackend, KnowledgePageInput, KnowledgeCitationInput, KnowledgePageWithCitations, KnowledgeQueryInput, KnowledgeWriteResult, KnowledgeArchiveInput, KnowledgeArchiveResult, KnowledgeRestoreInput, KnowledgeRestoreResult, KnowledgeSupersededInput, KnowledgeSupersededResult, KnowledgeMoveInput, KnowledgeMoveResult, KnowledgeMergeInput, KnowledgeMergeResult, KnowledgePurgeInput, KnowledgePurgeResult, KnowledgeVerifyInput, KnowledgeVerifyResult, KnowledgeRevision, KnowledgeRevisionMeta, KnowledgeRevisionRestoreInput, KnowledgeRevisionRestoreResult } from './types.js';
1
+ import type { KnowledgeBackend, KnowledgePageInput, KnowledgeCitationInput, KnowledgePageWithCitations, KnowledgeQueryInput, KnowledgeWriteResult, KnowledgeArchiveInput, KnowledgeArchiveResult, KnowledgeRestoreInput, KnowledgeRestoreResult, KnowledgeSupersededInput, KnowledgeSupersededResult, KnowledgeMoveInput, KnowledgeMoveResult, KnowledgeMergeInput, KnowledgeMergeResult, KnowledgePurgeInput, KnowledgePurgeResult, KnowledgeVerifyInput, KnowledgeVerifyResult, KnowledgeVerificationRecord, KnowledgeRevision, KnowledgeRevisionMeta, KnowledgeRevisionRestoreInput, KnowledgeRevisionRestoreResult, KnowledgeAccessStat } from './types.js';
2
2
  export interface SqliteKnowledgeConfig {
3
3
  /** Absolute path to the knowledge SQLite database file */
4
4
  dbPath: string;
5
+ /**
6
+ * SQLite busy_timeout in ms — how long to retry a locked write before
7
+ * surfacing SQLITE_BUSY. Defaults to 5000; pass a small value in tests
8
+ * that exercise the SQLITE_BUSY path so they don't wait the full window.
9
+ */
10
+ busyTimeoutMs?: number;
5
11
  }
6
12
  export declare class SqliteKnowledgeBackend implements KnowledgeBackend {
7
13
  private readonly config;
@@ -10,6 +16,7 @@ export declare class SqliteKnowledgeBackend implements KnowledgeBackend {
10
16
  writePage(input: KnowledgePageInput): Promise<KnowledgeWriteResult>;
11
17
  getPage(slug: string, opts?: {
12
18
  stampAccess?: boolean;
19
+ identity?: string;
13
20
  }): Promise<KnowledgePageWithCitations | null>;
14
21
  listPages(input?: KnowledgeQueryInput): Promise<KnowledgePageWithCitations[]>;
15
22
  queryPages(input: KnowledgeQueryInput): Promise<KnowledgePageWithCitations[]>;
@@ -20,18 +27,31 @@ export declare class SqliteKnowledgeBackend implements KnowledgeBackend {
20
27
  mergePages(input: KnowledgeMergeInput): Promise<KnowledgeMergeResult>;
21
28
  purgePages(input: KnowledgePurgeInput): Promise<KnowledgePurgeResult>;
22
29
  verifyPages(input: KnowledgeVerifyInput): Promise<KnowledgeVerifyResult>;
30
+ getVerifications(slug: string): Promise<KnowledgeVerificationRecord[]>;
23
31
  listRevisions(slug: string): Promise<KnowledgeRevisionMeta[]>;
24
32
  getRevision(revisionId: number): Promise<KnowledgeRevision | null>;
25
33
  restoreRevision(input: KnowledgeRevisionRestoreInput): Promise<KnowledgeRevisionRestoreResult>;
26
34
  /**
27
- * Insert a body snapshot and prune beyond the per-page cap. Caller owns the
28
- * transaction when called from restoreRevision; called bare from writePage.
29
- * Each statement is wrapped in retryWrite so both call sites are safe.
35
+ * Insert a body snapshot and prune beyond the per-page cap. Always called
36
+ * inside a transaction owned by the caller (writePage or restoreRevision).
37
+ * The inner retryWrite calls run bare statements — safe inside an outer
38
+ * transaction; the outer transaction is the atomicity boundary.
30
39
  */
31
40
  private snapshotRevision;
32
41
  addCitations(slug: string, citations: KnowledgeCitationInput[]): Promise<number>;
42
+ getAccessStats(pageId: number): Promise<KnowledgeAccessStat[]>;
33
43
  close(): void;
44
+ /**
45
+ * Stamp a real read on each page id: bump the shared pages.hit_count /
46
+ * last_accessed aggregate (unchanged legacy behavior — this is what
47
+ * knowledge_maintain's cold/expansion ranking reads) AND upsert the
48
+ * per-identity row in knowledge_access (t-677 attribution). Both updates
49
+ * happen in one transaction so a crash between them can't desync the
50
+ * aggregate from the per-reader breakdown.
51
+ */
52
+ private recordAccess;
34
53
  private ensureOpen;
35
54
  private fetchCitationsForPage;
36
55
  private initSchema;
37
56
  }
57
+ export declare function deriveSlaClass(anchor: string | null | undefined, domain: string): 'device' | 'software' | 'default';