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.
Files changed (85) hide show
  1. package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
  2. package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
  3. package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
  4. package/agent-spine-plugin/.mcp.json +8 -8
  5. package/agent-spine-plugin/CONTRIBUTING.md +52 -0
  6. package/agent-spine-plugin/LICENSE +186 -186
  7. package/agent-spine-plugin/README.md +394 -0
  8. package/agent-spine-plugin/SECURITY.md +47 -0
  9. package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
  10. package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
  11. package/agent-spine-plugin/bin/agentspine.js +7 -7
  12. package/agent-spine-plugin/blun.plugin.json +33 -33
  13. package/agent-spine-plugin/hooks/codex.json +47 -47
  14. package/agent-spine-plugin/hooks/hooks.json +106 -106
  15. package/agent-spine-plugin/hooks/version.json +5 -5
  16. package/agent-spine-plugin/package.json +69 -69
  17. package/agent-spine-plugin/scripts/check-hosts.js +199 -199
  18. package/agent-spine-plugin/skill/SKILL.md +76 -76
  19. package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
  20. package/agent-spine-plugin/src/cli.js +1488 -1442
  21. package/agent-spine-plugin/src/hook.js +886 -812
  22. package/agent-spine-plugin/src/index.js +93 -93
  23. package/agent-spine-plugin/src/lib/acceptance.js +333 -333
  24. package/agent-spine-plugin/src/lib/attention.js +755 -755
  25. package/agent-spine-plugin/src/lib/audit.js +351 -342
  26. package/agent-spine-plugin/src/lib/authentication.js +515 -515
  27. package/agent-spine-plugin/src/lib/briefing.js +317 -317
  28. package/agent-spine-plugin/src/lib/catalog.js +167 -167
  29. package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
  30. package/agent-spine-plugin/src/lib/context.js +154 -154
  31. package/agent-spine-plugin/src/lib/continuity.js +338 -338
  32. package/agent-spine-plugin/src/lib/coordination.js +577 -577
  33. package/agent-spine-plugin/src/lib/documents.js +217 -217
  34. package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
  35. package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
  36. package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
  37. package/agent-spine-plugin/src/lib/graph.js +337 -337
  38. package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
  39. package/agent-spine-plugin/src/lib/https-transport.js +392 -392
  40. package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
  41. package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
  42. package/agent-spine-plugin/src/lib/learning.js +6923 -6466
  43. package/agent-spine-plugin/src/lib/object-transport.js +206 -206
  44. package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
  45. package/agent-spine-plugin/src/lib/paths.js +109 -109
  46. package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
  47. package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
  48. package/agent-spine-plugin/src/lib/preflight.js +702 -702
  49. package/agent-spine-plugin/src/lib/runtime.js +13 -13
  50. package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
  51. package/agent-spine-plugin/src/lib/sharing.js +969 -969
  52. package/agent-spine-plugin/src/lib/source-roots.js +529 -482
  53. package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
  54. package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
  55. package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
  56. package/agent-spine-plugin/src/mcp.js +597 -572
  57. package/agent-spine-plugin/src/version.js +1 -1
  58. package/agent-spine-plugin/src/worker.js +202 -195
  59. package/bin/active-steer-priority-policy.cjs +24 -0
  60. package/bin/launcher-runtime.js +8 -1
  61. package/bin/mnemo-tool-agent-policy.cjs +22 -0
  62. package/bin/thinking-activity-status-policy.cjs +132 -0
  63. package/bin/thinking-only-guard.cjs +75 -0
  64. package/bin/tool-call-loop-policy.cjs +53 -0
  65. package/bin/turn-thinking-policy.cjs +25 -1
  66. package/blun.mjs +554 -64
  67. package/package.json +4 -1
  68. package/standard-skills/translate-native/LICENSE +21 -21
  69. package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
  70. package/standard-skills/translate-native/references/native-orthography.md +79 -79
  71. package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
  72. package/standard-skills/translate-native/references/structured-content.md +72 -72
  73. package/standard-skills/translate-native/references/translationese-review.md +77 -77
  74. package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
  75. package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
  76. package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
  77. package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
  78. package/standard-skills/translate-native/scripts/language_quality.py +377 -377
  79. package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
  80. package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
  81. package/standard-tools/language-guard/blun_language_guard.py +697 -697
  82. package/standard-tools/language-guard/check_diacritics.py +353 -353
  83. package/standard-tools/language-guard/guard_service_client.py +264 -264
  84. package/standard-tools/language-guard/language_quality.py +377 -377
  85. package/standard-tools/language-guard/translation_guard.py +916 -916
@@ -1,338 +1,338 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { open, readFile, stat, unlink, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
- import { buildCatalog } from "./catalog.js";
5
- import { loadGraph } from "./graph.js";
6
- import {
7
- acceptContinuityLearning, addLearningEvidence, loadLearning,
8
- proposeLearning, purgeLearningBySubject
9
- } from "./learning.js";
10
- import { projectStateDir } from "./paths.js";
11
- import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
12
-
13
- const SCHEMA = "agentspine.continuity/v1";
14
- const MAX_STATE_BYTES = 2 * 1024 * 1024;
15
- const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
16
- const SAFE_KINDS = new Set(["preference", "no-go", "correction", "project-fact", "reference"]);
17
- const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret|passwort|geheimnis)\s*[:=]\s*\S{8,}|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/i;
18
- const BLOCKED_RE = /\b(?:birthday|birthdate|geburtstag|relationship|beziehung|spouse|partner|children?|kinder?|pets?|haustiere?|address|adresse|phone|telefon|e-?mail|health|gesundheit|medical|medizin|diagnosis|diagnose|religion|politic|politik|sexual|finances?|finanzen|salary|gehalt|same person|same identity|alias of|merge identit|identit(?:y|ät)|permission|permissions|rights?|roles?|delegat|authorized|authorisation|authorization|berechtigt|rechte|rolle|freigabe|approval|approve|admin|deploy|production|produktion|billing|payment|zahlung|spending|network|netzwerk|database|datenbank|tool access|dateizugriff|file access|private group|private gruppe|private chat|privater chat|permisos?|derechos?|autorizad[oa]|roles?|delegación|aprobación|producción|pagos?|red|base de datos|rättigheter|behörighet|roller?|delegering|godkännande|produktion|betalning|nätverk|databas|privat grupp|privat chatt)\b/i;
19
-
20
- function defaults() {
21
- return {
22
- enabled: false,
23
- minConfidence: 0.95,
24
- minDirectness: 0.95,
25
- minEvidence: 2,
26
- maxPromptBytes: 16384,
27
- maxBriefingBytes: 16384,
28
- defaultEntityId: null,
29
- defaultProjectId: null
30
- };
31
- }
32
-
33
- function emptyState(root) {
34
- return { schema: SCHEMA, root, config: defaults(), signals: [], history: [] };
35
- }
36
-
37
- function validConfig(config) {
38
- return config && typeof config.enabled === "boolean"
39
- && Number.isFinite(config.minConfidence) && config.minConfidence >= 0.9 && config.minConfidence <= 1
40
- && Number.isFinite(config.minDirectness) && config.minDirectness >= 0.9 && config.minDirectness <= 1
41
- && Number.isInteger(config.minEvidence) && config.minEvidence >= 1 && config.minEvidence <= 10
42
- && Number.isInteger(config.maxPromptBytes) && config.maxPromptBytes >= 1024 && config.maxPromptBytes <= 65536
43
- && Number.isInteger(config.maxBriefingBytes) && config.maxBriefingBytes >= 4096 && config.maxBriefingBytes <= 262144
44
- && (config.defaultEntityId === null || ID_RE.test(config.defaultEntityId))
45
- && (config.defaultProjectId === null || ID_RE.test(config.defaultProjectId));
46
- }
47
-
48
- function normalizeState(value, root) {
49
- if (!value || value.schema !== SCHEMA || value.root !== root || !validConfig(value.config)
50
- || !Array.isArray(value.signals) || !Array.isArray(value.history)) {
51
- throw new Error("continuity state structure is invalid; automatic recall is disabled until repaired");
52
- }
53
- return value;
54
- }
55
-
56
- function timestamp(value = new Date()) {
57
- const parsed = value instanceof Date ? value : new Date(value);
58
- if (!Number.isFinite(parsed.getTime())) throw new Error("now must be a valid date");
59
- return parsed.toISOString();
60
- }
61
-
62
- function digest(value) {
63
- return createHash("sha256").update(value).digest("hex");
64
- }
65
-
66
- async function readState(path, root) {
67
- try {
68
- const metadata = await stat(path);
69
- if (metadata.size > MAX_STATE_BYTES) throw new Error("continuity state exceeds its 2 MiB read limit");
70
- return normalizeState(JSON.parse(await readFile(path, "utf8")), root);
71
- } catch (error) {
72
- if (error.code !== "ENOENT") throw error;
73
- return emptyState(root);
74
- }
75
- }
76
-
77
- async function saveState(state, path) {
78
- const content = `${JSON.stringify(state, null, 2)}\n`;
79
- if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("continuity state exceeds 2 MiB");
80
- const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
81
- await writeFile(temporary, content, { mode: 0o600 });
82
- try {
83
- await replaceFileWithRetry(temporary, path);
84
- } catch (error) {
85
- await unlink(temporary).catch((cleanupError) => {
86
- if (cleanupError.code !== "ENOENT") error.cleanupError = cleanupError;
87
- });
88
- throw error;
89
- }
90
- }
91
-
92
- async function withLock(path, root, task) {
93
- const lockPath = `${path}.lock`;
94
- let handle;
95
- for (let attempt = 0; attempt < 80; attempt += 1) {
96
- try {
97
- handle = await open(lockPath, "wx", 0o600);
98
- break;
99
- } catch (error) {
100
- if (!isFileLockContention(error)) throw error;
101
- try {
102
- const metadata = await stat(lockPath);
103
- if (Date.now() - metadata.mtimeMs > 15000) await unlink(lockPath);
104
- } catch (lockError) {
105
- if (lockError.code !== "ENOENT") throw lockError;
106
- }
107
- await new Promise((resolve) => setTimeout(resolve, 25));
108
- }
109
- }
110
- if (!handle) throw new Error("continuity state is busy; retry shortly");
111
- try {
112
- const state = await readState(path, root);
113
- const result = await task(state);
114
- await saveState(state, path);
115
- return result;
116
- } finally {
117
- await handle.close();
118
- await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
119
- }
120
- }
121
-
122
- export async function loadContinuity(root = process.cwd(), providedCatalog = null) {
123
- const catalog = providedCatalog || await buildCatalog(root);
124
- const directory = await projectStateDir(catalog.root);
125
- const continuityPath = join(directory, "continuity.json");
126
- return { continuity: await readState(continuityPath, catalog.root), continuityPath, catalog };
127
- }
128
-
129
- export async function inspectContinuity(root = process.cwd(), providedCatalog = null) {
130
- const catalog = providedCatalog || await buildCatalog(root);
131
- const directory = await projectStateDir(catalog.root);
132
- const continuityPath = join(directory, "continuity.json");
133
- try {
134
- return { continuity: await readState(continuityPath, catalog.root), continuityPath, error: null, catalog };
135
- } catch (error) {
136
- return { continuity: emptyState(catalog.root), continuityPath, error: error.message, catalog };
137
- }
138
- }
139
-
140
- async function mutate(root, task, providedCatalog = null) {
141
- const catalog = providedCatalog || await buildCatalog(root);
142
- const { continuityPath } = await loadContinuity(catalog.root, catalog);
143
- return withLock(continuityPath, catalog.root, (state) => task(state, catalog, continuityPath));
144
- }
145
-
146
- function validateConfiguredEntity(graph, id, kind = null) {
147
- if (id === null) return;
148
- const entity = graph.entities.find((item) => item.id === id);
149
- if (!entity || (kind && entity.kind !== kind)) throw new Error(`unknown ${kind || ""} entity: ${id}`.replace(" ", " "));
150
- }
151
-
152
- export async function configureContinuity({ root = process.cwd(), config = {}, confirmation = null, now = new Date() }) {
153
- if (!config || typeof config !== "object" || Array.isArray(config) || !Object.keys(config).length) {
154
- throw new Error("config must change at least one continuity setting");
155
- }
156
- const allowed = new Set(Object.keys(defaults()));
157
- const unknown = Object.keys(config).filter((key) => !allowed.has(key));
158
- if (unknown.length) throw new Error(`unsupported continuity config: ${unknown.join(", ")}`);
159
- if (config.enabled === true && confirmation !== "local-user-opt-in") {
160
- throw new Error("enabling automatic continuity requires one explicit local user opt-in");
161
- }
162
- const at = timestamp(now);
163
- return mutate(root, async (state, catalog, continuityPath) => {
164
- const scopeChanges = "defaultEntityId" in config || "defaultProjectId" in config;
165
- if (state.config.enabled && scopeChanges && confirmation !== "local-user-opt-in") {
166
- throw new Error("changing an enabled automatic-continuity scope requires explicit local user confirmation");
167
- }
168
- const next = { ...state.config, ...config };
169
- if (!validConfig(next)) throw new Error("resulting continuity configuration is invalid");
170
- const { graph } = await loadGraph(catalog.root, catalog);
171
- validateConfiguredEntity(graph, next.defaultEntityId);
172
- validateConfiguredEntity(graph, next.defaultProjectId);
173
- state.history.push({ kind: "config", at, previous: state.config, authority: "context-only" });
174
- state.config = next;
175
- return { config: next, continuityPath, authority: "context-only" };
176
- });
177
- }
178
-
179
- function fragment(value) {
180
- return value.trim().replace(/\s+/g, " ").replace(/[.!?]+$/, "").slice(0, 280);
181
- }
182
-
183
- function detectSignal(prompt) {
184
- const rules = [
185
- { kind: "no-go", re: /^(?:please\s+)?(?:never|do not|don't|bitte\s+nie|niemals|por favor\s+nunca|nunca|vänligen\s+aldrig|aldrig)\s+(.+)$/i, prefix: "Avoid: ", explicit: true },
186
- { kind: "correction", re: /^(?:correction|korrektur|nein|corrección|korrigering)[,:]\s*(.+)$/i, prefix: "Correction: ", explicit: true },
187
- { kind: "preference", re: /^(?:please|bitte|por favor|vänligen)\s+(?:always\s+|immer\s+|siempre\s+|alltid\s+)?(?:answer|respond|write|antworte|schreibe|responde|escribe|svara|skriv)\s+(?:always\s+|immer\s+|siempre\s+|alltid\s+)?(.+)$/i, prefix: "Response preference: ", explicit: true },
188
- { kind: "preference", re: /^(?:svara|skriv|responde|escribe)\s+(?:alltid|siempre)\s+(.+)$/i, prefix: "Response preference: ", explicit: true },
189
- { kind: "preference", re: /^(?:i prefer|ich bevorzuge|ich möchte|prefiero|quiero|jag föredrar|jag vill)\s+(.+)$/i, prefix: "Preference: ", explicit: true },
190
- { kind: "project-fact", re: /^(?:the project|das projekt|el proyecto|projektet)\s+(?:uses|verwendet|ist|usa|utiliza|es|använder|är)\s+(.+)$/i, prefix: "Project fact: ", explicit: false },
191
- { kind: "reference", re: /^(?:reference|referenz|referencia|referens)\s*:\s*(.+)$/i, prefix: "Reference: ", explicit: false }
192
- ];
193
- for (const rule of rules) {
194
- const match = prompt.trim().match(rule.re);
195
- if (!match) continue;
196
- const value = fragment(match[1]);
197
- if (!value) return null;
198
- return { kind: rule.kind, claim: `${rule.prefix}${value}`, confidence: 0.98, directness: 1, explicit: rule.explicit };
199
- }
200
- return null;
201
- }
202
-
203
- function assertSafeSignal(signal, prompt, scope) {
204
- if (!SAFE_KINDS.has(signal.kind)) throw new Error("signal kind cannot be learned automatically");
205
- if (scope.groupId !== null) throw new Error("group conversation content cannot be learned automatically");
206
- if (SECRET_RE.test(prompt) || SECRET_RE.test(signal.claim)) throw new Error("secret-shaped conversation content was rejected");
207
- if (BLOCKED_RE.test(prompt) || BLOCKED_RE.test(signal.claim)) throw new Error("sensitive, identity, authority, or operational content was rejected");
208
- }
209
-
210
- export async function captureContinuityPrompt({
211
- root = process.cwd(), prompt, entityId = null, groupId = null, projectId = null,
212
- eventId = null, now = new Date(), userStateRoot = null, catalog: providedCatalog = null,
213
- userCatalog: providedUserCatalog = null
214
- }) {
215
- const storageCatalog = userStateRoot && userStateRoot !== root
216
- ? providedUserCatalog : providedCatalog;
217
- const { continuity, catalog } = await loadContinuity(userStateRoot || root, storageCatalog);
218
- if (!continuity.config.enabled) return { enabled: false, captured: false, reason: "opt-in-disabled", authority: "context-only" };
219
- if (typeof prompt !== "string" || Buffer.byteLength(prompt) > continuity.config.maxPromptBytes) {
220
- throw new Error("prompt payload is missing or exceeds the configured byte limit");
221
- }
222
- const detected = detectSignal(prompt);
223
- if (!detected) return { enabled: true, captured: false, reason: "no-minimal-safe-signal", authority: "context-only" };
224
- const storageRoot = detected.kind === "project-fact" ? root : userStateRoot || root;
225
- const subjectId = detected.kind === "project-fact" ? projectId : entityId || projectId;
226
- if (!subjectId || !ID_RE.test(subjectId)) throw new Error("automatic learning requires an exact known person or project identity");
227
- if (groupId !== null && !ID_RE.test(groupId)) throw new Error("groupId is invalid");
228
- const targetCatalog = storageRoot === catalog.root ? catalog
229
- : providedCatalog || await buildCatalog(storageRoot);
230
- const { graph } = await loadGraph(targetCatalog.root, targetCatalog);
231
- const subject = graph.entities.find((item) => item.id === subjectId);
232
- if (!subject) throw new Error(`automatic learning requires a known canonical identity: ${subjectId}`);
233
- if (subjectId === projectId && subject.kind !== "project") throw new Error("projectId must identify a project");
234
- if (subjectId === entityId && !["person", "agent"].includes(subject.kind)) throw new Error("entityId must identify a person or agent");
235
- const scope = { entityId, groupId, projectId };
236
- assertSafeSignal(detected, prompt, scope);
237
- const at = timestamp(now);
238
- const signalKey = `${subjectId}\0${groupId || ""}\0${detected.kind}\0${detected.claim.toLocaleLowerCase("en")}`;
239
- const learningId = `learning:auto:${digest(signalKey).slice(0, 32)}`;
240
- const promptDigest = digest(prompt);
241
- const receiptId = eventId && ID_RE.test(eventId) ? eventId : `signal:${promptDigest.slice(0, 32)}`;
242
- const eventKey = digest(`${receiptId}\0${signalKey}\0${promptDigest}`);
243
-
244
- const recorded = await mutate(storageRoot, (state, catalog, continuityPath) => {
245
- if (!state.config.enabled) return { duplicate: false, disabled: true, config: state.config, continuityPath, catalog };
246
- const duplicate = state.signals.some((item) => item.eventKey === eventKey);
247
- if (duplicate) return { duplicate: true, disabled: false, config: state.config, continuityPath, catalog };
248
- state.signals.push({
249
- eventKey, receiptId, promptDigest, learningId, kind: detected.kind, subjectId,
250
- groupId, projectId, confidence: detected.confidence, directness: detected.directness, explicit: detected.explicit,
251
- observedAt: at, authority: "context-only"
252
- });
253
- state.history.push({ kind: "signal-observed", eventKey, learningId, subjectId, observedAt: at, authority: "context-only" });
254
- return { duplicate: false, disabled: false, config: state.config, continuityPath, catalog };
255
- }, targetCatalog);
256
- if (recorded.disabled) return { enabled: false, captured: false, reason: "opt-in-disabled", authority: "context-only" };
257
-
258
- const evidence = {
259
- id: `evidence:${eventKey.slice(0, 32)}`,
260
- type: "user-statement",
261
- summary: `Direct ${detected.kind} signal captured by the opted-in lifecycle adapter; prompt digest ${promptDigest.slice(0, 16)}.`,
262
- confidence: detected.confidence,
263
- observedAt: at
264
- };
265
- let existing = (await loadLearning(storageRoot, targetCatalog)).learning.candidates.find((item) => item.id === learningId);
266
- if (!existing) {
267
- try {
268
- await proposeLearning({
269
- root: storageRoot, id: learningId, kind: detected.kind, claim: detected.claim, subjectId,
270
- privacy: subjectId === projectId ? "shared" : "private", groupId: null, evidence, now: at,
271
- catalog: targetCatalog
272
- });
273
- } catch (error) {
274
- if (!/candidate IDs are immutable/.test(error.message)) throw error;
275
- }
276
- existing = (await loadLearning(storageRoot, targetCatalog)).learning.candidates.find((item) => item.id === learningId);
277
- }
278
- if (existing?.status === "candidate" && !existing.evidence.some((item) => item.id === evidence.id)) {
279
- try {
280
- await addLearningEvidence({ root: storageRoot, id: learningId, evidence, now: at, catalog: targetCatalog });
281
- } catch (error) {
282
- if (!/duplicate evidence id|unreviewed candidate/.test(error.message)) throw error;
283
- }
284
- }
285
- const refreshed = (await loadLearning(storageRoot, targetCatalog)).learning.candidates.find((item) => item.id === learningId);
286
- let accepted = refreshed?.status === "accepted";
287
- if (!accepted && refreshed?.status === "candidate") {
288
- const distinct = new Set(refreshed.evidence.map((item) => item.id)).size;
289
- const requiredEvidence = detected.explicit ? 1 : recorded.config.minEvidence;
290
- if (refreshed.confidence >= recorded.config.minConfidence
291
- && detected.directness >= recorded.config.minDirectness
292
- && distinct >= requiredEvidence) {
293
- await acceptContinuityLearning({
294
- root: storageRoot, id: learningId, now: at, catalog: targetCatalog,
295
- proof: {
296
- mode: "automatic-continuity-low-risk", localOptIn: true,
297
- minConfidence: recorded.config.minConfidence,
298
- minDirectness: recorded.config.minDirectness,
299
- minEvidence: requiredEvidence,
300
- directness: detected.directness
301
- }
302
- });
303
- accepted = true;
304
- }
305
- }
306
- return {
307
- enabled: true, captured: !recorded.duplicate, duplicate: recorded.duplicate,
308
- learningId, accepted, kind: detected.kind, authority: "context-only"
309
- };
310
- }
311
-
312
- export async function purgeContinuity({ root = process.cwd(), subjectId, confirmation = null, now = new Date() }) {
313
- if (!ID_RE.test(subjectId || "")) throw new Error("subjectId is required");
314
- if (confirmation !== "local-user-confirmed") throw new Error("continuity purge requires explicit local user confirmation");
315
- const at = timestamp(now);
316
- const result = await mutate(root, (state, _catalog, continuityPath) => {
317
- const signalCount = state.signals.filter((item) => item.subjectId === subjectId).length;
318
- state.signals = state.signals.filter((item) => item.subjectId !== subjectId);
319
- state.history = state.history.filter((item) => item.subjectId !== subjectId);
320
- state.history.push({ kind: "subject-purged", subjectId: digest(subjectId), at, authority: "context-only" });
321
- return { signalCount, continuityPath };
322
- });
323
- const learning = await purgeLearningBySubject({ root, subjectId });
324
- return { subjectId, deletedSignals: result.signalCount, deletedLearning: learning.deleted, authority: "context-only" };
325
- }
326
-
327
- export function continuityFindings(continuity) {
328
- const findings = [];
329
- if (!validConfig(continuity.config)) findings.push("invalid-config");
330
- const eventKeys = new Set();
331
- for (const item of continuity.signals) {
332
- if (!/^[a-f0-9]{64}$/.test(item.eventKey || "") || eventKeys.has(item.eventKey)) findings.push(`invalid-or-duplicate-signal:${item.eventKey || "unknown"}`);
333
- eventKeys.add(item.eventKey);
334
- if (!SAFE_KINDS.has(item.kind) || item.groupId !== null || typeof item.explicit !== "boolean" || item.authority !== "context-only") findings.push(`unsafe-signal:${item.eventKey || "unknown"}`);
335
- }
336
- if (continuity.history.some((item) => item.authority !== "context-only")) findings.push("history-authority");
337
- return findings;
338
- }
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { open, readFile, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { buildCatalog } from "./catalog.js";
5
+ import { loadGraph } from "./graph.js";
6
+ import {
7
+ acceptContinuityLearning, addLearningEvidence, loadLearning,
8
+ proposeLearning, purgeLearningBySubject
9
+ } from "./learning.js";
10
+ import { projectStateDir } from "./paths.js";
11
+ import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
12
+
13
+ const SCHEMA = "agentspine.continuity/v1";
14
+ const MAX_STATE_BYTES = 2 * 1024 * 1024;
15
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
16
+ const SAFE_KINDS = new Set(["preference", "no-go", "correction", "project-fact", "reference"]);
17
+ const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret|passwort|geheimnis)\s*[:=]\s*\S{8,}|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/i;
18
+ const BLOCKED_RE = /\b(?:birthday|birthdate|geburtstag|relationship|beziehung|spouse|partner|children?|kinder?|pets?|haustiere?|address|adresse|phone|telefon|e-?mail|health|gesundheit|medical|medizin|diagnosis|diagnose|religion|politic|politik|sexual|finances?|finanzen|salary|gehalt|same person|same identity|alias of|merge identit|identit(?:y|ät)|permission|permissions|rights?|roles?|delegat|authorized|authorisation|authorization|berechtigt|rechte|rolle|freigabe|approval|approve|admin|deploy|production|produktion|billing|payment|zahlung|spending|network|netzwerk|database|datenbank|tool access|dateizugriff|file access|private group|private gruppe|private chat|privater chat|permisos?|derechos?|autorizad[oa]|roles?|delegación|aprobación|producción|pagos?|red|base de datos|rättigheter|behörighet|roller?|delegering|godkännande|produktion|betalning|nätverk|databas|privat grupp|privat chatt)\b/i;
19
+
20
+ function defaults() {
21
+ return {
22
+ enabled: false,
23
+ minConfidence: 0.95,
24
+ minDirectness: 0.95,
25
+ minEvidence: 2,
26
+ maxPromptBytes: 16384,
27
+ maxBriefingBytes: 16384,
28
+ defaultEntityId: null,
29
+ defaultProjectId: null
30
+ };
31
+ }
32
+
33
+ function emptyState(root) {
34
+ return { schema: SCHEMA, root, config: defaults(), signals: [], history: [] };
35
+ }
36
+
37
+ function validConfig(config) {
38
+ return config && typeof config.enabled === "boolean"
39
+ && Number.isFinite(config.minConfidence) && config.minConfidence >= 0.9 && config.minConfidence <= 1
40
+ && Number.isFinite(config.minDirectness) && config.minDirectness >= 0.9 && config.minDirectness <= 1
41
+ && Number.isInteger(config.minEvidence) && config.minEvidence >= 1 && config.minEvidence <= 10
42
+ && Number.isInteger(config.maxPromptBytes) && config.maxPromptBytes >= 1024 && config.maxPromptBytes <= 65536
43
+ && Number.isInteger(config.maxBriefingBytes) && config.maxBriefingBytes >= 4096 && config.maxBriefingBytes <= 262144
44
+ && (config.defaultEntityId === null || ID_RE.test(config.defaultEntityId))
45
+ && (config.defaultProjectId === null || ID_RE.test(config.defaultProjectId));
46
+ }
47
+
48
+ function normalizeState(value, root) {
49
+ if (!value || value.schema !== SCHEMA || value.root !== root || !validConfig(value.config)
50
+ || !Array.isArray(value.signals) || !Array.isArray(value.history)) {
51
+ throw new Error("continuity state structure is invalid; automatic recall is disabled until repaired");
52
+ }
53
+ return value;
54
+ }
55
+
56
+ function timestamp(value = new Date()) {
57
+ const parsed = value instanceof Date ? value : new Date(value);
58
+ if (!Number.isFinite(parsed.getTime())) throw new Error("now must be a valid date");
59
+ return parsed.toISOString();
60
+ }
61
+
62
+ function digest(value) {
63
+ return createHash("sha256").update(value).digest("hex");
64
+ }
65
+
66
+ async function readState(path, root) {
67
+ try {
68
+ const metadata = await stat(path);
69
+ if (metadata.size > MAX_STATE_BYTES) throw new Error("continuity state exceeds its 2 MiB read limit");
70
+ return normalizeState(JSON.parse(await readFile(path, "utf8")), root);
71
+ } catch (error) {
72
+ if (error.code !== "ENOENT") throw error;
73
+ return emptyState(root);
74
+ }
75
+ }
76
+
77
+ async function saveState(state, path) {
78
+ const content = `${JSON.stringify(state, null, 2)}\n`;
79
+ if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("continuity state exceeds 2 MiB");
80
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
81
+ await writeFile(temporary, content, { mode: 0o600 });
82
+ try {
83
+ await replaceFileWithRetry(temporary, path);
84
+ } catch (error) {
85
+ await unlink(temporary).catch((cleanupError) => {
86
+ if (cleanupError.code !== "ENOENT") error.cleanupError = cleanupError;
87
+ });
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ async function withLock(path, root, task) {
93
+ const lockPath = `${path}.lock`;
94
+ let handle;
95
+ for (let attempt = 0; attempt < 80; attempt += 1) {
96
+ try {
97
+ handle = await open(lockPath, "wx", 0o600);
98
+ break;
99
+ } catch (error) {
100
+ if (!isFileLockContention(error)) throw error;
101
+ try {
102
+ const metadata = await stat(lockPath);
103
+ if (Date.now() - metadata.mtimeMs > 15000) await unlink(lockPath);
104
+ } catch (lockError) {
105
+ if (lockError.code !== "ENOENT") throw lockError;
106
+ }
107
+ await new Promise((resolve) => setTimeout(resolve, 25));
108
+ }
109
+ }
110
+ if (!handle) throw new Error("continuity state is busy; retry shortly");
111
+ try {
112
+ const state = await readState(path, root);
113
+ const result = await task(state);
114
+ await saveState(state, path);
115
+ return result;
116
+ } finally {
117
+ await handle.close();
118
+ await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
119
+ }
120
+ }
121
+
122
+ export async function loadContinuity(root = process.cwd(), providedCatalog = null) {
123
+ const catalog = providedCatalog || await buildCatalog(root);
124
+ const directory = await projectStateDir(catalog.root);
125
+ const continuityPath = join(directory, "continuity.json");
126
+ return { continuity: await readState(continuityPath, catalog.root), continuityPath, catalog };
127
+ }
128
+
129
+ export async function inspectContinuity(root = process.cwd(), providedCatalog = null) {
130
+ const catalog = providedCatalog || await buildCatalog(root);
131
+ const directory = await projectStateDir(catalog.root);
132
+ const continuityPath = join(directory, "continuity.json");
133
+ try {
134
+ return { continuity: await readState(continuityPath, catalog.root), continuityPath, error: null, catalog };
135
+ } catch (error) {
136
+ return { continuity: emptyState(catalog.root), continuityPath, error: error.message, catalog };
137
+ }
138
+ }
139
+
140
+ async function mutate(root, task, providedCatalog = null) {
141
+ const catalog = providedCatalog || await buildCatalog(root);
142
+ const { continuityPath } = await loadContinuity(catalog.root, catalog);
143
+ return withLock(continuityPath, catalog.root, (state) => task(state, catalog, continuityPath));
144
+ }
145
+
146
+ function validateConfiguredEntity(graph, id, kind = null) {
147
+ if (id === null) return;
148
+ const entity = graph.entities.find((item) => item.id === id);
149
+ if (!entity || (kind && entity.kind !== kind)) throw new Error(`unknown ${kind || ""} entity: ${id}`.replace(" ", " "));
150
+ }
151
+
152
+ export async function configureContinuity({ root = process.cwd(), config = {}, confirmation = null, now = new Date() }) {
153
+ if (!config || typeof config !== "object" || Array.isArray(config) || !Object.keys(config).length) {
154
+ throw new Error("config must change at least one continuity setting");
155
+ }
156
+ const allowed = new Set(Object.keys(defaults()));
157
+ const unknown = Object.keys(config).filter((key) => !allowed.has(key));
158
+ if (unknown.length) throw new Error(`unsupported continuity config: ${unknown.join(", ")}`);
159
+ if (config.enabled === true && confirmation !== "local-user-opt-in") {
160
+ throw new Error("enabling automatic continuity requires one explicit local user opt-in");
161
+ }
162
+ const at = timestamp(now);
163
+ return mutate(root, async (state, catalog, continuityPath) => {
164
+ const scopeChanges = "defaultEntityId" in config || "defaultProjectId" in config;
165
+ if (state.config.enabled && scopeChanges && confirmation !== "local-user-opt-in") {
166
+ throw new Error("changing an enabled automatic-continuity scope requires explicit local user confirmation");
167
+ }
168
+ const next = { ...state.config, ...config };
169
+ if (!validConfig(next)) throw new Error("resulting continuity configuration is invalid");
170
+ const { graph } = await loadGraph(catalog.root, catalog);
171
+ validateConfiguredEntity(graph, next.defaultEntityId);
172
+ validateConfiguredEntity(graph, next.defaultProjectId);
173
+ state.history.push({ kind: "config", at, previous: state.config, authority: "context-only" });
174
+ state.config = next;
175
+ return { config: next, continuityPath, authority: "context-only" };
176
+ });
177
+ }
178
+
179
+ function fragment(value) {
180
+ return value.trim().replace(/\s+/g, " ").replace(/[.!?]+$/, "").slice(0, 280);
181
+ }
182
+
183
+ function detectSignal(prompt) {
184
+ const rules = [
185
+ { kind: "no-go", re: /^(?:please\s+)?(?:never|do not|don't|bitte\s+nie|niemals|por favor\s+nunca|nunca|vänligen\s+aldrig|aldrig)\s+(.+)$/i, prefix: "Avoid: ", explicit: true },
186
+ { kind: "correction", re: /^(?:correction|korrektur|nein|corrección|korrigering)[,:]\s*(.+)$/i, prefix: "Correction: ", explicit: true },
187
+ { kind: "preference", re: /^(?:please|bitte|por favor|vänligen)\s+(?:always\s+|immer\s+|siempre\s+|alltid\s+)?(?:answer|respond|write|antworte|schreibe|responde|escribe|svara|skriv)\s+(?:always\s+|immer\s+|siempre\s+|alltid\s+)?(.+)$/i, prefix: "Response preference: ", explicit: true },
188
+ { kind: "preference", re: /^(?:svara|skriv|responde|escribe)\s+(?:alltid|siempre)\s+(.+)$/i, prefix: "Response preference: ", explicit: true },
189
+ { kind: "preference", re: /^(?:i prefer|ich bevorzuge|ich möchte|prefiero|quiero|jag föredrar|jag vill)\s+(.+)$/i, prefix: "Preference: ", explicit: true },
190
+ { kind: "project-fact", re: /^(?:the project|das projekt|el proyecto|projektet)\s+(?:uses|verwendet|ist|usa|utiliza|es|använder|är)\s+(.+)$/i, prefix: "Project fact: ", explicit: false },
191
+ { kind: "reference", re: /^(?:reference|referenz|referencia|referens)\s*:\s*(.+)$/i, prefix: "Reference: ", explicit: false }
192
+ ];
193
+ for (const rule of rules) {
194
+ const match = prompt.trim().match(rule.re);
195
+ if (!match) continue;
196
+ const value = fragment(match[1]);
197
+ if (!value) return null;
198
+ return { kind: rule.kind, claim: `${rule.prefix}${value}`, confidence: 0.98, directness: 1, explicit: rule.explicit };
199
+ }
200
+ return null;
201
+ }
202
+
203
+ function assertSafeSignal(signal, prompt, scope) {
204
+ if (!SAFE_KINDS.has(signal.kind)) throw new Error("signal kind cannot be learned automatically");
205
+ if (scope.groupId !== null) throw new Error("group conversation content cannot be learned automatically");
206
+ if (SECRET_RE.test(prompt) || SECRET_RE.test(signal.claim)) throw new Error("secret-shaped conversation content was rejected");
207
+ if (BLOCKED_RE.test(prompt) || BLOCKED_RE.test(signal.claim)) throw new Error("sensitive, identity, authority, or operational content was rejected");
208
+ }
209
+
210
+ export async function captureContinuityPrompt({
211
+ root = process.cwd(), prompt, entityId = null, groupId = null, projectId = null,
212
+ eventId = null, now = new Date(), userStateRoot = null, catalog: providedCatalog = null,
213
+ userCatalog: providedUserCatalog = null
214
+ }) {
215
+ const storageCatalog = userStateRoot && userStateRoot !== root
216
+ ? providedUserCatalog : providedCatalog;
217
+ const { continuity, catalog } = await loadContinuity(userStateRoot || root, storageCatalog);
218
+ if (!continuity.config.enabled) return { enabled: false, captured: false, reason: "opt-in-disabled", authority: "context-only" };
219
+ if (typeof prompt !== "string" || Buffer.byteLength(prompt) > continuity.config.maxPromptBytes) {
220
+ throw new Error("prompt payload is missing or exceeds the configured byte limit");
221
+ }
222
+ const detected = detectSignal(prompt);
223
+ if (!detected) return { enabled: true, captured: false, reason: "no-minimal-safe-signal", authority: "context-only" };
224
+ const storageRoot = detected.kind === "project-fact" ? root : userStateRoot || root;
225
+ const subjectId = detected.kind === "project-fact" ? projectId : entityId || projectId;
226
+ if (!subjectId || !ID_RE.test(subjectId)) throw new Error("automatic learning requires an exact known person or project identity");
227
+ if (groupId !== null && !ID_RE.test(groupId)) throw new Error("groupId is invalid");
228
+ const targetCatalog = storageRoot === catalog.root ? catalog
229
+ : providedCatalog || await buildCatalog(storageRoot);
230
+ const { graph } = await loadGraph(targetCatalog.root, targetCatalog);
231
+ const subject = graph.entities.find((item) => item.id === subjectId);
232
+ if (!subject) throw new Error(`automatic learning requires a known canonical identity: ${subjectId}`);
233
+ if (subjectId === projectId && subject.kind !== "project") throw new Error("projectId must identify a project");
234
+ if (subjectId === entityId && !["person", "agent"].includes(subject.kind)) throw new Error("entityId must identify a person or agent");
235
+ const scope = { entityId, groupId, projectId };
236
+ assertSafeSignal(detected, prompt, scope);
237
+ const at = timestamp(now);
238
+ const signalKey = `${subjectId}\0${groupId || ""}\0${detected.kind}\0${detected.claim.toLocaleLowerCase("en")}`;
239
+ const learningId = `learning:auto:${digest(signalKey).slice(0, 32)}`;
240
+ const promptDigest = digest(prompt);
241
+ const receiptId = eventId && ID_RE.test(eventId) ? eventId : `signal:${promptDigest.slice(0, 32)}`;
242
+ const eventKey = digest(`${receiptId}\0${signalKey}\0${promptDigest}`);
243
+
244
+ const recorded = await mutate(storageRoot, (state, catalog, continuityPath) => {
245
+ if (!state.config.enabled) return { duplicate: false, disabled: true, config: state.config, continuityPath, catalog };
246
+ const duplicate = state.signals.some((item) => item.eventKey === eventKey);
247
+ if (duplicate) return { duplicate: true, disabled: false, config: state.config, continuityPath, catalog };
248
+ state.signals.push({
249
+ eventKey, receiptId, promptDigest, learningId, kind: detected.kind, subjectId,
250
+ groupId, projectId, confidence: detected.confidence, directness: detected.directness, explicit: detected.explicit,
251
+ observedAt: at, authority: "context-only"
252
+ });
253
+ state.history.push({ kind: "signal-observed", eventKey, learningId, subjectId, observedAt: at, authority: "context-only" });
254
+ return { duplicate: false, disabled: false, config: state.config, continuityPath, catalog };
255
+ }, targetCatalog);
256
+ if (recorded.disabled) return { enabled: false, captured: false, reason: "opt-in-disabled", authority: "context-only" };
257
+
258
+ const evidence = {
259
+ id: `evidence:${eventKey.slice(0, 32)}`,
260
+ type: "user-statement",
261
+ summary: `Direct ${detected.kind} signal captured by the opted-in lifecycle adapter; prompt digest ${promptDigest.slice(0, 16)}.`,
262
+ confidence: detected.confidence,
263
+ observedAt: at
264
+ };
265
+ let existing = (await loadLearning(storageRoot, targetCatalog)).learning.candidates.find((item) => item.id === learningId);
266
+ if (!existing) {
267
+ try {
268
+ await proposeLearning({
269
+ root: storageRoot, id: learningId, kind: detected.kind, claim: detected.claim, subjectId,
270
+ privacy: subjectId === projectId ? "shared" : "private", groupId: null, evidence, now: at,
271
+ catalog: targetCatalog
272
+ });
273
+ } catch (error) {
274
+ if (!/candidate IDs are immutable/.test(error.message)) throw error;
275
+ }
276
+ existing = (await loadLearning(storageRoot, targetCatalog)).learning.candidates.find((item) => item.id === learningId);
277
+ }
278
+ if (existing?.status === "candidate" && !existing.evidence.some((item) => item.id === evidence.id)) {
279
+ try {
280
+ await addLearningEvidence({ root: storageRoot, id: learningId, evidence, now: at, catalog: targetCatalog });
281
+ } catch (error) {
282
+ if (!/duplicate evidence id|unreviewed candidate/.test(error.message)) throw error;
283
+ }
284
+ }
285
+ const refreshed = (await loadLearning(storageRoot, targetCatalog)).learning.candidates.find((item) => item.id === learningId);
286
+ let accepted = refreshed?.status === "accepted";
287
+ if (!accepted && refreshed?.status === "candidate") {
288
+ const distinct = new Set(refreshed.evidence.map((item) => item.id)).size;
289
+ const requiredEvidence = detected.explicit ? 1 : recorded.config.minEvidence;
290
+ if (refreshed.confidence >= recorded.config.minConfidence
291
+ && detected.directness >= recorded.config.minDirectness
292
+ && distinct >= requiredEvidence) {
293
+ await acceptContinuityLearning({
294
+ root: storageRoot, id: learningId, now: at, catalog: targetCatalog,
295
+ proof: {
296
+ mode: "automatic-continuity-low-risk", localOptIn: true,
297
+ minConfidence: recorded.config.minConfidence,
298
+ minDirectness: recorded.config.minDirectness,
299
+ minEvidence: requiredEvidence,
300
+ directness: detected.directness
301
+ }
302
+ });
303
+ accepted = true;
304
+ }
305
+ }
306
+ return {
307
+ enabled: true, captured: !recorded.duplicate, duplicate: recorded.duplicate,
308
+ learningId, accepted, kind: detected.kind, authority: "context-only"
309
+ };
310
+ }
311
+
312
+ export async function purgeContinuity({ root = process.cwd(), subjectId, confirmation = null, now = new Date() }) {
313
+ if (!ID_RE.test(subjectId || "")) throw new Error("subjectId is required");
314
+ if (confirmation !== "local-user-confirmed") throw new Error("continuity purge requires explicit local user confirmation");
315
+ const at = timestamp(now);
316
+ const result = await mutate(root, (state, _catalog, continuityPath) => {
317
+ const signalCount = state.signals.filter((item) => item.subjectId === subjectId).length;
318
+ state.signals = state.signals.filter((item) => item.subjectId !== subjectId);
319
+ state.history = state.history.filter((item) => item.subjectId !== subjectId);
320
+ state.history.push({ kind: "subject-purged", subjectId: digest(subjectId), at, authority: "context-only" });
321
+ return { signalCount, continuityPath };
322
+ });
323
+ const learning = await purgeLearningBySubject({ root, subjectId });
324
+ return { subjectId, deletedSignals: result.signalCount, deletedLearning: learning.deleted, authority: "context-only" };
325
+ }
326
+
327
+ export function continuityFindings(continuity) {
328
+ const findings = [];
329
+ if (!validConfig(continuity.config)) findings.push("invalid-config");
330
+ const eventKeys = new Set();
331
+ for (const item of continuity.signals) {
332
+ if (!/^[a-f0-9]{64}$/.test(item.eventKey || "") || eventKeys.has(item.eventKey)) findings.push(`invalid-or-duplicate-signal:${item.eventKey || "unknown"}`);
333
+ eventKeys.add(item.eventKey);
334
+ if (!SAFE_KINDS.has(item.kind) || item.groupId !== null || typeof item.explicit !== "boolean" || item.authority !== "context-only") findings.push(`unsafe-signal:${item.eventKey || "unknown"}`);
335
+ }
336
+ if (continuity.history.some((item) => item.authority !== "context-only")) findings.push("history-authority");
337
+ return findings;
338
+ }