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.
- package/CHANGELOG.md +32 -0
- package/LIESMICH.txt +4 -2
- package/README.md +4 -2
- package/agent-spine-plugin/.claude-plugin/marketplace.json +1 -1
- package/agent-spine-plugin/.claude-plugin/plugin.json +1 -1
- package/agent-spine-plugin/.codex-plugin/plugin.json +1 -2
- package/agent-spine-plugin/CHANGELOG.md +81 -14
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/README.md +6 -4
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/blun.plugin.json +2 -2
- package/agent-spine-plugin/docs/acceptance.md +2 -2
- package/agent-spine-plugin/docs/architecture.md +1 -1
- package/agent-spine-plugin/docs/gateway-runtime.md +2 -2
- package/agent-spine-plugin/docs/host-integration.md +2 -2
- package/agent-spine-plugin/docs/learning.md +37 -2
- package/agent-spine-plugin/docs/preflight-recall.md +1 -1
- package/agent-spine-plugin/docs/quality-gates.md +1 -1
- package/agent-spine-plugin/docs/relationships.md +1 -1
- package/agent-spine-plugin/docs/session-briefing.md +1 -1
- package/agent-spine-plugin/docs/source-roots.md +1 -1
- package/agent-spine-plugin/hooks/codex.json +1 -1
- package/agent-spine-plugin/hooks/version.json +1 -1
- package/agent-spine-plugin/package.json +1 -1
- package/agent-spine-plugin/scripts/check-hosts.js +2 -2
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +3 -3
- package/agent-spine-plugin/src/cli.js +69 -5
- package/agent-spine-plugin/src/hook.js +41 -23
- package/agent-spine-plugin/src/index.js +2 -1
- package/agent-spine-plugin/src/lib/acceptance.js +2 -3
- package/agent-spine-plugin/src/lib/attention.js +4 -4
- package/agent-spine-plugin/src/lib/audit.js +12 -3
- package/agent-spine-plugin/src/lib/authentication.js +4 -2
- package/agent-spine-plugin/src/lib/briefing.js +20 -8
- package/agent-spine-plugin/src/lib/catalog.js +28 -3
- package/agent-spine-plugin/src/lib/channel-runtime.js +2 -2
- package/agent-spine-plugin/src/lib/continuity.js +18 -13
- package/agent-spine-plugin/src/lib/documents.js +23 -5
- package/agent-spine-plugin/src/lib/feed-transport.js +4 -2
- package/agent-spine-plugin/src/lib/graph.js +47 -12
- package/agent-spine-plugin/src/lib/learning.js +422 -48
- package/agent-spine-plugin/src/lib/paths.js +50 -1
- package/agent-spine-plugin/src/lib/persona-runtime.js +20 -14
- package/agent-spine-plugin/src/lib/preflight.js +50 -26
- package/agent-spine-plugin/src/lib/source-roots.js +38 -7
- package/agent-spine-plugin/src/mcp.js +50 -4
- package/agent-spine-plugin/src/version.js +1 -1
- package/bin/telegram-approval-relay.cjs +2 -0
- package/blun.mjs +111 -8
- package/package.json +8 -2
- package/scripts/check-approval-observability-regression.js +111 -0
- package/scripts/check-bundled-agent-spine-regression.js +48 -0
- package/scripts/check-session-picker-resume-metrics-regression.js +97 -0
- package/scripts/check-session-start-hook-context-regression.js +54 -4
- package/scripts/check-shell-terminal-isolation-regression.js +81 -0
- package/scripts/check-slash-escape-regression.js +89 -0
- package/scripts/check-telegram-loop-exactly-once-regression.js +71 -0
- 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 ? {
|
|
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
|
|
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
|
|
226
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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 {
|
|
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 (
|
|
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,
|
|
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 =
|
|
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(() =>
|
|
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
|
}
|