blun-king-cli 9.1.563 → 9.1.565
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.
- package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
- package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
- package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
- package/agent-spine-plugin/.mcp.json +8 -8
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/LICENSE +186 -186
- package/agent-spine-plugin/README.md +394 -0
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
- package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
- package/agent-spine-plugin/bin/agentspine.js +7 -7
- package/agent-spine-plugin/blun.plugin.json +33 -33
- package/agent-spine-plugin/hooks/codex.json +47 -47
- package/agent-spine-plugin/hooks/hooks.json +106 -106
- package/agent-spine-plugin/hooks/version.json +5 -5
- package/agent-spine-plugin/package.json +69 -69
- package/agent-spine-plugin/scripts/check-hosts.js +199 -199
- package/agent-spine-plugin/skill/SKILL.md +76 -76
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
- package/agent-spine-plugin/src/cli.js +1488 -1442
- package/agent-spine-plugin/src/hook.js +886 -812
- package/agent-spine-plugin/src/index.js +93 -93
- package/agent-spine-plugin/src/lib/acceptance.js +333 -333
- package/agent-spine-plugin/src/lib/attention.js +755 -755
- package/agent-spine-plugin/src/lib/audit.js +351 -342
- package/agent-spine-plugin/src/lib/authentication.js +515 -515
- package/agent-spine-plugin/src/lib/briefing.js +317 -317
- package/agent-spine-plugin/src/lib/catalog.js +167 -167
- package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
- package/agent-spine-plugin/src/lib/context.js +154 -154
- package/agent-spine-plugin/src/lib/continuity.js +338 -338
- package/agent-spine-plugin/src/lib/coordination.js +577 -577
- package/agent-spine-plugin/src/lib/documents.js +217 -217
- package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
- package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
- package/agent-spine-plugin/src/lib/graph.js +337 -337
- package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
- package/agent-spine-plugin/src/lib/https-transport.js +392 -392
- package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
- package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
- package/agent-spine-plugin/src/lib/learning.js +6923 -6466
- package/agent-spine-plugin/src/lib/object-transport.js +206 -206
- package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
- package/agent-spine-plugin/src/lib/paths.js +109 -109
- package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
- package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
- package/agent-spine-plugin/src/lib/preflight.js +702 -702
- package/agent-spine-plugin/src/lib/runtime.js +13 -13
- package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
- package/agent-spine-plugin/src/lib/sharing.js +969 -969
- package/agent-spine-plugin/src/lib/source-roots.js +529 -482
- package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
- package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
- package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
- package/agent-spine-plugin/src/mcp.js +597 -572
- package/agent-spine-plugin/src/version.js +1 -1
- package/agent-spine-plugin/src/worker.js +202 -195
- package/bin/active-steer-priority-policy.cjs +24 -0
- package/bin/launcher-runtime.js +8 -1
- package/bin/mnemo-tool-agent-policy.cjs +22 -0
- package/bin/thinking-activity-status-policy.cjs +132 -0
- package/bin/thinking-only-guard.cjs +75 -0
- package/bin/tool-call-loop-policy.cjs +53 -0
- package/bin/turn-thinking-policy.cjs +25 -1
- package/blun.mjs +554 -64
- package/package.json +4 -1
- package/standard-skills/translate-native/LICENSE +21 -21
- package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
- package/standard-skills/translate-native/references/native-orthography.md +79 -79
- package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
- package/standard-skills/translate-native/references/structured-content.md +72 -72
- package/standard-skills/translate-native/references/translationese-review.md +77 -77
- package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
- package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
- package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
- package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
- package/standard-skills/translate-native/scripts/language_quality.py +377 -377
- package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
- package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
- package/standard-tools/language-guard/blun_language_guard.py +697 -697
- package/standard-tools/language-guard/check_diacritics.py +353 -353
- package/standard-tools/language-guard/guard_service_client.py +264 -264
- package/standard-tools/language-guard/language_quality.py +377 -377
- package/standard-tools/language-guard/translation_guard.py +916 -916
|
@@ -1,337 +1,337 @@
|
|
|
1
|
-
import { readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { buildCatalog } from "./catalog.js";
|
|
4
|
-
import { canonicalPath, projectStateDir } from "./paths.js";
|
|
5
|
-
|
|
6
|
-
const RELATIONS = new Set([
|
|
7
|
-
"loads", "belongs-to", "explains", "supports", "related",
|
|
8
|
-
"contradicts", "supersedes-in-relevance"
|
|
9
|
-
]);
|
|
10
|
-
|
|
11
|
-
const ENTITY_KINDS = new Set(["person", "agent", "group", "channel", "project"]);
|
|
12
|
-
const ENTITY_RELATIONS = new Set([
|
|
13
|
-
"knows", "works-with", "member-of", "communicates-via",
|
|
14
|
-
"responsible-for", "reports-to", "related"
|
|
15
|
-
]);
|
|
16
|
-
const ANNOTATION_LAYERS = new Set([
|
|
17
|
-
"soul", "memory-index", "memory-fact", "reference", "project-reference",
|
|
18
|
-
"identity", "voice", "conduct", "history"
|
|
19
|
-
]);
|
|
20
|
-
const FORBIDDEN_ATTRIBUTE_KEYS = /^(permissions?|rights?|authorization|credentials?|secrets?|tokens?|api[-_]?keys?)$/i;
|
|
21
|
-
|
|
22
|
-
function emptyGraph(root) {
|
|
23
|
-
return {
|
|
24
|
-
schema: "agentspine.graph/v1",
|
|
25
|
-
root,
|
|
26
|
-
edges: [],
|
|
27
|
-
annotations: [],
|
|
28
|
-
entities: [],
|
|
29
|
-
entityEdges: [],
|
|
30
|
-
history: []
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function normalizeGraph(value, root) {
|
|
35
|
-
const graph = value && typeof value === "object" ? value : emptyGraph(root);
|
|
36
|
-
graph.schema = "agentspine.graph/v1";
|
|
37
|
-
graph.root = root;
|
|
38
|
-
for (const key of ["edges", "annotations", "entities", "entityEdges", "history"]) {
|
|
39
|
-
if (!Array.isArray(graph[key])) graph[key] = [];
|
|
40
|
-
}
|
|
41
|
-
return graph;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function preservePrevious(graph, kind, value) {
|
|
45
|
-
if (!value) return;
|
|
46
|
-
graph.history.push({
|
|
47
|
-
kind,
|
|
48
|
-
supersededAt: new Date().toISOString(),
|
|
49
|
-
value: { ...value, authority: "context-only" },
|
|
50
|
-
privacy: value.privacy || "private",
|
|
51
|
-
authority: "context-only"
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function normalizeRelativePath(value, field) {
|
|
56
|
-
if (!value || typeof value !== "string") throw new Error(`${field} is required`);
|
|
57
|
-
const normalized = value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
58
|
-
if (normalized.startsWith("/") || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
59
|
-
throw new Error(`${field} must be a project-relative path`);
|
|
60
|
-
}
|
|
61
|
-
return normalized;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function normalizeConfidence(value) {
|
|
65
|
-
const number = Number(value);
|
|
66
|
-
if (!Number.isFinite(number)) throw new Error("confidence must be a finite number");
|
|
67
|
-
return Math.max(0, Math.min(1, number));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function validateAttributes(value, path = "attributes") {
|
|
71
|
-
if (value === null || value === undefined) return {};
|
|
72
|
-
if (Array.isArray(value)) return value.map((item, index) => validateAttributes(item, `${path}[${index}]`));
|
|
73
|
-
if (typeof value !== "object") return value;
|
|
74
|
-
const result = {};
|
|
75
|
-
for (const [key, item] of Object.entries(value)) {
|
|
76
|
-
if (FORBIDDEN_ATTRIBUTE_KEYS.test(key)) {
|
|
77
|
-
throw new Error(`${path}.${key} is authority data and cannot be stored in relationship memory`);
|
|
78
|
-
}
|
|
79
|
-
result[key] = validateAttributes(item, `${path}.${key}`);
|
|
80
|
-
}
|
|
81
|
-
if (Buffer.byteLength(JSON.stringify(result)) > 16384) throw new Error("attributes exceed 16 KiB");
|
|
82
|
-
return result;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export async function loadGraph(root, providedCatalog = null) {
|
|
86
|
-
const catalog = providedCatalog || await buildCatalog(root);
|
|
87
|
-
const directory = await projectStateDir(catalog.root);
|
|
88
|
-
const path = join(directory, "graph.json");
|
|
89
|
-
try {
|
|
90
|
-
const metadata = await stat(path);
|
|
91
|
-
if (metadata.size > 5 * 1024 * 1024) throw new Error("relationship graph exceeds the 5 MiB read limit");
|
|
92
|
-
return { graph: normalizeGraph(JSON.parse(await readFile(path, "utf8")), catalog.root), graphPath: path, catalog };
|
|
93
|
-
} catch (error) {
|
|
94
|
-
if (error.code !== "ENOENT") throw error;
|
|
95
|
-
return {
|
|
96
|
-
graph: emptyGraph(catalog.root),
|
|
97
|
-
graphPath: path,
|
|
98
|
-
catalog
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
}
|
|
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
|
-
|
|
118
|
-
async function saveGraph(graph, path) {
|
|
119
|
-
const temporary = `${path}.${process.pid}.tmp`;
|
|
120
|
-
const content = `${JSON.stringify(graph, null, 2)}\n`;
|
|
121
|
-
if (Buffer.byteLength(content) > 5 * 1024 * 1024) throw new Error("relationship graph exceeds 5 MiB; archive or compact old observations first");
|
|
122
|
-
await writeFile(temporary, content, { mode: 0o600 });
|
|
123
|
-
await rename(temporary, path);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export async function linkDocuments({ root = process.cwd(), from, to, relation = "related", reason = "", confidence = 0.5 }) {
|
|
127
|
-
if (!RELATIONS.has(relation)) throw new Error(`unsupported relation: ${relation}`);
|
|
128
|
-
const { graph, graphPath, catalog } = await loadGraph(root);
|
|
129
|
-
from = normalizeRelativePath(from, "from");
|
|
130
|
-
to = normalizeRelativePath(to, "to");
|
|
131
|
-
const known = new Set(catalog.documents.map((document) => document.relativePath));
|
|
132
|
-
if (!known.has(from)) throw new Error(`unknown source document: ${from}`);
|
|
133
|
-
if (!known.has(to)) throw new Error(`unknown target document: ${to}`);
|
|
134
|
-
const edge = {
|
|
135
|
-
from,
|
|
136
|
-
to,
|
|
137
|
-
relation,
|
|
138
|
-
reason: String(reason).slice(0, 1000),
|
|
139
|
-
confidence: normalizeConfidence(confidence),
|
|
140
|
-
updatedAt: new Date().toISOString(),
|
|
141
|
-
authority: "context-only"
|
|
142
|
-
};
|
|
143
|
-
const key = (item) => `${item.from}\0${item.to}\0${item.relation}`;
|
|
144
|
-
preservePrevious(graph, "document-edge", graph.edges.find((item) => key(item) === key(edge)));
|
|
145
|
-
graph.edges = graph.edges.filter((item) => key(item) !== key(edge));
|
|
146
|
-
graph.edges.push(edge);
|
|
147
|
-
graph.edges.sort((a, b) => key(a).localeCompare(key(b)));
|
|
148
|
-
await saveGraph(graph, graphPath);
|
|
149
|
-
return { edge, graphPath };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export async function annotateDocument({ root = process.cwd(), path, layer, reason = "", confidence = 0.5 }) {
|
|
153
|
-
if (!ANNOTATION_LAYERS.has(layer)) throw new Error(`unsupported context-only layer: ${layer}`);
|
|
154
|
-
const { graph, graphPath, catalog } = await loadGraph(root);
|
|
155
|
-
path = normalizeRelativePath(path, "path");
|
|
156
|
-
if (!catalog.documents.some((document) => document.relativePath === path)) {
|
|
157
|
-
throw new Error(`unknown document: ${path}`);
|
|
158
|
-
}
|
|
159
|
-
const annotation = {
|
|
160
|
-
path,
|
|
161
|
-
layer,
|
|
162
|
-
reason: String(reason).slice(0, 1000),
|
|
163
|
-
confidence: normalizeConfidence(confidence),
|
|
164
|
-
updatedAt: new Date().toISOString(),
|
|
165
|
-
authority: "context-only"
|
|
166
|
-
};
|
|
167
|
-
preservePrevious(graph, "document-annotation", graph.annotations.find((item) => item.path === path));
|
|
168
|
-
graph.annotations = graph.annotations.filter((item) => item.path !== path);
|
|
169
|
-
graph.annotations.push(annotation);
|
|
170
|
-
graph.annotations.sort((a, b) => a.path.localeCompare(b.path));
|
|
171
|
-
await saveGraph(graph, graphPath);
|
|
172
|
-
return { annotation, graphPath };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export async function upsertEntity({
|
|
176
|
-
root = process.cwd(), id, kind, displayName = "", aliases = [],
|
|
177
|
-
attributes = {}, sourceDocument = null, confidence = 0.5, privacy = "private", catalog: providedCatalog = null
|
|
178
|
-
}) {
|
|
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");
|
|
180
|
-
if (!ENTITY_KINDS.has(kind)) throw new Error(`unsupported entity kind: ${kind}`);
|
|
181
|
-
if (!["private", "shared", "group"].includes(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
182
|
-
if (!attributes || Array.isArray(attributes) || typeof attributes !== "object") throw new Error("attributes must be an object");
|
|
183
|
-
const { graph, graphPath, catalog } = await loadGraph(root, providedCatalog);
|
|
184
|
-
if (sourceDocument !== null) {
|
|
185
|
-
sourceDocument = normalizeRelativePath(sourceDocument, "sourceDocument");
|
|
186
|
-
if (!catalog.documents.some((document) => document.relativePath === sourceDocument)) {
|
|
187
|
-
throw new Error(`unknown source document: ${sourceDocument}`);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
const entity = {
|
|
191
|
-
id,
|
|
192
|
-
kind,
|
|
193
|
-
displayName: String(displayName).slice(0, 200),
|
|
194
|
-
aliases: [...new Set((Array.isArray(aliases) ? aliases : []).map((alias) => String(alias).slice(0, 200)))].slice(0, 20),
|
|
195
|
-
attributes: validateAttributes(attributes),
|
|
196
|
-
sourceDocument,
|
|
197
|
-
confidence: normalizeConfidence(confidence),
|
|
198
|
-
privacy,
|
|
199
|
-
updatedAt: new Date().toISOString(),
|
|
200
|
-
authority: "context-only"
|
|
201
|
-
};
|
|
202
|
-
preservePrevious(graph, "entity", graph.entities.find((item) => item.id === id));
|
|
203
|
-
graph.entities = graph.entities.filter((item) => item.id !== id);
|
|
204
|
-
graph.entities.push(entity);
|
|
205
|
-
graph.entities.sort((a, b) => a.id.localeCompare(b.id));
|
|
206
|
-
await saveGraph(graph, graphPath);
|
|
207
|
-
return { entity, graphPath };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export async function linkEntities({ root = process.cwd(), from, to, relation = "related", reason = "", confidence = 0.5,
|
|
211
|
-
privacy = "private", catalog: providedCatalog = null }) {
|
|
212
|
-
if (!ENTITY_RELATIONS.has(relation)) throw new Error(`unsupported entity relation: ${relation}`);
|
|
213
|
-
if (!["private", "shared", "group"].includes(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
214
|
-
const { graph, graphPath } = await loadGraph(root, providedCatalog);
|
|
215
|
-
const known = new Set(graph.entities.map((entity) => entity.id));
|
|
216
|
-
if (!known.has(from)) throw new Error(`unknown source entity: ${from}`);
|
|
217
|
-
if (!known.has(to)) throw new Error(`unknown target entity: ${to}`);
|
|
218
|
-
const edge = {
|
|
219
|
-
from,
|
|
220
|
-
to,
|
|
221
|
-
relation,
|
|
222
|
-
reason: String(reason).slice(0, 1000),
|
|
223
|
-
confidence: normalizeConfidence(confidence),
|
|
224
|
-
privacy,
|
|
225
|
-
updatedAt: new Date().toISOString(),
|
|
226
|
-
authority: "context-only"
|
|
227
|
-
};
|
|
228
|
-
const key = (item) => `${item.from}\0${item.to}\0${item.relation}`;
|
|
229
|
-
preservePrevious(graph, "entity-edge", graph.entityEdges.find((item) => key(item) === key(edge)));
|
|
230
|
-
graph.entityEdges = graph.entityEdges.filter((item) => key(item) !== key(edge));
|
|
231
|
-
graph.entityEdges.push(edge);
|
|
232
|
-
graph.entityEdges.sort((a, b) => key(a).localeCompare(key(b)));
|
|
233
|
-
await saveGraph(graph, graphPath);
|
|
234
|
-
return { edge, graphPath };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export async function unlinkEntities({ root = process.cwd(), from, to, relation = "related", catalog: providedCatalog = null }) {
|
|
238
|
-
if (!ENTITY_RELATIONS.has(relation)) throw new Error(`unsupported entity relation: ${relation}`);
|
|
239
|
-
const { graph, graphPath } = await loadGraph(root, providedCatalog);
|
|
240
|
-
const previous = graph.entityEdges.find((item) => item.from === from && item.to === to && item.relation === relation);
|
|
241
|
-
if (!previous) return { removed: null, duplicate: true, graphPath };
|
|
242
|
-
preservePrevious(graph, "entity-edge", previous);
|
|
243
|
-
graph.entityEdges = graph.entityEdges.filter((item) => !(item.from === from && item.to === to && item.relation === relation));
|
|
244
|
-
await saveGraph(graph, graphPath);
|
|
245
|
-
return { removed: previous, duplicate: false, graphPath };
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function relationshipAudience(graph, groupId) {
|
|
249
|
-
const ids = new Set([groupId]);
|
|
250
|
-
for (const edge of graph.entityEdges) {
|
|
251
|
-
if (edge.relation !== "member-of" || edge.privacy === "private") continue;
|
|
252
|
-
if (edge.from === groupId) ids.add(edge.to);
|
|
253
|
-
if (edge.to === groupId) ids.add(edge.from);
|
|
254
|
-
}
|
|
255
|
-
return ids;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
async function assembleRelationshipContext({
|
|
259
|
-
root = process.cwd(), entityId, includePrivate = false, groupId = null, catalog: providedCatalog = null
|
|
260
|
-
}, loadGraphImpl, signal) {
|
|
261
|
-
if (!entityId) throw new Error("entityId is required");
|
|
262
|
-
const { graph } = await loadGraphImpl(root, providedCatalog, { signal });
|
|
263
|
-
if (groupId !== null) {
|
|
264
|
-
const group = graph.entities.find((item) => item.id === groupId && item.kind === "group");
|
|
265
|
-
if (!group) throw new Error(`unknown group entity: ${groupId}`);
|
|
266
|
-
if (includePrivate) throw new Error("private relationship context cannot be assembled for a group audience");
|
|
267
|
-
}
|
|
268
|
-
const entity = graph.entities.find((item) => item.id === entityId);
|
|
269
|
-
if (!entity) throw new Error(`unknown entity: ${entityId}`);
|
|
270
|
-
const audience = groupId === null ? null : relationshipAudience(graph, groupId);
|
|
271
|
-
if (audience && !audience.has(entityId)) throw new Error(`entity is not a visible member of group: ${groupId}`);
|
|
272
|
-
const visible = (item) => {
|
|
273
|
-
if (item.attributes?.identityBindingId && item.attributes.identityStatus !== "active") return false;
|
|
274
|
-
if (item.privacy === "private") return includePrivate && groupId === null;
|
|
275
|
-
if (item.privacy === "group" && !audience) return false;
|
|
276
|
-
if (audience && item.id && !audience.has(item.id)) return false;
|
|
277
|
-
return true;
|
|
278
|
-
};
|
|
279
|
-
if (!visible(entity)) throw new Error(`private relationship context requires includePrivate: ${entityId}`);
|
|
280
|
-
const entities = new Map(graph.entities.map((item) => [item.id, item]));
|
|
281
|
-
const visibleEdge = (edge) => visible(edge)
|
|
282
|
-
&& (!audience || [edge.from, edge.to].every((id) => audience.has(id)))
|
|
283
|
-
&& [edge.from, edge.to].every((id) => !entities.has(id) || visible(entities.get(id)));
|
|
284
|
-
const directEdges = graph.entityEdges.filter((edge) => (edge.from === entityId || edge.to === entityId) && visibleEdge(edge));
|
|
285
|
-
const teamEdges = groupId === null ? [] : graph.entityEdges.filter((edge) => edge.relation === "member-of"
|
|
286
|
-
&& (edge.from === groupId || edge.to === groupId) && visibleEdge(edge));
|
|
287
|
-
const edgeKey = (edge) => `${edge.from}\0${edge.to}\0${edge.relation}`;
|
|
288
|
-
const edges = [...new Map([...directEdges, ...teamEdges].map((edge) => [edgeKey(edge), edge])).values()]
|
|
289
|
-
.sort((left, right) => edgeKey(left).localeCompare(edgeKey(right)));
|
|
290
|
-
const ids = new Set(edges.flatMap((edge) => [edge.from, edge.to]));
|
|
291
|
-
ids.add(entityId);
|
|
292
|
-
return {
|
|
293
|
-
entity,
|
|
294
|
-
relatedEntities: graph.entities.filter((item) => ids.has(item.id) && visible(item)),
|
|
295
|
-
edges,
|
|
296
|
-
history: graph.history.filter((entry) => {
|
|
297
|
-
if (!visible(entry)) return false;
|
|
298
|
-
const value = entry.value || {};
|
|
299
|
-
if (value.id && entities.has(value.id) && !visible(entities.get(value.id))) return false;
|
|
300
|
-
if ((value.from || value.to) && !visibleEdge(value)) return false;
|
|
301
|
-
return value.id === entityId || value.from === entityId || value.to === entityId;
|
|
302
|
-
}),
|
|
303
|
-
groupId,
|
|
304
|
-
authority: "context-only"
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export async function relationshipContext(options = {}, { loadGraphImpl = loadRelationshipGraph, timeoutMs = 5000 } = {}) {
|
|
309
|
-
const controller = new AbortController();
|
|
310
|
-
let timer;
|
|
311
|
-
const timeoutError = new Error(`relationship context exceeded its ${timeoutMs} ms local read limit`);
|
|
312
|
-
const deadline = new Promise((_resolve, reject) => {
|
|
313
|
-
timer = setTimeout(() => {
|
|
314
|
-
reject(timeoutError);
|
|
315
|
-
controller.abort();
|
|
316
|
-
}, timeoutMs);
|
|
317
|
-
});
|
|
318
|
-
try {
|
|
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
|
-
};
|
|
333
|
-
} finally {
|
|
334
|
-
clearTimeout(timer);
|
|
335
|
-
controller.abort();
|
|
336
|
-
}
|
|
337
|
-
}
|
|
1
|
+
import { readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { buildCatalog } from "./catalog.js";
|
|
4
|
+
import { canonicalPath, projectStateDir } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
const RELATIONS = new Set([
|
|
7
|
+
"loads", "belongs-to", "explains", "supports", "related",
|
|
8
|
+
"contradicts", "supersedes-in-relevance"
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const ENTITY_KINDS = new Set(["person", "agent", "group", "channel", "project"]);
|
|
12
|
+
const ENTITY_RELATIONS = new Set([
|
|
13
|
+
"knows", "works-with", "member-of", "communicates-via",
|
|
14
|
+
"responsible-for", "reports-to", "related"
|
|
15
|
+
]);
|
|
16
|
+
const ANNOTATION_LAYERS = new Set([
|
|
17
|
+
"soul", "memory-index", "memory-fact", "reference", "project-reference",
|
|
18
|
+
"identity", "voice", "conduct", "history"
|
|
19
|
+
]);
|
|
20
|
+
const FORBIDDEN_ATTRIBUTE_KEYS = /^(permissions?|rights?|authorization|credentials?|secrets?|tokens?|api[-_]?keys?)$/i;
|
|
21
|
+
|
|
22
|
+
function emptyGraph(root) {
|
|
23
|
+
return {
|
|
24
|
+
schema: "agentspine.graph/v1",
|
|
25
|
+
root,
|
|
26
|
+
edges: [],
|
|
27
|
+
annotations: [],
|
|
28
|
+
entities: [],
|
|
29
|
+
entityEdges: [],
|
|
30
|
+
history: []
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeGraph(value, root) {
|
|
35
|
+
const graph = value && typeof value === "object" ? value : emptyGraph(root);
|
|
36
|
+
graph.schema = "agentspine.graph/v1";
|
|
37
|
+
graph.root = root;
|
|
38
|
+
for (const key of ["edges", "annotations", "entities", "entityEdges", "history"]) {
|
|
39
|
+
if (!Array.isArray(graph[key])) graph[key] = [];
|
|
40
|
+
}
|
|
41
|
+
return graph;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function preservePrevious(graph, kind, value) {
|
|
45
|
+
if (!value) return;
|
|
46
|
+
graph.history.push({
|
|
47
|
+
kind,
|
|
48
|
+
supersededAt: new Date().toISOString(),
|
|
49
|
+
value: { ...value, authority: "context-only" },
|
|
50
|
+
privacy: value.privacy || "private",
|
|
51
|
+
authority: "context-only"
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeRelativePath(value, field) {
|
|
56
|
+
if (!value || typeof value !== "string") throw new Error(`${field} is required`);
|
|
57
|
+
const normalized = value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
58
|
+
if (normalized.startsWith("/") || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
59
|
+
throw new Error(`${field} must be a project-relative path`);
|
|
60
|
+
}
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeConfidence(value) {
|
|
65
|
+
const number = Number(value);
|
|
66
|
+
if (!Number.isFinite(number)) throw new Error("confidence must be a finite number");
|
|
67
|
+
return Math.max(0, Math.min(1, number));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validateAttributes(value, path = "attributes") {
|
|
71
|
+
if (value === null || value === undefined) return {};
|
|
72
|
+
if (Array.isArray(value)) return value.map((item, index) => validateAttributes(item, `${path}[${index}]`));
|
|
73
|
+
if (typeof value !== "object") return value;
|
|
74
|
+
const result = {};
|
|
75
|
+
for (const [key, item] of Object.entries(value)) {
|
|
76
|
+
if (FORBIDDEN_ATTRIBUTE_KEYS.test(key)) {
|
|
77
|
+
throw new Error(`${path}.${key} is authority data and cannot be stored in relationship memory`);
|
|
78
|
+
}
|
|
79
|
+
result[key] = validateAttributes(item, `${path}.${key}`);
|
|
80
|
+
}
|
|
81
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 16384) throw new Error("attributes exceed 16 KiB");
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function loadGraph(root, providedCatalog = null) {
|
|
86
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
87
|
+
const directory = await projectStateDir(catalog.root);
|
|
88
|
+
const path = join(directory, "graph.json");
|
|
89
|
+
try {
|
|
90
|
+
const metadata = await stat(path);
|
|
91
|
+
if (metadata.size > 5 * 1024 * 1024) throw new Error("relationship graph exceeds the 5 MiB read limit");
|
|
92
|
+
return { graph: normalizeGraph(JSON.parse(await readFile(path, "utf8")), catalog.root), graphPath: path, catalog };
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error.code !== "ENOENT") throw error;
|
|
95
|
+
return {
|
|
96
|
+
graph: emptyGraph(catalog.root),
|
|
97
|
+
graphPath: path,
|
|
98
|
+
catalog
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
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
|
+
|
|
118
|
+
async function saveGraph(graph, path) {
|
|
119
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
120
|
+
const content = `${JSON.stringify(graph, null, 2)}\n`;
|
|
121
|
+
if (Buffer.byteLength(content) > 5 * 1024 * 1024) throw new Error("relationship graph exceeds 5 MiB; archive or compact old observations first");
|
|
122
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
123
|
+
await rename(temporary, path);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function linkDocuments({ root = process.cwd(), from, to, relation = "related", reason = "", confidence = 0.5 }) {
|
|
127
|
+
if (!RELATIONS.has(relation)) throw new Error(`unsupported relation: ${relation}`);
|
|
128
|
+
const { graph, graphPath, catalog } = await loadGraph(root);
|
|
129
|
+
from = normalizeRelativePath(from, "from");
|
|
130
|
+
to = normalizeRelativePath(to, "to");
|
|
131
|
+
const known = new Set(catalog.documents.map((document) => document.relativePath));
|
|
132
|
+
if (!known.has(from)) throw new Error(`unknown source document: ${from}`);
|
|
133
|
+
if (!known.has(to)) throw new Error(`unknown target document: ${to}`);
|
|
134
|
+
const edge = {
|
|
135
|
+
from,
|
|
136
|
+
to,
|
|
137
|
+
relation,
|
|
138
|
+
reason: String(reason).slice(0, 1000),
|
|
139
|
+
confidence: normalizeConfidence(confidence),
|
|
140
|
+
updatedAt: new Date().toISOString(),
|
|
141
|
+
authority: "context-only"
|
|
142
|
+
};
|
|
143
|
+
const key = (item) => `${item.from}\0${item.to}\0${item.relation}`;
|
|
144
|
+
preservePrevious(graph, "document-edge", graph.edges.find((item) => key(item) === key(edge)));
|
|
145
|
+
graph.edges = graph.edges.filter((item) => key(item) !== key(edge));
|
|
146
|
+
graph.edges.push(edge);
|
|
147
|
+
graph.edges.sort((a, b) => key(a).localeCompare(key(b)));
|
|
148
|
+
await saveGraph(graph, graphPath);
|
|
149
|
+
return { edge, graphPath };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function annotateDocument({ root = process.cwd(), path, layer, reason = "", confidence = 0.5 }) {
|
|
153
|
+
if (!ANNOTATION_LAYERS.has(layer)) throw new Error(`unsupported context-only layer: ${layer}`);
|
|
154
|
+
const { graph, graphPath, catalog } = await loadGraph(root);
|
|
155
|
+
path = normalizeRelativePath(path, "path");
|
|
156
|
+
if (!catalog.documents.some((document) => document.relativePath === path)) {
|
|
157
|
+
throw new Error(`unknown document: ${path}`);
|
|
158
|
+
}
|
|
159
|
+
const annotation = {
|
|
160
|
+
path,
|
|
161
|
+
layer,
|
|
162
|
+
reason: String(reason).slice(0, 1000),
|
|
163
|
+
confidence: normalizeConfidence(confidence),
|
|
164
|
+
updatedAt: new Date().toISOString(),
|
|
165
|
+
authority: "context-only"
|
|
166
|
+
};
|
|
167
|
+
preservePrevious(graph, "document-annotation", graph.annotations.find((item) => item.path === path));
|
|
168
|
+
graph.annotations = graph.annotations.filter((item) => item.path !== path);
|
|
169
|
+
graph.annotations.push(annotation);
|
|
170
|
+
graph.annotations.sort((a, b) => a.path.localeCompare(b.path));
|
|
171
|
+
await saveGraph(graph, graphPath);
|
|
172
|
+
return { annotation, graphPath };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function upsertEntity({
|
|
176
|
+
root = process.cwd(), id, kind, displayName = "", aliases = [],
|
|
177
|
+
attributes = {}, sourceDocument = null, confidence = 0.5, privacy = "private", catalog: providedCatalog = null
|
|
178
|
+
}) {
|
|
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");
|
|
180
|
+
if (!ENTITY_KINDS.has(kind)) throw new Error(`unsupported entity kind: ${kind}`);
|
|
181
|
+
if (!["private", "shared", "group"].includes(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
182
|
+
if (!attributes || Array.isArray(attributes) || typeof attributes !== "object") throw new Error("attributes must be an object");
|
|
183
|
+
const { graph, graphPath, catalog } = await loadGraph(root, providedCatalog);
|
|
184
|
+
if (sourceDocument !== null) {
|
|
185
|
+
sourceDocument = normalizeRelativePath(sourceDocument, "sourceDocument");
|
|
186
|
+
if (!catalog.documents.some((document) => document.relativePath === sourceDocument)) {
|
|
187
|
+
throw new Error(`unknown source document: ${sourceDocument}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const entity = {
|
|
191
|
+
id,
|
|
192
|
+
kind,
|
|
193
|
+
displayName: String(displayName).slice(0, 200),
|
|
194
|
+
aliases: [...new Set((Array.isArray(aliases) ? aliases : []).map((alias) => String(alias).slice(0, 200)))].slice(0, 20),
|
|
195
|
+
attributes: validateAttributes(attributes),
|
|
196
|
+
sourceDocument,
|
|
197
|
+
confidence: normalizeConfidence(confidence),
|
|
198
|
+
privacy,
|
|
199
|
+
updatedAt: new Date().toISOString(),
|
|
200
|
+
authority: "context-only"
|
|
201
|
+
};
|
|
202
|
+
preservePrevious(graph, "entity", graph.entities.find((item) => item.id === id));
|
|
203
|
+
graph.entities = graph.entities.filter((item) => item.id !== id);
|
|
204
|
+
graph.entities.push(entity);
|
|
205
|
+
graph.entities.sort((a, b) => a.id.localeCompare(b.id));
|
|
206
|
+
await saveGraph(graph, graphPath);
|
|
207
|
+
return { entity, graphPath };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function linkEntities({ root = process.cwd(), from, to, relation = "related", reason = "", confidence = 0.5,
|
|
211
|
+
privacy = "private", catalog: providedCatalog = null }) {
|
|
212
|
+
if (!ENTITY_RELATIONS.has(relation)) throw new Error(`unsupported entity relation: ${relation}`);
|
|
213
|
+
if (!["private", "shared", "group"].includes(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
214
|
+
const { graph, graphPath } = await loadGraph(root, providedCatalog);
|
|
215
|
+
const known = new Set(graph.entities.map((entity) => entity.id));
|
|
216
|
+
if (!known.has(from)) throw new Error(`unknown source entity: ${from}`);
|
|
217
|
+
if (!known.has(to)) throw new Error(`unknown target entity: ${to}`);
|
|
218
|
+
const edge = {
|
|
219
|
+
from,
|
|
220
|
+
to,
|
|
221
|
+
relation,
|
|
222
|
+
reason: String(reason).slice(0, 1000),
|
|
223
|
+
confidence: normalizeConfidence(confidence),
|
|
224
|
+
privacy,
|
|
225
|
+
updatedAt: new Date().toISOString(),
|
|
226
|
+
authority: "context-only"
|
|
227
|
+
};
|
|
228
|
+
const key = (item) => `${item.from}\0${item.to}\0${item.relation}`;
|
|
229
|
+
preservePrevious(graph, "entity-edge", graph.entityEdges.find((item) => key(item) === key(edge)));
|
|
230
|
+
graph.entityEdges = graph.entityEdges.filter((item) => key(item) !== key(edge));
|
|
231
|
+
graph.entityEdges.push(edge);
|
|
232
|
+
graph.entityEdges.sort((a, b) => key(a).localeCompare(key(b)));
|
|
233
|
+
await saveGraph(graph, graphPath);
|
|
234
|
+
return { edge, graphPath };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function unlinkEntities({ root = process.cwd(), from, to, relation = "related", catalog: providedCatalog = null }) {
|
|
238
|
+
if (!ENTITY_RELATIONS.has(relation)) throw new Error(`unsupported entity relation: ${relation}`);
|
|
239
|
+
const { graph, graphPath } = await loadGraph(root, providedCatalog);
|
|
240
|
+
const previous = graph.entityEdges.find((item) => item.from === from && item.to === to && item.relation === relation);
|
|
241
|
+
if (!previous) return { removed: null, duplicate: true, graphPath };
|
|
242
|
+
preservePrevious(graph, "entity-edge", previous);
|
|
243
|
+
graph.entityEdges = graph.entityEdges.filter((item) => !(item.from === from && item.to === to && item.relation === relation));
|
|
244
|
+
await saveGraph(graph, graphPath);
|
|
245
|
+
return { removed: previous, duplicate: false, graphPath };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function relationshipAudience(graph, groupId) {
|
|
249
|
+
const ids = new Set([groupId]);
|
|
250
|
+
for (const edge of graph.entityEdges) {
|
|
251
|
+
if (edge.relation !== "member-of" || edge.privacy === "private") continue;
|
|
252
|
+
if (edge.from === groupId) ids.add(edge.to);
|
|
253
|
+
if (edge.to === groupId) ids.add(edge.from);
|
|
254
|
+
}
|
|
255
|
+
return ids;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function assembleRelationshipContext({
|
|
259
|
+
root = process.cwd(), entityId, includePrivate = false, groupId = null, catalog: providedCatalog = null
|
|
260
|
+
}, loadGraphImpl, signal) {
|
|
261
|
+
if (!entityId) throw new Error("entityId is required");
|
|
262
|
+
const { graph } = await loadGraphImpl(root, providedCatalog, { signal });
|
|
263
|
+
if (groupId !== null) {
|
|
264
|
+
const group = graph.entities.find((item) => item.id === groupId && item.kind === "group");
|
|
265
|
+
if (!group) throw new Error(`unknown group entity: ${groupId}`);
|
|
266
|
+
if (includePrivate) throw new Error("private relationship context cannot be assembled for a group audience");
|
|
267
|
+
}
|
|
268
|
+
const entity = graph.entities.find((item) => item.id === entityId);
|
|
269
|
+
if (!entity) throw new Error(`unknown entity: ${entityId}`);
|
|
270
|
+
const audience = groupId === null ? null : relationshipAudience(graph, groupId);
|
|
271
|
+
if (audience && !audience.has(entityId)) throw new Error(`entity is not a visible member of group: ${groupId}`);
|
|
272
|
+
const visible = (item) => {
|
|
273
|
+
if (item.attributes?.identityBindingId && item.attributes.identityStatus !== "active") return false;
|
|
274
|
+
if (item.privacy === "private") return includePrivate && groupId === null;
|
|
275
|
+
if (item.privacy === "group" && !audience) return false;
|
|
276
|
+
if (audience && item.id && !audience.has(item.id)) return false;
|
|
277
|
+
return true;
|
|
278
|
+
};
|
|
279
|
+
if (!visible(entity)) throw new Error(`private relationship context requires includePrivate: ${entityId}`);
|
|
280
|
+
const entities = new Map(graph.entities.map((item) => [item.id, item]));
|
|
281
|
+
const visibleEdge = (edge) => visible(edge)
|
|
282
|
+
&& (!audience || [edge.from, edge.to].every((id) => audience.has(id)))
|
|
283
|
+
&& [edge.from, edge.to].every((id) => !entities.has(id) || visible(entities.get(id)));
|
|
284
|
+
const directEdges = graph.entityEdges.filter((edge) => (edge.from === entityId || edge.to === entityId) && visibleEdge(edge));
|
|
285
|
+
const teamEdges = groupId === null ? [] : graph.entityEdges.filter((edge) => edge.relation === "member-of"
|
|
286
|
+
&& (edge.from === groupId || edge.to === groupId) && visibleEdge(edge));
|
|
287
|
+
const edgeKey = (edge) => `${edge.from}\0${edge.to}\0${edge.relation}`;
|
|
288
|
+
const edges = [...new Map([...directEdges, ...teamEdges].map((edge) => [edgeKey(edge), edge])).values()]
|
|
289
|
+
.sort((left, right) => edgeKey(left).localeCompare(edgeKey(right)));
|
|
290
|
+
const ids = new Set(edges.flatMap((edge) => [edge.from, edge.to]));
|
|
291
|
+
ids.add(entityId);
|
|
292
|
+
return {
|
|
293
|
+
entity,
|
|
294
|
+
relatedEntities: graph.entities.filter((item) => ids.has(item.id) && visible(item)),
|
|
295
|
+
edges,
|
|
296
|
+
history: graph.history.filter((entry) => {
|
|
297
|
+
if (!visible(entry)) return false;
|
|
298
|
+
const value = entry.value || {};
|
|
299
|
+
if (value.id && entities.has(value.id) && !visible(entities.get(value.id))) return false;
|
|
300
|
+
if ((value.from || value.to) && !visibleEdge(value)) return false;
|
|
301
|
+
return value.id === entityId || value.from === entityId || value.to === entityId;
|
|
302
|
+
}),
|
|
303
|
+
groupId,
|
|
304
|
+
authority: "context-only"
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function relationshipContext(options = {}, { loadGraphImpl = loadRelationshipGraph, timeoutMs = 5000 } = {}) {
|
|
309
|
+
const controller = new AbortController();
|
|
310
|
+
let timer;
|
|
311
|
+
const timeoutError = new Error(`relationship context exceeded its ${timeoutMs} ms local read limit`);
|
|
312
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
313
|
+
timer = setTimeout(() => {
|
|
314
|
+
reject(timeoutError);
|
|
315
|
+
controller.abort();
|
|
316
|
+
}, timeoutMs);
|
|
317
|
+
});
|
|
318
|
+
try {
|
|
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
|
+
};
|
|
333
|
+
} finally {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
controller.abort();
|
|
336
|
+
}
|
|
337
|
+
}
|