blun-king-cli 9.1.520 → 9.1.525

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 (58) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LIESMICH.txt +4 -2
  3. package/README.md +4 -2
  4. package/agent-spine-plugin/.claude-plugin/marketplace.json +1 -1
  5. package/agent-spine-plugin/.claude-plugin/plugin.json +1 -1
  6. package/agent-spine-plugin/.codex-plugin/plugin.json +1 -2
  7. package/agent-spine-plugin/CHANGELOG.md +81 -14
  8. package/agent-spine-plugin/CONTRIBUTING.md +52 -0
  9. package/agent-spine-plugin/README.md +6 -4
  10. package/agent-spine-plugin/SECURITY.md +47 -0
  11. package/agent-spine-plugin/blun.plugin.json +2 -2
  12. package/agent-spine-plugin/docs/acceptance.md +2 -2
  13. package/agent-spine-plugin/docs/architecture.md +1 -1
  14. package/agent-spine-plugin/docs/gateway-runtime.md +2 -2
  15. package/agent-spine-plugin/docs/host-integration.md +2 -2
  16. package/agent-spine-plugin/docs/learning.md +37 -2
  17. package/agent-spine-plugin/docs/preflight-recall.md +1 -1
  18. package/agent-spine-plugin/docs/quality-gates.md +1 -1
  19. package/agent-spine-plugin/docs/relationships.md +1 -1
  20. package/agent-spine-plugin/docs/session-briefing.md +1 -1
  21. package/agent-spine-plugin/docs/source-roots.md +1 -1
  22. package/agent-spine-plugin/hooks/codex.json +1 -1
  23. package/agent-spine-plugin/hooks/version.json +1 -1
  24. package/agent-spine-plugin/package.json +1 -1
  25. package/agent-spine-plugin/scripts/check-hosts.js +2 -2
  26. package/agent-spine-plugin/skills/agent-spine/SKILL.md +3 -3
  27. package/agent-spine-plugin/src/cli.js +69 -5
  28. package/agent-spine-plugin/src/hook.js +41 -23
  29. package/agent-spine-plugin/src/index.js +2 -1
  30. package/agent-spine-plugin/src/lib/acceptance.js +2 -3
  31. package/agent-spine-plugin/src/lib/attention.js +4 -4
  32. package/agent-spine-plugin/src/lib/audit.js +12 -3
  33. package/agent-spine-plugin/src/lib/authentication.js +4 -2
  34. package/agent-spine-plugin/src/lib/briefing.js +20 -8
  35. package/agent-spine-plugin/src/lib/catalog.js +28 -3
  36. package/agent-spine-plugin/src/lib/channel-runtime.js +2 -2
  37. package/agent-spine-plugin/src/lib/continuity.js +18 -13
  38. package/agent-spine-plugin/src/lib/documents.js +23 -5
  39. package/agent-spine-plugin/src/lib/feed-transport.js +4 -2
  40. package/agent-spine-plugin/src/lib/graph.js +47 -12
  41. package/agent-spine-plugin/src/lib/learning.js +422 -48
  42. package/agent-spine-plugin/src/lib/paths.js +50 -1
  43. package/agent-spine-plugin/src/lib/persona-runtime.js +20 -14
  44. package/agent-spine-plugin/src/lib/preflight.js +50 -26
  45. package/agent-spine-plugin/src/lib/source-roots.js +38 -7
  46. package/agent-spine-plugin/src/mcp.js +50 -4
  47. package/agent-spine-plugin/src/version.js +1 -1
  48. package/bin/telegram-approval-relay.cjs +2 -0
  49. package/blun.mjs +111 -8
  50. package/package.json +8 -2
  51. package/scripts/check-approval-observability-regression.js +111 -0
  52. package/scripts/check-bundled-agent-spine-regression.js +48 -0
  53. package/scripts/check-session-picker-resume-metrics-regression.js +97 -0
  54. package/scripts/check-session-start-hook-context-regression.js +54 -4
  55. package/scripts/check-shell-terminal-isolation-regression.js +81 -0
  56. package/scripts/check-slash-escape-regression.js +89 -0
  57. package/scripts/check-telegram-loop-exactly-once-regression.js +71 -0
  58. package/telegram-plugin/bin/telegram-approval-relay.cjs +2 -1
@@ -1,4 +1,4 @@
1
- import { buildCatalog } from "./catalog.js";
1
+ import { buildCatalog, catalogForStateRoot } from "./catalog.js";
2
2
  import { resolveContext } from "./context.js";
3
3
  import { relationshipContext } from "./graph.js";
4
4
  import { learningContext } from "./learning.js";
@@ -107,7 +107,7 @@ async function settleReads(promises) {
107
107
  */
108
108
  export async function sessionBriefing({
109
109
  root = process.cwd(), cwd = root, host = "generic", entityId = null,
110
- groupId = null, projectId = null, currentTaskId = null,
110
+ userId = null, tenantId = null, groupId = null, projectId = null, currentTaskId = null,
111
111
  includePrivate = false, focusActive = true, includeSourceContent = true,
112
112
  maxBytes = 16384, now = new Date(), catalog: providedCatalog = null, userStateRoot = null,
113
113
  sourceDiagnostics = null, prompt = null
@@ -123,13 +123,18 @@ export async function sessionBriefing({
123
123
  catalog
124
124
  });
125
125
  const portableRelationship = Boolean(entityId && userStateRoot && userStateRoot !== catalog.root);
126
+ const userCatalog = userStateRoot && userStateRoot !== catalog.root
127
+ ? catalogForStateRoot(catalog, userStateRoot) : catalog;
128
+ const learningScope = {
129
+ personaId: entityId, userId, tenantId, projectId, groupId, taskId: currentTaskId
130
+ };
126
131
  const relationship = entityId
127
132
  ? await relationshipContext(userStateRoot && userStateRoot !== catalog.root
128
- ? { root: userStateRoot, entityId, includePrivate, groupId }
133
+ ? { root: userStateRoot, entityId, includePrivate, groupId, catalog: userCatalog }
129
134
  : { root: catalog.root, entityId, includePrivate, groupId, catalog })
130
135
  : null;
131
136
  const [learned, attention, tasks, shared, userLearned, personas, gateway] = await settleReads([
132
- learningContext({ root: catalog.root, includePrivate, groupId, maxItems: 50, catalog }),
137
+ learningContext({ root: catalog.root, includePrivate, groupId, scope: learningScope, maxItems: 50, catalog, now }),
133
138
  attentionContext({
134
139
  root: catalog.root, includePrivate, entityId, groupId, projectId, currentTaskId,
135
140
  focusActive, markPresented: false, maxItems: 20, now, catalog
@@ -137,7 +142,8 @@ export async function sessionBriefing({
137
142
  taskContext({ root: catalog.root, includePrivate, groupId, projectId, includeClosed: false, maxItems: 100, catalog }),
138
143
  sharedContext({ root: catalog.root, includePrivate, groupId, maxItems: 50, catalog }),
139
144
  userStateRoot && userStateRoot !== catalog.root
140
- ? learningContext({ root: userStateRoot, includePrivate, groupId, maxItems: 50 })
145
+ ? learningContext({ root: userStateRoot, includePrivate, groupId, scope: learningScope, maxItems: 50, now,
146
+ catalog: userCatalog })
141
147
  : Promise.resolve({ items: [] }),
142
148
  loadPersonaRuntime(catalog.root, catalog),
143
149
  loadGatewayRuntime(catalog.root, catalog)
@@ -157,11 +163,17 @@ export async function sessionBriefing({
157
163
  root: catalog.root,
158
164
  cwd: sources.cwd,
159
165
  host,
160
- scope: { entityId, groupId, projectId, includePrivate },
166
+ scope: { entityId, userId, tenantId, groupId, projectId, includePrivate },
161
167
  focus: { active: Boolean(focusActive), currentTaskId },
162
168
  sources: { documents: [], diagnostics: sourceDiagnostics },
163
169
  tasks: [],
164
- relationship: relationship ? { entity: null, relatedEntities: [], edges: [] } : null,
170
+ relationship: relationship ? {
171
+ status: relationship.status || "loaded",
172
+ reason: relationship.reason || null,
173
+ entity: null,
174
+ relatedEntities: [],
175
+ edges: []
176
+ } : null,
165
177
  voiceBrief: {
166
178
  schema: "agentspine.voice-brief/v1",
167
179
  personaId: entityId,
@@ -231,7 +243,7 @@ export async function sessionBriefing({
231
243
  if (binding?.sourceBinding && !tryAdd(result, result.voiceBrief.personaSources, binding.sourceBinding)) countOmitted(result, "voice");
232
244
  }
233
245
 
234
- if (relationship) {
246
+ if (relationship && relationship.status !== "degraded") {
235
247
  if (!trySet(result, result.relationship, "entity", relationship.entity)) countOmitted(result, "relationships");
236
248
  if (!registeredPersona && !trySet(result, result.voiceBrief, "displayName", relationship.entity.displayName || null)) countOmitted(result, "voice");
237
249
  const language = typeof relationship.entity.attributes?.language === "string"
@@ -2,12 +2,36 @@ import { randomUUID } from "node:crypto";
2
2
  import { open, readFile, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
- import { canonicalPath, projectStateDir } from "./paths.js";
5
+ import { canonicalPath, comparablePath, isInside, projectStateDir, stateRoot } from "./paths.js";
6
6
  import { discoverDocuments } from "./documents.js";
7
7
  import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
8
8
 
9
9
  export const CATALOG_SCHEMA = "agentspine.catalog/v1";
10
10
 
11
+ export function catalogScanPolicy(root, env = process.env) {
12
+ return {
13
+ stateRoot: isInside(comparablePath(root), comparablePath(stateRoot(env))) ? "excluded" : "outside",
14
+ authority: "scanner-boundary-only"
15
+ };
16
+ }
17
+
18
+ export function catalogForStateRoot(catalog, root) {
19
+ if (!catalog || catalog.schema !== CATALOG_SCHEMA || typeof root !== "string" || !root) {
20
+ throw new Error("a resolved catalog and state root are required");
21
+ }
22
+ if (catalog.root === root) return catalog;
23
+ return {
24
+ schema: CATALOG_SCHEMA,
25
+ generatedAt: catalog.generatedAt,
26
+ root,
27
+ scanPolicy: catalogScanPolicy(root),
28
+ preservation: "source-files-are-read-only",
29
+ documents: [],
30
+ conflicts: [],
31
+ summary: { total: 0, protected: 0, conflicts: 0, byLayer: {} }
32
+ };
33
+ }
34
+
11
35
  function catalogConflicts(documents) {
12
36
  const byPath = new Map(documents.map((document) => [document.relativePath, document]));
13
37
  const conflicts = [];
@@ -41,9 +65,9 @@ function catalogConflicts(documents) {
41
65
  return conflicts.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
42
66
  }
43
67
 
44
- export async function buildCatalog(inputRoot = process.cwd()) {
68
+ export async function buildCatalog(inputRoot = process.cwd(), { env = process.env } = {}) {
45
69
  const root = await canonicalPath(inputRoot);
46
- const documents = await discoverDocuments(root);
70
+ const documents = await discoverDocuments(root, { excludeRoots: [stateRoot(env)] });
47
71
  const conflicts = catalogConflicts(documents);
48
72
  const byLayer = Object.fromEntries(
49
73
  [...new Set(documents.map((document) => document.layer))]
@@ -54,6 +78,7 @@ export async function buildCatalog(inputRoot = process.cwd()) {
54
78
  schema: CATALOG_SCHEMA,
55
79
  generatedAt: new Date().toISOString(),
56
80
  root,
81
+ scanPolicy: catalogScanPolicy(root, env),
57
82
  preservation: "source-files-are-read-only",
58
83
  documents,
59
84
  conflicts,
@@ -531,7 +531,7 @@ function activeBinding(policy, event) {
531
531
 
532
532
  export async function claimChannelEvent({
533
533
  root = process.cwd(), agentId, projectId, groupId = null, provider, workerId,
534
- eventId = null, leaseSeconds = 120, now = new Date()
534
+ eventId = null, leaseSeconds = 120, now = new Date(), catalog: providedCatalog = null
535
535
  }) {
536
536
  agentId = stableId(agentId, "agentId");
537
537
  projectId = stableId(projectId, "projectId");
@@ -541,7 +541,7 @@ export async function claimChannelEvent({
541
541
  eventId = stableId(eventId, "eventId", { nullable: true });
542
542
  const seconds = Number(leaseSeconds);
543
543
  if (!Number.isInteger(seconds) || seconds < 15 || seconds > 900) throw new Error("leaseSeconds must be an integer between 15 and 900");
544
- const paths = await pathsFor(root);
544
+ const paths = await pathsFor(root, providedCatalog);
545
545
  return withChannelLock(paths, async () => {
546
546
  const [policy, runtime, { graph }] = await Promise.all([
547
547
  readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
@@ -137,8 +137,8 @@ export async function inspectContinuity(root = process.cwd(), providedCatalog =
137
137
  }
138
138
  }
139
139
 
140
- async function mutate(root, task) {
141
- const catalog = await buildCatalog(root);
140
+ async function mutate(root, task, providedCatalog = null) {
141
+ const catalog = providedCatalog || await buildCatalog(root);
142
142
  const { continuityPath } = await loadContinuity(catalog.root, catalog);
143
143
  return withLock(continuityPath, catalog.root, (state) => task(state, catalog, continuityPath));
144
144
  }
@@ -209,9 +209,12 @@ function assertSafeSignal(signal, prompt, scope) {
209
209
 
210
210
  export async function captureContinuityPrompt({
211
211
  root = process.cwd(), prompt, entityId = null, groupId = null, projectId = null,
212
- eventId = null, now = new Date(), userStateRoot = null
212
+ eventId = null, now = new Date(), userStateRoot = null, catalog: providedCatalog = null,
213
+ userCatalog: providedUserCatalog = null
213
214
  }) {
214
- const { continuity, catalog } = await loadContinuity(userStateRoot || root);
215
+ const storageCatalog = userStateRoot && userStateRoot !== root
216
+ ? providedUserCatalog : providedCatalog;
217
+ const { continuity, catalog } = await loadContinuity(userStateRoot || root, storageCatalog);
215
218
  if (!continuity.config.enabled) return { enabled: false, captured: false, reason: "opt-in-disabled", authority: "context-only" };
216
219
  if (typeof prompt !== "string" || Buffer.byteLength(prompt) > continuity.config.maxPromptBytes) {
217
220
  throw new Error("prompt payload is missing or exceeds the configured byte limit");
@@ -222,8 +225,9 @@ export async function captureContinuityPrompt({
222
225
  const subjectId = detected.kind === "project-fact" ? projectId : entityId || projectId;
223
226
  if (!subjectId || !ID_RE.test(subjectId)) throw new Error("automatic learning requires an exact known person or project identity");
224
227
  if (groupId !== null && !ID_RE.test(groupId)) throw new Error("groupId is invalid");
225
- const storageCatalog = storageRoot === catalog.root ? catalog : await buildCatalog(storageRoot);
226
- const { graph } = await loadGraph(storageCatalog.root, storageCatalog);
228
+ const targetCatalog = storageRoot === catalog.root ? catalog
229
+ : providedCatalog || await buildCatalog(storageRoot);
230
+ const { graph } = await loadGraph(targetCatalog.root, targetCatalog);
227
231
  const subject = graph.entities.find((item) => item.id === subjectId);
228
232
  if (!subject) throw new Error(`automatic learning requires a known canonical identity: ${subjectId}`);
229
233
  if (subjectId === projectId && subject.kind !== "project") throw new Error("projectId must identify a project");
@@ -248,7 +252,7 @@ export async function captureContinuityPrompt({
248
252
  });
249
253
  state.history.push({ kind: "signal-observed", eventKey, learningId, subjectId, observedAt: at, authority: "context-only" });
250
254
  return { duplicate: false, disabled: false, config: state.config, continuityPath, catalog };
251
- });
255
+ }, targetCatalog);
252
256
  if (recorded.disabled) return { enabled: false, captured: false, reason: "opt-in-disabled", authority: "context-only" };
253
257
 
254
258
  const evidence = {
@@ -258,26 +262,27 @@ export async function captureContinuityPrompt({
258
262
  confidence: detected.confidence,
259
263
  observedAt: at
260
264
  };
261
- let existing = (await loadLearning(storageRoot)).learning.candidates.find((item) => item.id === learningId);
265
+ let existing = (await loadLearning(storageRoot, targetCatalog)).learning.candidates.find((item) => item.id === learningId);
262
266
  if (!existing) {
263
267
  try {
264
268
  await proposeLearning({
265
269
  root: storageRoot, id: learningId, kind: detected.kind, claim: detected.claim, subjectId,
266
- privacy: subjectId === projectId ? "shared" : "private", groupId: null, evidence, now: at
270
+ privacy: subjectId === projectId ? "shared" : "private", groupId: null, evidence, now: at,
271
+ catalog: targetCatalog
267
272
  });
268
273
  } catch (error) {
269
274
  if (!/candidate IDs are immutable/.test(error.message)) throw error;
270
275
  }
271
- existing = (await loadLearning(storageRoot)).learning.candidates.find((item) => item.id === learningId);
276
+ existing = (await loadLearning(storageRoot, targetCatalog)).learning.candidates.find((item) => item.id === learningId);
272
277
  }
273
278
  if (existing?.status === "candidate" && !existing.evidence.some((item) => item.id === evidence.id)) {
274
279
  try {
275
- await addLearningEvidence({ root: storageRoot, id: learningId, evidence, now: at });
280
+ await addLearningEvidence({ root: storageRoot, id: learningId, evidence, now: at, catalog: targetCatalog });
276
281
  } catch (error) {
277
282
  if (!/duplicate evidence id|unreviewed candidate/.test(error.message)) throw error;
278
283
  }
279
284
  }
280
- const refreshed = (await loadLearning(storageRoot)).learning.candidates.find((item) => item.id === learningId);
285
+ const refreshed = (await loadLearning(storageRoot, targetCatalog)).learning.candidates.find((item) => item.id === learningId);
281
286
  let accepted = refreshed?.status === "accepted";
282
287
  if (!accepted && refreshed?.status === "candidate") {
283
288
  const distinct = new Set(refreshed.evidence.map((item) => item.id)).size;
@@ -286,7 +291,7 @@ export async function captureContinuityPrompt({
286
291
  && detected.directness >= recorded.config.minDirectness
287
292
  && distinct >= requiredEvidence) {
288
293
  await acceptContinuityLearning({
289
- root: storageRoot, id: learningId, now: at,
294
+ root: storageRoot, id: learningId, now: at, catalog: targetCatalog,
290
295
  proof: {
291
296
  mode: "automatic-continuity-low-risk", localOptIn: true,
292
297
  minConfidence: recorded.config.minConfidence,
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises";
3
- import { basename, dirname, extname, join, relative, resolve } from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
 
5
5
  const SKIP_DIRS = new Set([
6
6
  ".git", ".hg", ".svn", ".next", ".nuxt", ".turbo", ".venv",
@@ -132,7 +132,12 @@ export function extractMarkdownLinks(text, filePath, root) {
132
132
  return [...links].sort();
133
133
  }
134
134
 
135
- async function walk(directory, found) {
135
+ function isInside(parent, child) {
136
+ const value = relative(parent, child);
137
+ return value === "" || (!isAbsolute(value) && !value.startsWith(`..${sep}`) && value !== "..");
138
+ }
139
+
140
+ async function walk(directory, found, excludedRoots) {
136
141
  const entries = [];
137
142
  for await (const entry of await opendir(directory)) entries.push(entry);
138
143
  entries.sort((a, b) => a.name.localeCompare(b.name));
@@ -140,16 +145,29 @@ async function walk(directory, found) {
140
145
  if (entry.isSymbolicLink()) continue;
141
146
  const fullPath = join(directory, entry.name);
142
147
  if (entry.isDirectory()) {
143
- if (!SKIP_DIRS.has(entry.name)) await walk(fullPath, found);
148
+ if (!SKIP_DIRS.has(entry.name) && !excludedRoots.some((root) => isInside(root, fullPath))) {
149
+ await walk(fullPath, found, excludedRoots);
150
+ }
144
151
  } else if (entry.isFile() && extname(entry.name).toLowerCase() === ".md") {
145
152
  found.push(fullPath);
146
153
  }
147
154
  }
148
155
  }
149
156
 
150
- export async function discoverDocuments(root) {
157
+ export async function discoverDocuments(root, { excludeRoots = [] } = {}) {
151
158
  const files = [];
152
- await walk(root, files);
159
+ // Canonicalize existing exclusion roots just like the scanned root. On macOS
160
+ // temporary directories commonly cross /var -> /private/var, while Windows
161
+ // may expose the same directory through differently cased/normalized paths.
162
+ // Comparing only resolved spellings would therefore walk the private state
163
+ // directory even though it is the configured exclusion boundary.
164
+ const excludedRoots = (await Promise.all(excludeRoots.map(async (path) => {
165
+ try { return await realpath(resolve(path)); } catch (error) {
166
+ if (error.code === "ENOENT") return resolve(path);
167
+ throw error;
168
+ }
169
+ }))).filter((path) => isInside(root, path));
170
+ await walk(root, files, excludedRoots);
153
171
  const documents = new Array(files.length);
154
172
  let cursor = 0;
155
173
  async function worker() {
@@ -15,7 +15,7 @@ import {
15
15
  import {
16
16
  httpsObjectUrl, putHttpsSnapshot, validateHttpsObjectBase
17
17
  } from "./object-transport.js";
18
- import { isInside, projectStateDir } from "./paths.js";
18
+ import { projectStateDir, statePathIsScanExcluded } from "./paths.js";
19
19
 
20
20
  const SCHEMA = "agentspine.feed/v1";
21
21
  const STATE_SCHEMA = "agentspine.feed-state/v1";
@@ -358,7 +358,9 @@ function validateState(state, root) {
358
358
  async function feedStatePaths(root) {
359
359
  const catalog = await buildCatalog(root);
360
360
  const directory = await projectStateDir(catalog.root);
361
- if (isInside(catalog.root, directory)) throw new Error("HTTPS feed state must remain outside the scanned project");
361
+ if (!statePathIsScanExcluded(catalog, directory)) {
362
+ throw new Error("HTTPS feed state must remain outside the scanned project or in an excluded home-state root");
363
+ }
362
364
  return { root: catalog.root, path: join(directory, "feed-state.json") };
363
365
  }
364
366
 
@@ -1,7 +1,7 @@
1
1
  import { readFile, rename, stat, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { buildCatalog } from "./catalog.js";
4
- import { projectStateDir } from "./paths.js";
4
+ import { canonicalPath, projectStateDir } from "./paths.js";
5
5
 
6
6
  const RELATIONS = new Set([
7
7
  "loads", "belongs-to", "explains", "supports", "related",
@@ -100,6 +100,21 @@ export async function loadGraph(root, providedCatalog = null) {
100
100
  }
101
101
  }
102
102
 
103
+ async function loadRelationshipGraph(root, providedCatalog = null, { signal = null } = {}) {
104
+ const canonicalRoot = providedCatalog?.root || await canonicalPath(root);
105
+ const directory = await projectStateDir(canonicalRoot);
106
+ const path = join(directory, "graph.json");
107
+ try {
108
+ const metadata = await stat(path);
109
+ if (metadata.size > 5 * 1024 * 1024) throw new Error("relationship graph exceeds the 5 MiB read limit");
110
+ const content = await readFile(path, { encoding: "utf8", signal });
111
+ return { graph: normalizeGraph(JSON.parse(content), canonicalRoot), graphPath: path };
112
+ } catch (error) {
113
+ if (error.code !== "ENOENT") throw error;
114
+ return { graph: emptyGraph(canonicalRoot), graphPath: path };
115
+ }
116
+ }
117
+
103
118
  async function saveGraph(graph, path) {
104
119
  const temporary = `${path}.${process.pid}.tmp`;
105
120
  const content = `${JSON.stringify(graph, null, 2)}\n`;
@@ -159,13 +174,13 @@ export async function annotateDocument({ root = process.cwd(), path, layer, reas
159
174
 
160
175
  export async function upsertEntity({
161
176
  root = process.cwd(), id, kind, displayName = "", aliases = [],
162
- attributes = {}, sourceDocument = null, confidence = 0.5, privacy = "private"
177
+ attributes = {}, sourceDocument = null, confidence = 0.5, privacy = "private", catalog: providedCatalog = null
163
178
  }) {
164
179
  if (!id || !/^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/.test(id)) throw new Error("id must be a stable, whitespace-free identifier");
165
180
  if (!ENTITY_KINDS.has(kind)) throw new Error(`unsupported entity kind: ${kind}`);
166
181
  if (!["private", "shared", "group"].includes(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
167
182
  if (!attributes || Array.isArray(attributes) || typeof attributes !== "object") throw new Error("attributes must be an object");
168
- const { graph, graphPath, catalog } = await loadGraph(root);
183
+ const { graph, graphPath, catalog } = await loadGraph(root, providedCatalog);
169
184
  if (sourceDocument !== null) {
170
185
  sourceDocument = normalizeRelativePath(sourceDocument, "sourceDocument");
171
186
  if (!catalog.documents.some((document) => document.relativePath === sourceDocument)) {
@@ -192,10 +207,11 @@ export async function upsertEntity({
192
207
  return { entity, graphPath };
193
208
  }
194
209
 
195
- export async function linkEntities({ root = process.cwd(), from, to, relation = "related", reason = "", confidence = 0.5, privacy = "private" }) {
210
+ export async function linkEntities({ root = process.cwd(), from, to, relation = "related", reason = "", confidence = 0.5,
211
+ privacy = "private", catalog: providedCatalog = null }) {
196
212
  if (!ENTITY_RELATIONS.has(relation)) throw new Error(`unsupported entity relation: ${relation}`);
197
213
  if (!["private", "shared", "group"].includes(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
198
- const { graph, graphPath } = await loadGraph(root);
214
+ const { graph, graphPath } = await loadGraph(root, providedCatalog);
199
215
  const known = new Set(graph.entities.map((entity) => entity.id));
200
216
  if (!known.has(from)) throw new Error(`unknown source entity: ${from}`);
201
217
  if (!known.has(to)) throw new Error(`unknown target entity: ${to}`);
@@ -218,9 +234,9 @@ export async function linkEntities({ root = process.cwd(), from, to, relation =
218
234
  return { edge, graphPath };
219
235
  }
220
236
 
221
- export async function unlinkEntities({ root = process.cwd(), from, to, relation = "related" }) {
237
+ export async function unlinkEntities({ root = process.cwd(), from, to, relation = "related", catalog: providedCatalog = null }) {
222
238
  if (!ENTITY_RELATIONS.has(relation)) throw new Error(`unsupported entity relation: ${relation}`);
223
- const { graph, graphPath } = await loadGraph(root);
239
+ const { graph, graphPath } = await loadGraph(root, providedCatalog);
224
240
  const previous = graph.entityEdges.find((item) => item.from === from && item.to === to && item.relation === relation);
225
241
  if (!previous) return { removed: null, duplicate: true, graphPath };
226
242
  preservePrevious(graph, "entity-edge", previous);
@@ -241,9 +257,9 @@ function relationshipAudience(graph, groupId) {
241
257
 
242
258
  async function assembleRelationshipContext({
243
259
  root = process.cwd(), entityId, includePrivate = false, groupId = null, catalog: providedCatalog = null
244
- }, loadGraphImpl) {
260
+ }, loadGraphImpl, signal) {
245
261
  if (!entityId) throw new Error("entityId is required");
246
- const { graph } = await loadGraphImpl(root, providedCatalog);
262
+ const { graph } = await loadGraphImpl(root, providedCatalog, { signal });
247
263
  if (groupId !== null) {
248
264
  const group = graph.entities.find((item) => item.id === groupId && item.kind === "group");
249
265
  if (!group) throw new Error(`unknown group entity: ${groupId}`);
@@ -289,14 +305,33 @@ async function assembleRelationshipContext({
289
305
  };
290
306
  }
291
307
 
292
- export async function relationshipContext(options = {}, { loadGraphImpl = loadGraph, timeoutMs = 5000 } = {}) {
308
+ export async function relationshipContext(options = {}, { loadGraphImpl = loadRelationshipGraph, timeoutMs = 5000 } = {}) {
309
+ const controller = new AbortController();
293
310
  let timer;
311
+ const timeoutError = new Error(`relationship context exceeded its ${timeoutMs} ms local read limit`);
294
312
  const deadline = new Promise((_resolve, reject) => {
295
- timer = setTimeout(() => reject(new Error(`relationship context exceeded its ${timeoutMs} ms local read limit`)), timeoutMs);
313
+ timer = setTimeout(() => {
314
+ reject(timeoutError);
315
+ controller.abort();
316
+ }, timeoutMs);
296
317
  });
297
318
  try {
298
- return await Promise.race([assembleRelationshipContext(options, loadGraphImpl), deadline]);
319
+ return await Promise.race([assembleRelationshipContext(options, loadGraphImpl, controller.signal), deadline]);
320
+ } catch (error) {
321
+ if (error !== timeoutError) throw error;
322
+ return {
323
+ status: "degraded",
324
+ reason: "local-state-timeout",
325
+ timeoutMs,
326
+ entity: null,
327
+ relatedEntities: [],
328
+ edges: [],
329
+ history: [],
330
+ groupId: options.groupId ?? null,
331
+ authority: "context-only"
332
+ };
299
333
  } finally {
300
334
  clearTimeout(timer);
335
+ controller.abort();
301
336
  }
302
337
  }