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,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
|
+
}
|