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,755 +1,755 @@
|
|
|
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 { loadCoordination } from "./coordination.js";
|
|
6
|
-
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
7
|
-
import { loadGraph } from "./graph.js";
|
|
8
|
-
import { projectStateDir } from "./paths.js";
|
|
9
|
-
|
|
10
|
-
const SIGNAL_KINDS = new Set(["unanswered-question", "promise", "check-in", "meaningful-change"]);
|
|
11
|
-
const ACTIVITY_KINDS = new Set(["message", "interaction", "task", "check-in"]);
|
|
12
|
-
const EVENT_KINDS = new Set(["heartbeat", "promise", "blocker"]);
|
|
13
|
-
const PRIVACY = new Set(["private", "shared", "group"]);
|
|
14
|
-
const STATUSES = new Set(["open", "completed", "dismissed"]);
|
|
15
|
-
const EVENT_STATUSES = {
|
|
16
|
-
heartbeat: new Set(["active", "stopped"]),
|
|
17
|
-
promise: new Set(["open", "completed", "dismissed"]),
|
|
18
|
-
blocker: new Set(["open", "resolved", "dismissed"])
|
|
19
|
-
};
|
|
20
|
-
const HOOK_EVENTS = new Set(["UserPromptSubmit", "PostToolUse", "Stop", "SubagentStop"]);
|
|
21
|
-
const TEAM_RELATIONS = new Set(["works-with", "member-of", "reports-to", "responsible-for"]);
|
|
22
|
-
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
23
|
-
const MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
24
|
-
const UNSAFE_EVENT_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,}|\b(?:permission|permissions|rights?|roles?|delegat|authorized|authorization|berechtigt|rechte|rolle|freigabe|approval|approve|admin|deploy|production|produktion|billing|payment|zahlung|spending|network access|netzwerkzugriff|database access|datenbankzugriff|tool access|dateizugriff|file access|same person|same identity|alias of|merge identit|identit(?:y|ät)|private group|private gruppe|private chat|privater chat|permisos?|derechos?|autorizad[oa]|delegación|aprobación|producción|pagos?|acceso (?:a la )?red|acceso (?:a la )?base de datos|misma persona|misma identidad|grupo privado|chat privado|rättigheter|behörighet|delegering|godkännande|produktion|betalning|nätverksåtkomst|databasåtkomst|samma person|samma identitet|privat grupp|privat chatt)\b/i;
|
|
25
|
-
|
|
26
|
-
function defaultConfig() {
|
|
27
|
-
return {
|
|
28
|
-
enabled: true,
|
|
29
|
-
minIntervalHours: 24,
|
|
30
|
-
entitySilenceDays: 14,
|
|
31
|
-
heartbeatStaleMinutes: 30,
|
|
32
|
-
maxItems: 3,
|
|
33
|
-
quietHours: null
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function emptyAttention(root) {
|
|
38
|
-
return {
|
|
39
|
-
schema: "agentspine.attention/v2",
|
|
40
|
-
root,
|
|
41
|
-
config: defaultConfig(),
|
|
42
|
-
signals: [],
|
|
43
|
-
activities: [],
|
|
44
|
-
events: [],
|
|
45
|
-
receipts: [],
|
|
46
|
-
history: [],
|
|
47
|
-
presentations: {}
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function normalizeAttention(value, root) {
|
|
52
|
-
const state = value && typeof value === "object" ? value : emptyAttention(root);
|
|
53
|
-
const originalSchema = state.schema;
|
|
54
|
-
if (!["agentspine.attention/v1", "agentspine.attention/v2"].includes(state.schema) || state.root !== root) {
|
|
55
|
-
throw new Error("attention state structure is invalid; automatic attention is disabled until repaired");
|
|
56
|
-
}
|
|
57
|
-
state.schema = "agentspine.attention/v2";
|
|
58
|
-
state.config = { ...defaultConfig(), ...(state.config && typeof state.config === "object" ? state.config : {}) };
|
|
59
|
-
for (const key of ["signals", "activities", "history"]) {
|
|
60
|
-
if (!Array.isArray(state[key])) throw new Error("attention state structure is invalid; automatic attention is disabled until repaired");
|
|
61
|
-
}
|
|
62
|
-
for (const key of ["events", "receipts"]) {
|
|
63
|
-
if (state[key] === undefined && originalSchema === "agentspine.attention/v1") state[key] = [];
|
|
64
|
-
if (!Array.isArray(state[key])) throw new Error("attention state structure is invalid; automatic attention is disabled until repaired");
|
|
65
|
-
}
|
|
66
|
-
if (!state.presentations || Array.isArray(state.presentations) || typeof state.presentations !== "object") {
|
|
67
|
-
throw new Error("attention state structure is invalid; automatic attention is disabled until repaired");
|
|
68
|
-
}
|
|
69
|
-
if (state.events.some((event) => !validEventRecord(event)) || state.receipts.some((receipt) => !validReceipt(receipt))) {
|
|
70
|
-
throw new Error("attention lifecycle state is invalid; automatic attention is disabled until repaired");
|
|
71
|
-
}
|
|
72
|
-
return state;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function validEventRecord(event) {
|
|
76
|
-
return event && ID_RE.test(event.id || "") && EVENT_KINDS.has(event.kind)
|
|
77
|
-
&& EVENT_STATUSES[event.kind]?.has(event.status) && PRIVACY.has(event.privacy)
|
|
78
|
-
&& (event.entityId === null || ID_RE.test(event.entityId))
|
|
79
|
-
&& (event.groupId === null || ID_RE.test(event.groupId))
|
|
80
|
-
&& ID_RE.test(event.projectId || "") && ID_RE.test(event.taskId || "")
|
|
81
|
-
&& typeof event.summary === "string" && event.summary.length > 0 && event.summary.length <= 280
|
|
82
|
-
&& Number.isInteger(event.occurrenceCount) && event.occurrenceCount >= 1
|
|
83
|
-
&& event.authority === "context-only"
|
|
84
|
-
&& Number.isFinite(new Date(event.createdAt).getTime())
|
|
85
|
-
&& Number.isFinite(new Date(event.updatedAt).getTime())
|
|
86
|
-
&& (event.dueAt === null || Number.isFinite(new Date(event.dueAt).getTime()))
|
|
87
|
-
&& !UNSAFE_EVENT_RE.test(event.summary)
|
|
88
|
-
&& event.provenance?.source === "native-lifecycle-hook"
|
|
89
|
-
&& ID_RE.test(event.provenance?.receiptId || "")
|
|
90
|
-
&& HOOK_EVENTS.has(event.provenance?.hookEvent)
|
|
91
|
-
&& new Set(["claude", "codex", "generic"]).has(event.provenance?.host)
|
|
92
|
-
&& /^[a-f0-9]{64}$/.test(event.provenance?.digest || "");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function validReceipt(receipt) {
|
|
96
|
-
return receipt && ID_RE.test(receipt.id || "") && ID_RE.test(receipt.eventId || "")
|
|
97
|
-
&& EVENT_KINDS.has(receipt.kind) && receipt.authority === "context-only"
|
|
98
|
-
&& /^[a-f0-9]{64}$/.test(receipt.digest || "")
|
|
99
|
-
&& Number.isFinite(new Date(receipt.at).getTime());
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function normalizeDate(value, field, nullable = false) {
|
|
103
|
-
if ((value === null || value === undefined || value === "") && nullable) return null;
|
|
104
|
-
const date = value instanceof Date ? value : new Date(value);
|
|
105
|
-
if (!Number.isFinite(date.getTime())) throw new Error(`${field} must be a valid date`);
|
|
106
|
-
return date.toISOString();
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function normalizeNumber(value, field, minimum, maximum) {
|
|
110
|
-
const number = Number(value);
|
|
111
|
-
if (!Number.isFinite(number) || number < minimum || number > maximum) {
|
|
112
|
-
throw new Error(`${field} must be between ${minimum} and ${maximum}`);
|
|
113
|
-
}
|
|
114
|
-
return number;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function normalizeInteger(value, field, minimum, maximum) {
|
|
118
|
-
const number = normalizeNumber(value, field, minimum, maximum);
|
|
119
|
-
if (!Number.isInteger(number)) throw new Error(`${field} must be an integer`);
|
|
120
|
-
return number;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function validConfig(config) {
|
|
124
|
-
const quiet = config.quietHours;
|
|
125
|
-
const quietValid = quiet === null || (
|
|
126
|
-
quiet && typeof quiet === "object"
|
|
127
|
-
&& Number.isInteger(quiet.start) && quiet.start >= 0 && quiet.start <= 23
|
|
128
|
-
&& Number.isInteger(quiet.end) && quiet.end >= 0 && quiet.end <= 23
|
|
129
|
-
&& Number.isFinite(quiet.utcOffsetMinutes) && quiet.utcOffsetMinutes >= -720 && quiet.utcOffsetMinutes <= 840
|
|
130
|
-
);
|
|
131
|
-
return typeof config.enabled === "boolean"
|
|
132
|
-
&& Number.isFinite(config.minIntervalHours) && config.minIntervalHours >= 1 && config.minIntervalHours <= 720
|
|
133
|
-
&& Number.isFinite(config.entitySilenceDays) && config.entitySilenceDays >= 1 && config.entitySilenceDays <= 3650
|
|
134
|
-
&& Number.isInteger(config.heartbeatStaleMinutes) && config.heartbeatStaleMinutes >= 1 && config.heartbeatStaleMinutes <= 10080
|
|
135
|
-
&& Number.isInteger(config.maxItems) && config.maxItems >= 1 && config.maxItems <= 20
|
|
136
|
-
&& quietValid;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function normalizeRelativePath(value, field) {
|
|
140
|
-
if (value === null || value === undefined || value === "") return null;
|
|
141
|
-
if (typeof value !== "string") throw new Error(`${field} must be a project-relative path`);
|
|
142
|
-
const normalized = value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
143
|
-
if (normalized.startsWith("/") || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
144
|
-
throw new Error(`${field} must be a project-relative path`);
|
|
145
|
-
}
|
|
146
|
-
return normalized;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function readAttentionFile(path, root) {
|
|
150
|
-
try {
|
|
151
|
-
const metadata = await stat(path);
|
|
152
|
-
if (metadata.size > MAX_STATE_BYTES) throw new Error("attention state exceeds the 5 MiB read limit");
|
|
153
|
-
return normalizeAttention(JSON.parse(await readFile(path, "utf8")), root);
|
|
154
|
-
} catch (error) {
|
|
155
|
-
if (error.code !== "ENOENT") throw error;
|
|
156
|
-
return emptyAttention(root);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export async function loadAttention(root = process.cwd(), providedCatalog = null) {
|
|
161
|
-
const catalog = providedCatalog || await buildCatalog(root);
|
|
162
|
-
const directory = await projectStateDir(catalog.root);
|
|
163
|
-
const attentionPath = join(directory, "attention.json");
|
|
164
|
-
return { attention: await readAttentionFile(attentionPath, catalog.root), attentionPath, catalog };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export async function inspectAttention(root = process.cwd(), providedCatalog = null) {
|
|
168
|
-
const catalog = providedCatalog || await buildCatalog(root);
|
|
169
|
-
const directory = await projectStateDir(catalog.root);
|
|
170
|
-
const attentionPath = join(directory, "attention.json");
|
|
171
|
-
try {
|
|
172
|
-
return { attention: await readAttentionFile(attentionPath, catalog.root), attentionPath, catalog, error: null };
|
|
173
|
-
} catch (error) {
|
|
174
|
-
return { attention: emptyAttention(catalog.root), attentionPath, catalog, error: error.message };
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export function attentionFindings(attention) {
|
|
179
|
-
const findings = [];
|
|
180
|
-
if (!attention || attention.schema !== "agentspine.attention/v2") findings.push("invalid-schema");
|
|
181
|
-
if (!validConfig(attention?.config || {})) findings.push("invalid-config");
|
|
182
|
-
const ids = new Set();
|
|
183
|
-
for (const event of attention?.events || []) {
|
|
184
|
-
if (!validEventRecord(event)) findings.push(`invalid-event:${event?.id || "unknown"}`);
|
|
185
|
-
if (ids.has(event.id)) findings.push(`duplicate-event:${event.id}`);
|
|
186
|
-
ids.add(event.id);
|
|
187
|
-
}
|
|
188
|
-
const receiptIds = new Set();
|
|
189
|
-
for (const receipt of attention?.receipts || []) {
|
|
190
|
-
if (!validReceipt(receipt)) findings.push(`invalid-receipt:${receipt?.id || "unknown"}`);
|
|
191
|
-
if (receiptIds.has(receipt.id)) findings.push(`duplicate-receipt:${receipt.id}`);
|
|
192
|
-
if (!ids.has(receipt.eventId)) findings.push(`orphan-receipt:${receipt.id}`);
|
|
193
|
-
receiptIds.add(receipt.id);
|
|
194
|
-
}
|
|
195
|
-
for (const event of attention?.events || []) {
|
|
196
|
-
const receipt = (attention.receipts || []).find((item) => item.id === event.provenance?.receiptId);
|
|
197
|
-
if (!receipt || receipt.eventId !== event.id || receipt.digest !== event.provenance.digest) {
|
|
198
|
-
findings.push(`event-provenance-receipt-mismatch:${event.id}`);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return findings;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async function saveAttention(state, path) {
|
|
205
|
-
const content = `${JSON.stringify(state, null, 2)}\n`;
|
|
206
|
-
if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("attention state exceeds 5 MiB; resolve or delete old cues first");
|
|
207
|
-
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
208
|
-
await writeFile(temporary, content, { mode: 0o600 });
|
|
209
|
-
await replaceFileWithRetry(temporary, path);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
async function withAttentionLock(path, task) {
|
|
213
|
-
const lockPath = `${path}.lock`;
|
|
214
|
-
let handle;
|
|
215
|
-
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
216
|
-
try {
|
|
217
|
-
handle = await open(lockPath, "wx", 0o600);
|
|
218
|
-
break;
|
|
219
|
-
} catch (error) {
|
|
220
|
-
if (!isFileLockContention(error)) throw error;
|
|
221
|
-
try {
|
|
222
|
-
const metadata = await stat(lockPath);
|
|
223
|
-
if (Date.now() - metadata.mtimeMs > 15000) await unlink(lockPath);
|
|
224
|
-
} catch (lockError) {
|
|
225
|
-
if (lockError.code !== "ENOENT") throw lockError;
|
|
226
|
-
}
|
|
227
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
if (!handle) throw new Error("attention state is busy; retry shortly");
|
|
231
|
-
try {
|
|
232
|
-
const state = await readAttentionFile(path, task.root);
|
|
233
|
-
const result = await task.run(state);
|
|
234
|
-
await saveAttention(state, path);
|
|
235
|
-
return result;
|
|
236
|
-
} finally {
|
|
237
|
-
await handle.close();
|
|
238
|
-
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function preservePrevious(state, kind, value, now) {
|
|
243
|
-
if (!value) return;
|
|
244
|
-
state.history.push({
|
|
245
|
-
kind,
|
|
246
|
-
recordId: value.id,
|
|
247
|
-
entityId: value.entityId || null,
|
|
248
|
-
supersededAt: now,
|
|
249
|
-
value: { ...value, authority: "context-only" },
|
|
250
|
-
privacy: value.privacy || "private",
|
|
251
|
-
authority: "context-only"
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function digest(value) {
|
|
256
|
-
return createHash("sha256").update(value).digest("hex");
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function eventIdentity(kind, scope, summary) {
|
|
260
|
-
const canonical = [kind, scope.entityId || "", scope.groupId || "", scope.projectId || "", scope.taskId || "", summary.trim().toLowerCase()].join("\0");
|
|
261
|
-
return `event:${kind}:${digest(canonical).slice(0, 24)}`;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function validateKnownScope(graph, { entityId, groupId, projectId, taskId, privacy }) {
|
|
265
|
-
if (!entityId) throw new Error("attention events require an exact known actor identity");
|
|
266
|
-
if (!projectId || !taskId) throw new Error("attention events require exact project and task scope");
|
|
267
|
-
if (entityId && !graph.entities.some((entity) => entity.id === entityId && ["person", "agent"].includes(entity.kind))) {
|
|
268
|
-
throw new Error(`unknown person or agent entity: ${entityId}`);
|
|
269
|
-
}
|
|
270
|
-
if (!graph.entities.some((entity) => entity.id === projectId && entity.kind === "project")) {
|
|
271
|
-
throw new Error(`unknown project entity: ${projectId}`);
|
|
272
|
-
}
|
|
273
|
-
validateGroupScope(privacy, groupId, graph, entityId);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function validateEventSummary(summary) {
|
|
277
|
-
if (!summary || typeof summary !== "string") throw new Error("event summary is required");
|
|
278
|
-
const compact = summary.trim().replace(/\s+/g, " ").slice(0, 280);
|
|
279
|
-
if (!compact) throw new Error("event summary is required");
|
|
280
|
-
if (UNSAFE_EVENT_RE.test(compact)) throw new Error("secret, identity, authority, or operational-rights content was rejected");
|
|
281
|
-
return compact;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** Record or transition a minimal provider-neutral lifecycle event. */
|
|
285
|
-
export async function recordAttentionEvent({
|
|
286
|
-
root = process.cwd(), id = null, kind, summary, status = null,
|
|
287
|
-
entityId = null, groupId = null, projectId = null, taskId = null,
|
|
288
|
-
privacy = "private", dueAt = null, receiptId, host, hookEvent,
|
|
289
|
-
observedAt = new Date(), catalog: providedCatalog = null
|
|
290
|
-
}) {
|
|
291
|
-
if (!EVENT_KINDS.has(kind)) throw new Error(`unsupported attention event kind: ${kind}`);
|
|
292
|
-
if (!PRIVACY.has(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
293
|
-
if (!ID_RE.test(receiptId || "")) throw new Error("receiptId is required for idempotent lifecycle recording");
|
|
294
|
-
if (!new Set(["claude", "codex", "generic"]).has(host)) throw new Error("event host is invalid");
|
|
295
|
-
if (!HOOK_EVENTS.has(hookEvent)) throw new Error("event provenance must name a supported lifecycle hook");
|
|
296
|
-
for (const [field, value] of Object.entries({ entityId, groupId, projectId, taskId })) {
|
|
297
|
-
if (value !== null && !ID_RE.test(value)) throw new Error(`${field} is invalid`);
|
|
298
|
-
}
|
|
299
|
-
const at = normalizeDate(observedAt, "observedAt");
|
|
300
|
-
const normalizedDueAt = normalizeDate(dueAt, "dueAt", true);
|
|
301
|
-
const normalizedSummary = validateEventSummary(summary);
|
|
302
|
-
const nextStatus = status || (kind === "heartbeat" ? "active" : "open");
|
|
303
|
-
if (!EVENT_STATUSES[kind].has(nextStatus)) throw new Error(`unsupported ${kind} status: ${nextStatus}`);
|
|
304
|
-
const scope = { entityId, groupId, projectId, taskId };
|
|
305
|
-
const eventId = id || eventIdentity(kind, scope, normalizedSummary);
|
|
306
|
-
if (!ID_RE.test(eventId)) throw new Error("event id must be stable and whitespace-free");
|
|
307
|
-
const provenanceDigest = digest(JSON.stringify({ kind, summary: normalizedSummary, status: nextStatus, ...scope }));
|
|
308
|
-
return attentionMutation(root, async (state, catalog, attentionPath) => {
|
|
309
|
-
const duplicate = state.receipts.find((receipt) => receipt.id === receiptId);
|
|
310
|
-
if (duplicate) {
|
|
311
|
-
if (duplicate.eventId !== eventId || duplicate.kind !== kind || duplicate.digest !== provenanceDigest) {
|
|
312
|
-
throw new Error("attention receipt collision detected");
|
|
313
|
-
}
|
|
314
|
-
const event = state.events.find((item) => item.id === duplicate.eventId) || null;
|
|
315
|
-
return { event, duplicate: true, receipt: duplicate, attentionPath };
|
|
316
|
-
}
|
|
317
|
-
const { graph } = await loadGraph(catalog.root, catalog);
|
|
318
|
-
validateKnownScope(graph, { ...scope, privacy });
|
|
319
|
-
const { coordination } = await loadCoordination(catalog.root, catalog);
|
|
320
|
-
const task = coordination.tasks.find((item) => item.id === taskId);
|
|
321
|
-
if (!task || task.projectId !== projectId) throw new Error("attention event task must exist in the exact project scope");
|
|
322
|
-
if (![task.createdBy, task.assigneeId].includes(entityId)) {
|
|
323
|
-
throw new Error("attention event actor must be the task creator or assignee");
|
|
324
|
-
}
|
|
325
|
-
if (task.groupId !== groupId && (task.groupId !== null || groupId !== null)) {
|
|
326
|
-
throw new Error("attention event group scope must match the task");
|
|
327
|
-
}
|
|
328
|
-
if (stricterPrivacy(privacy, task.privacy) !== privacy) {
|
|
329
|
-
throw new Error("attention event privacy cannot be broader than the task");
|
|
330
|
-
}
|
|
331
|
-
const previous = state.events.find((item) => item.id === eventId);
|
|
332
|
-
if (previous && (previous.kind !== kind || previous.entityId !== entityId || previous.groupId !== groupId
|
|
333
|
-
|| previous.projectId !== projectId || previous.taskId !== taskId || previous.privacy !== privacy)) {
|
|
334
|
-
throw new Error("stable attention event identity cannot change kind, scope, or privacy");
|
|
335
|
-
}
|
|
336
|
-
preservePrevious(state, "attention-event", previous, at);
|
|
337
|
-
const provenance = {
|
|
338
|
-
source: "native-lifecycle-hook", host, hookEvent, receiptId,
|
|
339
|
-
observedAt: at,
|
|
340
|
-
digest: provenanceDigest
|
|
341
|
-
};
|
|
342
|
-
const event = {
|
|
343
|
-
id: eventId, kind, summary: normalizedSummary, status: nextStatus,
|
|
344
|
-
entityId, groupId, projectId, taskId, privacy,
|
|
345
|
-
dueAt: normalizedDueAt,
|
|
346
|
-
createdAt: previous?.createdAt || at,
|
|
347
|
-
updatedAt: at,
|
|
348
|
-
occurrenceCount: (previous?.occurrenceCount || 0) + 1,
|
|
349
|
-
provenance,
|
|
350
|
-
authority: "context-only"
|
|
351
|
-
};
|
|
352
|
-
state.events = state.events.filter((item) => item.id !== eventId);
|
|
353
|
-
state.events.push(event);
|
|
354
|
-
state.events.sort((left, right) => left.id.localeCompare(right.id));
|
|
355
|
-
const receipt = { id: receiptId, eventId, kind, digest: provenanceDigest, at, authority: "context-only" };
|
|
356
|
-
state.receipts.push(receipt);
|
|
357
|
-
state.receipts.sort((left, right) => left.at.localeCompare(right.at) || left.id.localeCompare(right.id));
|
|
358
|
-
return { event, duplicate: false, receipt, attentionPath };
|
|
359
|
-
}, providedCatalog);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function isGroupMember(graph, groupId, entityId) {
|
|
363
|
-
if (!entityId || entityId === groupId) return true;
|
|
364
|
-
return graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private" && (
|
|
365
|
-
(edge.from === entityId && edge.to === groupId) || (edge.to === entityId && edge.from === groupId)
|
|
366
|
-
));
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function validateGroupScope(privacy, groupId, graph, entityId = null) {
|
|
370
|
-
if (privacy === "group") {
|
|
371
|
-
if (!groupId) throw new Error("group privacy requires groupId");
|
|
372
|
-
const group = graph.entities.find((entity) => entity.id === groupId);
|
|
373
|
-
if (!group || group.kind !== "group") throw new Error(`unknown group entity: ${groupId}`);
|
|
374
|
-
if (!isGroupMember(graph, groupId, entityId)) throw new Error(`entity is not a visible member of group: ${groupId}`);
|
|
375
|
-
} else if (groupId !== null && groupId !== undefined) {
|
|
376
|
-
throw new Error("groupId is only valid with group privacy");
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
async function attentionMutation(root, operation, providedCatalog = null) {
|
|
381
|
-
const catalog = providedCatalog || await buildCatalog(root);
|
|
382
|
-
const { attentionPath } = await loadAttention(catalog.root, catalog);
|
|
383
|
-
return withAttentionLock(attentionPath, { root: catalog.root, run: (state) => operation(state, catalog, attentionPath) });
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
export async function upsertAttention({
|
|
387
|
-
root = process.cwd(), id = `signal:${randomUUID()}`, kind, summary, entityId = null,
|
|
388
|
-
dueAt = null, priority = 50, privacy = "private", groupId = null, sourceDocument = null,
|
|
389
|
-
confidence = 0.5, now = new Date()
|
|
390
|
-
}) {
|
|
391
|
-
if (!ID_RE.test(id)) throw new Error("id must be a stable, whitespace-free identifier");
|
|
392
|
-
if (!SIGNAL_KINDS.has(kind)) throw new Error(`unsupported attention kind: ${kind}`);
|
|
393
|
-
if (!summary || typeof summary !== "string") throw new Error("summary is required");
|
|
394
|
-
if (!PRIVACY.has(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
395
|
-
const timestamp = normalizeDate(now, "now");
|
|
396
|
-
const normalizedDueAt = normalizeDate(dueAt, "dueAt", true);
|
|
397
|
-
sourceDocument = normalizeRelativePath(sourceDocument, "sourceDocument");
|
|
398
|
-
return attentionMutation(root, async (state, catalog, attentionPath) => {
|
|
399
|
-
const { graph } = await loadGraph(catalog.root, catalog);
|
|
400
|
-
if (entityId !== null && !graph.entities.some((entity) => entity.id === entityId)) throw new Error(`unknown entity: ${entityId}`);
|
|
401
|
-
validateGroupScope(privacy, groupId, graph, entityId);
|
|
402
|
-
if (sourceDocument !== null && !catalog.documents.some((document) => document.relativePath === sourceDocument)) {
|
|
403
|
-
throw new Error(`unknown source document: ${sourceDocument}`);
|
|
404
|
-
}
|
|
405
|
-
const previous = state.signals.find((signal) => signal.id === id);
|
|
406
|
-
const signal = {
|
|
407
|
-
id,
|
|
408
|
-
kind,
|
|
409
|
-
summary: summary.slice(0, 500),
|
|
410
|
-
entityId,
|
|
411
|
-
dueAt: normalizedDueAt,
|
|
412
|
-
priority: normalizeNumber(priority, "priority", 0, 100),
|
|
413
|
-
privacy,
|
|
414
|
-
groupId,
|
|
415
|
-
sourceDocument,
|
|
416
|
-
confidence: normalizeNumber(confidence, "confidence", 0, 1),
|
|
417
|
-
status: "open",
|
|
418
|
-
createdAt: previous?.createdAt || timestamp,
|
|
419
|
-
updatedAt: timestamp,
|
|
420
|
-
authority: "context-only"
|
|
421
|
-
};
|
|
422
|
-
preservePrevious(state, "attention-signal", previous, timestamp);
|
|
423
|
-
state.signals = state.signals.filter((item) => item.id !== id);
|
|
424
|
-
state.signals.push(signal);
|
|
425
|
-
state.signals.sort((a, b) => a.id.localeCompare(b.id));
|
|
426
|
-
return { signal, attentionPath };
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
export async function resolveAttention({ root = process.cwd(), id, status = "completed", now = new Date() }) {
|
|
431
|
-
if (!ID_RE.test(id || "")) throw new Error("id is required");
|
|
432
|
-
if (!STATUSES.has(status)) throw new Error(`unsupported attention status: ${status}`);
|
|
433
|
-
const timestamp = normalizeDate(now, "now");
|
|
434
|
-
return attentionMutation(root, (state, _catalog, attentionPath) => {
|
|
435
|
-
const previous = state.signals.find((signal) => signal.id === id);
|
|
436
|
-
if (!previous) throw new Error(`unknown attention signal: ${id}`);
|
|
437
|
-
preservePrevious(state, "attention-signal", previous, timestamp);
|
|
438
|
-
const signal = { ...previous, status, updatedAt: timestamp, authority: "context-only" };
|
|
439
|
-
state.signals = state.signals.map((item) => item.id === id ? signal : item);
|
|
440
|
-
return { signal, attentionPath };
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
export async function recordActivity({
|
|
445
|
-
root = process.cwd(), entityId, kind = "interaction", at = new Date(), privacy = "private", groupId = null
|
|
446
|
-
}) {
|
|
447
|
-
if (!ID_RE.test(entityId || "")) throw new Error("entityId is required");
|
|
448
|
-
if (!ACTIVITY_KINDS.has(kind)) throw new Error(`unsupported activity kind: ${kind}`);
|
|
449
|
-
if (!PRIVACY.has(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
450
|
-
const timestamp = normalizeDate(at, "at");
|
|
451
|
-
return attentionMutation(root, async (state, catalog, attentionPath) => {
|
|
452
|
-
const { graph } = await loadGraph(catalog.root, catalog);
|
|
453
|
-
if (!graph.entities.some((entity) => entity.id === entityId)) throw new Error(`unknown entity: ${entityId}`);
|
|
454
|
-
validateGroupScope(privacy, groupId, graph, entityId);
|
|
455
|
-
const activity = {
|
|
456
|
-
id: `activity:${randomUUID()}`,
|
|
457
|
-
entityId,
|
|
458
|
-
kind,
|
|
459
|
-
at: timestamp,
|
|
460
|
-
privacy,
|
|
461
|
-
groupId,
|
|
462
|
-
authority: "context-only"
|
|
463
|
-
};
|
|
464
|
-
state.activities.push(activity);
|
|
465
|
-
state.activities.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
|
|
466
|
-
return { activity, attentionPath };
|
|
467
|
-
});
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function normalizeQuietHours(value) {
|
|
471
|
-
if (value === null || value === false) return null;
|
|
472
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("quietHours must be an object or null");
|
|
473
|
-
return {
|
|
474
|
-
start: normalizeInteger(value.start, "quietHours.start", 0, 23),
|
|
475
|
-
end: normalizeInteger(value.end, "quietHours.end", 0, 23),
|
|
476
|
-
utcOffsetMinutes: normalizeInteger(value.utcOffsetMinutes ?? 0, "quietHours.utcOffsetMinutes", -720, 840)
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
export async function configureAttention({ root = process.cwd(), config = {}, now = new Date() }) {
|
|
481
|
-
if (!config || Array.isArray(config) || typeof config !== "object") throw new Error("config must be an object");
|
|
482
|
-
if (!Object.keys(config).length) throw new Error("config must change at least one attention setting");
|
|
483
|
-
const allowed = new Set(["enabled", "minIntervalHours", "entitySilenceDays", "heartbeatStaleMinutes", "maxItems", "quietHours"]);
|
|
484
|
-
const unknown = Object.keys(config).filter((key) => !allowed.has(key));
|
|
485
|
-
if (unknown.length) throw new Error(`unsupported attention config: ${unknown.join(", ")}`);
|
|
486
|
-
const timestamp = normalizeDate(now, "now");
|
|
487
|
-
return attentionMutation(root, (state, _catalog, attentionPath) => {
|
|
488
|
-
preservePrevious(state, "attention-config", { id: "config", ...state.config, privacy: "private" }, timestamp);
|
|
489
|
-
if ("enabled" in config) {
|
|
490
|
-
if (typeof config.enabled !== "boolean") throw new Error("enabled must be boolean");
|
|
491
|
-
state.config.enabled = config.enabled;
|
|
492
|
-
}
|
|
493
|
-
if ("minIntervalHours" in config) state.config.minIntervalHours = normalizeNumber(config.minIntervalHours, "minIntervalHours", 1, 720);
|
|
494
|
-
if ("entitySilenceDays" in config) state.config.entitySilenceDays = normalizeNumber(config.entitySilenceDays, "entitySilenceDays", 1, 3650);
|
|
495
|
-
if ("heartbeatStaleMinutes" in config) state.config.heartbeatStaleMinutes = normalizeInteger(config.heartbeatStaleMinutes, "heartbeatStaleMinutes", 1, 10080);
|
|
496
|
-
if ("maxItems" in config) state.config.maxItems = normalizeInteger(config.maxItems, "maxItems", 1, 20);
|
|
497
|
-
if ("quietHours" in config) state.config.quietHours = normalizeQuietHours(config.quietHours);
|
|
498
|
-
if (!validConfig(state.config)) throw new Error("resulting attention configuration is invalid");
|
|
499
|
-
return { config: state.config, attentionPath };
|
|
500
|
-
});
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
function isQuiet(now, quietHours) {
|
|
504
|
-
if (!quietHours) return false;
|
|
505
|
-
const shifted = new Date(now.getTime() + quietHours.utcOffsetMinutes * 60000);
|
|
506
|
-
const hour = shifted.getUTCHours();
|
|
507
|
-
if (quietHours.start === quietHours.end) return true;
|
|
508
|
-
if (quietHours.start < quietHours.end) return hour >= quietHours.start && hour < quietHours.end;
|
|
509
|
-
return hour >= quietHours.start || hour < quietHours.end;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function recentlyPresented(state, key, now) {
|
|
513
|
-
const value = state.presentations[key];
|
|
514
|
-
if (!value) return false;
|
|
515
|
-
return now.getTime() - new Date(value).getTime() < state.config.minIntervalHours * 3600000;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
function groupEntityIds(graph, groupId, includePrivate) {
|
|
519
|
-
const ids = new Set();
|
|
520
|
-
if (!groupId) return ids;
|
|
521
|
-
ids.add(groupId);
|
|
522
|
-
for (const edge of graph.entityEdges) {
|
|
523
|
-
if (edge.relation !== "member-of" || (!includePrivate && edge.privacy === "private")) continue;
|
|
524
|
-
if (edge.to === groupId) ids.add(edge.from);
|
|
525
|
-
if (edge.from === groupId) ids.add(edge.to);
|
|
526
|
-
}
|
|
527
|
-
return ids;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
function entityVisible(entity, includePrivate, groupEntities) {
|
|
531
|
-
if (!entity) return true;
|
|
532
|
-
if (entity.privacy === "group") return groupEntities.has(entity.id);
|
|
533
|
-
if (entity.privacy === "private") return includePrivate;
|
|
534
|
-
return true;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function visible(record, entities, includePrivate, groupId, groupEntities) {
|
|
538
|
-
if (!includePrivate && record.privacy === "private") return false;
|
|
539
|
-
if (record.privacy === "group" && (!groupId || record.groupId !== groupId)) return false;
|
|
540
|
-
if (record.privacy === "group" && record.entityId && !groupEntities.has(record.entityId)) return false;
|
|
541
|
-
if (record.entityId) {
|
|
542
|
-
const entity = entities.get(record.entityId);
|
|
543
|
-
if (!entityVisible(entity, includePrivate, groupEntities)) return false;
|
|
544
|
-
}
|
|
545
|
-
return true;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function stricterPrivacy(left, right) {
|
|
549
|
-
const rank = { shared: 0, group: 1, private: 2 };
|
|
550
|
-
return rank[left] >= rank[right] ? left : right;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function looserPrivacy(left, right) {
|
|
554
|
-
const rank = { shared: 0, group: 1, private: 2 };
|
|
555
|
-
return rank[left] <= rank[right] ? left : right;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
export async function attentionContext({
|
|
559
|
-
root = process.cwd(), includePrivate = false, focusActive = false,
|
|
560
|
-
markPresented = false, maxItems = null, entityId = null, groupId = null,
|
|
561
|
-
projectId = null, currentTaskId = null, now = new Date(), catalog: providedCatalog = null
|
|
562
|
-
} = {}) {
|
|
563
|
-
const timestamp = normalizeDate(now, "now");
|
|
564
|
-
const current = new Date(timestamp);
|
|
565
|
-
const catalog = providedCatalog || await buildCatalog(root);
|
|
566
|
-
const { attention, attentionPath } = await loadAttention(catalog.root, catalog);
|
|
567
|
-
if (!validConfig(attention.config)) throw new Error("attention configuration is invalid; inspect or reset the external attention state");
|
|
568
|
-
const { graph } = await loadGraph(catalog.root, catalog);
|
|
569
|
-
const entities = new Map(graph.entities.map((entity) => [entity.id, entity]));
|
|
570
|
-
if (groupId !== null) {
|
|
571
|
-
const group = entities.get(groupId);
|
|
572
|
-
if (!group || group.kind !== "group") throw new Error(`unknown group entity: ${groupId}`);
|
|
573
|
-
}
|
|
574
|
-
const groupEntities = groupEntityIds(graph, groupId, includePrivate);
|
|
575
|
-
let suppressed = null;
|
|
576
|
-
if (!attention.config.enabled) suppressed = "disabled";
|
|
577
|
-
else if (isQuiet(current, attention.config.quietHours)) suppressed = "quiet-hours";
|
|
578
|
-
else if (focusActive) suppressed = "focus-active";
|
|
579
|
-
|
|
580
|
-
const candidates = [];
|
|
581
|
-
if (!suppressed) {
|
|
582
|
-
for (const signal of attention.signals) {
|
|
583
|
-
if (signal.status !== "open" || !visible(signal, entities, includePrivate, groupId, groupEntities)) continue;
|
|
584
|
-
if (!SIGNAL_KINDS.has(signal.kind) || !Number.isFinite(signal.priority) || !Number.isFinite(signal.confidence)) continue;
|
|
585
|
-
if (signal.dueAt && new Date(signal.dueAt) > current) continue;
|
|
586
|
-
if (signal.dueAt && !Number.isFinite(new Date(signal.dueAt).getTime())) continue;
|
|
587
|
-
const key = `cue:${signal.id}`;
|
|
588
|
-
if (recentlyPresented(attention, key, current)) continue;
|
|
589
|
-
const weights = { "unanswered-question": 30, promise: 25, "meaningful-change": 15, "check-in": 5 };
|
|
590
|
-
candidates.push({
|
|
591
|
-
key,
|
|
592
|
-
source: "signal",
|
|
593
|
-
score: signal.priority + weights[signal.kind],
|
|
594
|
-
kind: signal.kind,
|
|
595
|
-
summary: signal.summary,
|
|
596
|
-
entityId: signal.entityId,
|
|
597
|
-
dueAt: signal.dueAt,
|
|
598
|
-
privacy: signal.privacy,
|
|
599
|
-
groupId: signal.groupId || null,
|
|
600
|
-
confidence: signal.confidence,
|
|
601
|
-
authority: "context-only"
|
|
602
|
-
});
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
const teamEdges = graph.entityEdges
|
|
606
|
-
.filter((edge) => TEAM_RELATIONS.has(edge.relation))
|
|
607
|
-
.filter((edge) => includePrivate || edge.privacy !== "private")
|
|
608
|
-
.filter((edge) => edge.privacy !== "group" || (
|
|
609
|
-
groupId && [edge.from, edge.to].every((id) => groupEntities.has(id))
|
|
610
|
-
))
|
|
611
|
-
.filter((edge) => [edge.from, edge.to].every((id) => {
|
|
612
|
-
return entityVisible(entities.get(id), includePrivate, groupEntities);
|
|
613
|
-
}));
|
|
614
|
-
const teamIds = new Set(teamEdges.flatMap((edge) => [edge.from, edge.to]));
|
|
615
|
-
for (const entity of graph.entities) {
|
|
616
|
-
if (!teamIds.has(entity.id) || !["person", "agent"].includes(entity.kind)) continue;
|
|
617
|
-
if (!visible({ entityId: entity.id, privacy: entity.privacy, groupId }, entities, includePrivate, groupId, groupEntities)) continue;
|
|
618
|
-
const latestActivity = attention.activities
|
|
619
|
-
.filter((activity) => activity.entityId === entity.id && visible(activity, entities, includePrivate, groupId, groupEntities))
|
|
620
|
-
.sort((a, b) => b.at.localeCompare(a.at))[0];
|
|
621
|
-
const baseline = new Date(latestActivity?.at || entity.updatedAt);
|
|
622
|
-
const silenceDays = (current.getTime() - baseline.getTime()) / 86400000;
|
|
623
|
-
if (silenceDays < attention.config.entitySilenceDays) continue;
|
|
624
|
-
const key = `neglected:${entity.id}`;
|
|
625
|
-
if (recentlyPresented(attention, key, current)) continue;
|
|
626
|
-
const edgePrivacy = teamEdges
|
|
627
|
-
.filter((edge) => edge.from === entity.id || edge.to === entity.id)
|
|
628
|
-
.map((edge) => [edge.from, edge.to].reduce(
|
|
629
|
-
(scope, id) => stricterPrivacy(scope, entities.get(id)?.privacy || "private"),
|
|
630
|
-
edge.privacy
|
|
631
|
-
))
|
|
632
|
-
.reduce((scope, privacy) => scope === null ? privacy : looserPrivacy(scope, privacy), null);
|
|
633
|
-
const privacy = stricterPrivacy(entity.privacy, edgePrivacy || "private");
|
|
634
|
-
candidates.push({
|
|
635
|
-
key,
|
|
636
|
-
source: "relationship",
|
|
637
|
-
score: 40 + Math.min(30, Math.floor(silenceDays - attention.config.entitySilenceDays)),
|
|
638
|
-
kind: "check-in",
|
|
639
|
-
summary: `No recorded interaction with ${entity.displayName || entity.id} for ${Math.floor(silenceDays)} days.`,
|
|
640
|
-
entityId: entity.id,
|
|
641
|
-
dueAt: null,
|
|
642
|
-
privacy,
|
|
643
|
-
groupId: privacy === "group" ? groupId : null,
|
|
644
|
-
confidence: entity.confidence,
|
|
645
|
-
authority: "context-only"
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// Focus suppresses unrelated reminders but not a blocker or due promise for
|
|
651
|
-
// the exact task already in focus. Quiet hours and the global switch still
|
|
652
|
-
// suppress every presentation while retaining durable state.
|
|
653
|
-
if (attention.config.enabled && suppressed !== "quiet-hours") {
|
|
654
|
-
for (const event of attention.events) {
|
|
655
|
-
if (!EVENT_KINDS.has(event.kind) || !EVENT_STATUSES[event.kind]?.has(event.status)) continue;
|
|
656
|
-
if (!visible(event, entities, includePrivate, groupId, groupEntities)) continue;
|
|
657
|
-
if (event.entityId !== null && event.entityId !== entityId) continue;
|
|
658
|
-
if (event.projectId !== projectId || event.taskId !== currentTaskId) continue;
|
|
659
|
-
if (event.kind === "heartbeat" && event.status !== "active") continue;
|
|
660
|
-
if (event.kind === "promise" && event.status !== "open") continue;
|
|
661
|
-
if (event.kind === "blocker" && event.status !== "open") continue;
|
|
662
|
-
if (event.dueAt && new Date(event.dueAt) > current) continue;
|
|
663
|
-
if (event.dueAt && !Number.isFinite(new Date(event.dueAt).getTime())) continue;
|
|
664
|
-
if (event.kind === "heartbeat" && !event.dueAt) {
|
|
665
|
-
const staleAt = new Date(event.updatedAt).getTime() + attention.config.heartbeatStaleMinutes * 60000;
|
|
666
|
-
if (staleAt > current.getTime()) continue;
|
|
667
|
-
}
|
|
668
|
-
const key = `event:${event.id}`;
|
|
669
|
-
if (recentlyPresented(attention, key, current)) continue;
|
|
670
|
-
const weights = { blocker: 200, promise: 150, heartbeat: 80 };
|
|
671
|
-
candidates.push({
|
|
672
|
-
key, source: "lifecycle-event", score: weights[event.kind],
|
|
673
|
-
kind: event.kind, summary: event.summary, status: event.status,
|
|
674
|
-
entityId: event.entityId, projectId: event.projectId, taskId: event.taskId,
|
|
675
|
-
dueAt: event.dueAt, privacy: event.privacy, groupId: event.groupId,
|
|
676
|
-
occurrenceCount: event.occurrenceCount,
|
|
677
|
-
provenance: event.provenance,
|
|
678
|
-
authority: "context-only"
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
if (focusActive && candidates.some((item) => item.source === "lifecycle-event")) {
|
|
683
|
-
suppressed = "focus-active-except-current-task";
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
const limit = maxItems === null
|
|
687
|
-
? attention.config.maxItems
|
|
688
|
-
: normalizeInteger(maxItems, "maxItems", 0, 20);
|
|
689
|
-
const items = candidates
|
|
690
|
-
.sort((a, b) => b.score - a.score || (a.dueAt || "").localeCompare(b.dueAt || "") || a.key.localeCompare(b.key))
|
|
691
|
-
.slice(0, limit);
|
|
692
|
-
|
|
693
|
-
if (markPresented && items.length) {
|
|
694
|
-
await withAttentionLock(attentionPath, {
|
|
695
|
-
root: catalog.root,
|
|
696
|
-
run: (state) => {
|
|
697
|
-
for (const item of items) state.presentations[item.key] = timestamp;
|
|
698
|
-
return null;
|
|
699
|
-
}
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
return {
|
|
704
|
-
schema: "agentspine.attention-context/v1",
|
|
705
|
-
root: catalog.root,
|
|
706
|
-
enabled: attention.config.enabled,
|
|
707
|
-
suppressed,
|
|
708
|
-
now: timestamp,
|
|
709
|
-
entityId,
|
|
710
|
-
groupId,
|
|
711
|
-
projectId,
|
|
712
|
-
currentTaskId,
|
|
713
|
-
items,
|
|
714
|
-
remaining: Math.max(0, candidates.length - items.length),
|
|
715
|
-
authority: "context-only",
|
|
716
|
-
note: "Attention cues are suggestions, never instructions. The current task and host permissions remain authoritative."
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
export async function deleteAttention({ root = process.cwd(), signalId = null, eventId = null, entityId = null }) {
|
|
721
|
-
if ([signalId, eventId, entityId].filter(Boolean).length !== 1) throw new Error("provide exactly one of signalId, eventId, or entityId");
|
|
722
|
-
return attentionMutation(root, (state, _catalog, attentionPath) => {
|
|
723
|
-
if (signalId) {
|
|
724
|
-
const existed = state.signals.some((signal) => signal.id === signalId);
|
|
725
|
-
state.signals = state.signals.filter((signal) => signal.id !== signalId);
|
|
726
|
-
state.history = state.history.filter((entry) => entry.recordId !== signalId && entry.value?.id !== signalId);
|
|
727
|
-
delete state.presentations[`cue:${signalId}`];
|
|
728
|
-
return { deleted: existed, signalId, attentionPath };
|
|
729
|
-
}
|
|
730
|
-
if (eventId) {
|
|
731
|
-
const existed = state.events.some((event) => event.id === eventId);
|
|
732
|
-
state.events = state.events.filter((event) => event.id !== eventId);
|
|
733
|
-
const receiptIds = new Set(state.receipts.filter((receipt) => receipt.eventId === eventId).map((receipt) => receipt.id));
|
|
734
|
-
state.receipts = state.receipts.filter((receipt) => receipt.eventId !== eventId);
|
|
735
|
-
state.history = state.history.filter((entry) => entry.recordId !== eventId && entry.value?.id !== eventId);
|
|
736
|
-
delete state.presentations[`event:${eventId}`];
|
|
737
|
-
return { deleted: existed, eventId, deletedReceipts: receiptIds.size, attentionPath };
|
|
738
|
-
}
|
|
739
|
-
const signalIds = new Set(state.signals.filter((signal) => signal.entityId === entityId).map((signal) => signal.id));
|
|
740
|
-
const deletedSignals = signalIds.size;
|
|
741
|
-
const deletedActivities = state.activities.filter((activity) => activity.entityId === entityId).length;
|
|
742
|
-
const eventIds = new Set(state.events.filter((event) => event.entityId === entityId).map((event) => event.id));
|
|
743
|
-
const deletedEvents = eventIds.size;
|
|
744
|
-
state.signals = state.signals.filter((signal) => signal.entityId !== entityId);
|
|
745
|
-
state.activities = state.activities.filter((activity) => activity.entityId !== entityId);
|
|
746
|
-
state.events = state.events.filter((event) => event.entityId !== entityId);
|
|
747
|
-
state.receipts = state.receipts.filter((receipt) => !eventIds.has(receipt.eventId));
|
|
748
|
-
state.history = state.history.filter((entry) => entry.entityId !== entityId && entry.value?.entityId !== entityId && !signalIds.has(entry.recordId) && !eventIds.has(entry.recordId));
|
|
749
|
-
for (const key of Object.keys(state.presentations)) {
|
|
750
|
-
if (key === `neglected:${entityId}` || [...signalIds].some((id) => key === `cue:${id}`)) delete state.presentations[key];
|
|
751
|
-
if ([...eventIds].some((id) => key === `event:${id}`)) delete state.presentations[key];
|
|
752
|
-
}
|
|
753
|
-
return { deletedSignals, deletedActivities, deletedEvents, entityId, attentionPath };
|
|
754
|
-
});
|
|
755
|
-
}
|
|
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 { loadCoordination } from "./coordination.js";
|
|
6
|
+
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
7
|
+
import { loadGraph } from "./graph.js";
|
|
8
|
+
import { projectStateDir } from "./paths.js";
|
|
9
|
+
|
|
10
|
+
const SIGNAL_KINDS = new Set(["unanswered-question", "promise", "check-in", "meaningful-change"]);
|
|
11
|
+
const ACTIVITY_KINDS = new Set(["message", "interaction", "task", "check-in"]);
|
|
12
|
+
const EVENT_KINDS = new Set(["heartbeat", "promise", "blocker"]);
|
|
13
|
+
const PRIVACY = new Set(["private", "shared", "group"]);
|
|
14
|
+
const STATUSES = new Set(["open", "completed", "dismissed"]);
|
|
15
|
+
const EVENT_STATUSES = {
|
|
16
|
+
heartbeat: new Set(["active", "stopped"]),
|
|
17
|
+
promise: new Set(["open", "completed", "dismissed"]),
|
|
18
|
+
blocker: new Set(["open", "resolved", "dismissed"])
|
|
19
|
+
};
|
|
20
|
+
const HOOK_EVENTS = new Set(["UserPromptSubmit", "PostToolUse", "Stop", "SubagentStop"]);
|
|
21
|
+
const TEAM_RELATIONS = new Set(["works-with", "member-of", "reports-to", "responsible-for"]);
|
|
22
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
23
|
+
const MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
24
|
+
const UNSAFE_EVENT_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,}|\b(?:permission|permissions|rights?|roles?|delegat|authorized|authorization|berechtigt|rechte|rolle|freigabe|approval|approve|admin|deploy|production|produktion|billing|payment|zahlung|spending|network access|netzwerkzugriff|database access|datenbankzugriff|tool access|dateizugriff|file access|same person|same identity|alias of|merge identit|identit(?:y|ät)|private group|private gruppe|private chat|privater chat|permisos?|derechos?|autorizad[oa]|delegación|aprobación|producción|pagos?|acceso (?:a la )?red|acceso (?:a la )?base de datos|misma persona|misma identidad|grupo privado|chat privado|rättigheter|behörighet|delegering|godkännande|produktion|betalning|nätverksåtkomst|databasåtkomst|samma person|samma identitet|privat grupp|privat chatt)\b/i;
|
|
25
|
+
|
|
26
|
+
function defaultConfig() {
|
|
27
|
+
return {
|
|
28
|
+
enabled: true,
|
|
29
|
+
minIntervalHours: 24,
|
|
30
|
+
entitySilenceDays: 14,
|
|
31
|
+
heartbeatStaleMinutes: 30,
|
|
32
|
+
maxItems: 3,
|
|
33
|
+
quietHours: null
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function emptyAttention(root) {
|
|
38
|
+
return {
|
|
39
|
+
schema: "agentspine.attention/v2",
|
|
40
|
+
root,
|
|
41
|
+
config: defaultConfig(),
|
|
42
|
+
signals: [],
|
|
43
|
+
activities: [],
|
|
44
|
+
events: [],
|
|
45
|
+
receipts: [],
|
|
46
|
+
history: [],
|
|
47
|
+
presentations: {}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeAttention(value, root) {
|
|
52
|
+
const state = value && typeof value === "object" ? value : emptyAttention(root);
|
|
53
|
+
const originalSchema = state.schema;
|
|
54
|
+
if (!["agentspine.attention/v1", "agentspine.attention/v2"].includes(state.schema) || state.root !== root) {
|
|
55
|
+
throw new Error("attention state structure is invalid; automatic attention is disabled until repaired");
|
|
56
|
+
}
|
|
57
|
+
state.schema = "agentspine.attention/v2";
|
|
58
|
+
state.config = { ...defaultConfig(), ...(state.config && typeof state.config === "object" ? state.config : {}) };
|
|
59
|
+
for (const key of ["signals", "activities", "history"]) {
|
|
60
|
+
if (!Array.isArray(state[key])) throw new Error("attention state structure is invalid; automatic attention is disabled until repaired");
|
|
61
|
+
}
|
|
62
|
+
for (const key of ["events", "receipts"]) {
|
|
63
|
+
if (state[key] === undefined && originalSchema === "agentspine.attention/v1") state[key] = [];
|
|
64
|
+
if (!Array.isArray(state[key])) throw new Error("attention state structure is invalid; automatic attention is disabled until repaired");
|
|
65
|
+
}
|
|
66
|
+
if (!state.presentations || Array.isArray(state.presentations) || typeof state.presentations !== "object") {
|
|
67
|
+
throw new Error("attention state structure is invalid; automatic attention is disabled until repaired");
|
|
68
|
+
}
|
|
69
|
+
if (state.events.some((event) => !validEventRecord(event)) || state.receipts.some((receipt) => !validReceipt(receipt))) {
|
|
70
|
+
throw new Error("attention lifecycle state is invalid; automatic attention is disabled until repaired");
|
|
71
|
+
}
|
|
72
|
+
return state;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validEventRecord(event) {
|
|
76
|
+
return event && ID_RE.test(event.id || "") && EVENT_KINDS.has(event.kind)
|
|
77
|
+
&& EVENT_STATUSES[event.kind]?.has(event.status) && PRIVACY.has(event.privacy)
|
|
78
|
+
&& (event.entityId === null || ID_RE.test(event.entityId))
|
|
79
|
+
&& (event.groupId === null || ID_RE.test(event.groupId))
|
|
80
|
+
&& ID_RE.test(event.projectId || "") && ID_RE.test(event.taskId || "")
|
|
81
|
+
&& typeof event.summary === "string" && event.summary.length > 0 && event.summary.length <= 280
|
|
82
|
+
&& Number.isInteger(event.occurrenceCount) && event.occurrenceCount >= 1
|
|
83
|
+
&& event.authority === "context-only"
|
|
84
|
+
&& Number.isFinite(new Date(event.createdAt).getTime())
|
|
85
|
+
&& Number.isFinite(new Date(event.updatedAt).getTime())
|
|
86
|
+
&& (event.dueAt === null || Number.isFinite(new Date(event.dueAt).getTime()))
|
|
87
|
+
&& !UNSAFE_EVENT_RE.test(event.summary)
|
|
88
|
+
&& event.provenance?.source === "native-lifecycle-hook"
|
|
89
|
+
&& ID_RE.test(event.provenance?.receiptId || "")
|
|
90
|
+
&& HOOK_EVENTS.has(event.provenance?.hookEvent)
|
|
91
|
+
&& new Set(["claude", "codex", "generic"]).has(event.provenance?.host)
|
|
92
|
+
&& /^[a-f0-9]{64}$/.test(event.provenance?.digest || "");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validReceipt(receipt) {
|
|
96
|
+
return receipt && ID_RE.test(receipt.id || "") && ID_RE.test(receipt.eventId || "")
|
|
97
|
+
&& EVENT_KINDS.has(receipt.kind) && receipt.authority === "context-only"
|
|
98
|
+
&& /^[a-f0-9]{64}$/.test(receipt.digest || "")
|
|
99
|
+
&& Number.isFinite(new Date(receipt.at).getTime());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeDate(value, field, nullable = false) {
|
|
103
|
+
if ((value === null || value === undefined || value === "") && nullable) return null;
|
|
104
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
105
|
+
if (!Number.isFinite(date.getTime())) throw new Error(`${field} must be a valid date`);
|
|
106
|
+
return date.toISOString();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeNumber(value, field, minimum, maximum) {
|
|
110
|
+
const number = Number(value);
|
|
111
|
+
if (!Number.isFinite(number) || number < minimum || number > maximum) {
|
|
112
|
+
throw new Error(`${field} must be between ${minimum} and ${maximum}`);
|
|
113
|
+
}
|
|
114
|
+
return number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeInteger(value, field, minimum, maximum) {
|
|
118
|
+
const number = normalizeNumber(value, field, minimum, maximum);
|
|
119
|
+
if (!Number.isInteger(number)) throw new Error(`${field} must be an integer`);
|
|
120
|
+
return number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function validConfig(config) {
|
|
124
|
+
const quiet = config.quietHours;
|
|
125
|
+
const quietValid = quiet === null || (
|
|
126
|
+
quiet && typeof quiet === "object"
|
|
127
|
+
&& Number.isInteger(quiet.start) && quiet.start >= 0 && quiet.start <= 23
|
|
128
|
+
&& Number.isInteger(quiet.end) && quiet.end >= 0 && quiet.end <= 23
|
|
129
|
+
&& Number.isFinite(quiet.utcOffsetMinutes) && quiet.utcOffsetMinutes >= -720 && quiet.utcOffsetMinutes <= 840
|
|
130
|
+
);
|
|
131
|
+
return typeof config.enabled === "boolean"
|
|
132
|
+
&& Number.isFinite(config.minIntervalHours) && config.minIntervalHours >= 1 && config.minIntervalHours <= 720
|
|
133
|
+
&& Number.isFinite(config.entitySilenceDays) && config.entitySilenceDays >= 1 && config.entitySilenceDays <= 3650
|
|
134
|
+
&& Number.isInteger(config.heartbeatStaleMinutes) && config.heartbeatStaleMinutes >= 1 && config.heartbeatStaleMinutes <= 10080
|
|
135
|
+
&& Number.isInteger(config.maxItems) && config.maxItems >= 1 && config.maxItems <= 20
|
|
136
|
+
&& quietValid;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeRelativePath(value, field) {
|
|
140
|
+
if (value === null || value === undefined || value === "") return null;
|
|
141
|
+
if (typeof value !== "string") throw new Error(`${field} must be a project-relative path`);
|
|
142
|
+
const normalized = value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
143
|
+
if (normalized.startsWith("/") || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
144
|
+
throw new Error(`${field} must be a project-relative path`);
|
|
145
|
+
}
|
|
146
|
+
return normalized;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readAttentionFile(path, root) {
|
|
150
|
+
try {
|
|
151
|
+
const metadata = await stat(path);
|
|
152
|
+
if (metadata.size > MAX_STATE_BYTES) throw new Error("attention state exceeds the 5 MiB read limit");
|
|
153
|
+
return normalizeAttention(JSON.parse(await readFile(path, "utf8")), root);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error.code !== "ENOENT") throw error;
|
|
156
|
+
return emptyAttention(root);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function loadAttention(root = process.cwd(), providedCatalog = null) {
|
|
161
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
162
|
+
const directory = await projectStateDir(catalog.root);
|
|
163
|
+
const attentionPath = join(directory, "attention.json");
|
|
164
|
+
return { attention: await readAttentionFile(attentionPath, catalog.root), attentionPath, catalog };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function inspectAttention(root = process.cwd(), providedCatalog = null) {
|
|
168
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
169
|
+
const directory = await projectStateDir(catalog.root);
|
|
170
|
+
const attentionPath = join(directory, "attention.json");
|
|
171
|
+
try {
|
|
172
|
+
return { attention: await readAttentionFile(attentionPath, catalog.root), attentionPath, catalog, error: null };
|
|
173
|
+
} catch (error) {
|
|
174
|
+
return { attention: emptyAttention(catalog.root), attentionPath, catalog, error: error.message };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function attentionFindings(attention) {
|
|
179
|
+
const findings = [];
|
|
180
|
+
if (!attention || attention.schema !== "agentspine.attention/v2") findings.push("invalid-schema");
|
|
181
|
+
if (!validConfig(attention?.config || {})) findings.push("invalid-config");
|
|
182
|
+
const ids = new Set();
|
|
183
|
+
for (const event of attention?.events || []) {
|
|
184
|
+
if (!validEventRecord(event)) findings.push(`invalid-event:${event?.id || "unknown"}`);
|
|
185
|
+
if (ids.has(event.id)) findings.push(`duplicate-event:${event.id}`);
|
|
186
|
+
ids.add(event.id);
|
|
187
|
+
}
|
|
188
|
+
const receiptIds = new Set();
|
|
189
|
+
for (const receipt of attention?.receipts || []) {
|
|
190
|
+
if (!validReceipt(receipt)) findings.push(`invalid-receipt:${receipt?.id || "unknown"}`);
|
|
191
|
+
if (receiptIds.has(receipt.id)) findings.push(`duplicate-receipt:${receipt.id}`);
|
|
192
|
+
if (!ids.has(receipt.eventId)) findings.push(`orphan-receipt:${receipt.id}`);
|
|
193
|
+
receiptIds.add(receipt.id);
|
|
194
|
+
}
|
|
195
|
+
for (const event of attention?.events || []) {
|
|
196
|
+
const receipt = (attention.receipts || []).find((item) => item.id === event.provenance?.receiptId);
|
|
197
|
+
if (!receipt || receipt.eventId !== event.id || receipt.digest !== event.provenance.digest) {
|
|
198
|
+
findings.push(`event-provenance-receipt-mismatch:${event.id}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return findings;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function saveAttention(state, path) {
|
|
205
|
+
const content = `${JSON.stringify(state, null, 2)}\n`;
|
|
206
|
+
if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("attention state exceeds 5 MiB; resolve or delete old cues first");
|
|
207
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
208
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
209
|
+
await replaceFileWithRetry(temporary, path);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function withAttentionLock(path, task) {
|
|
213
|
+
const lockPath = `${path}.lock`;
|
|
214
|
+
let handle;
|
|
215
|
+
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
216
|
+
try {
|
|
217
|
+
handle = await open(lockPath, "wx", 0o600);
|
|
218
|
+
break;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (!isFileLockContention(error)) throw error;
|
|
221
|
+
try {
|
|
222
|
+
const metadata = await stat(lockPath);
|
|
223
|
+
if (Date.now() - metadata.mtimeMs > 15000) await unlink(lockPath);
|
|
224
|
+
} catch (lockError) {
|
|
225
|
+
if (lockError.code !== "ENOENT") throw lockError;
|
|
226
|
+
}
|
|
227
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!handle) throw new Error("attention state is busy; retry shortly");
|
|
231
|
+
try {
|
|
232
|
+
const state = await readAttentionFile(path, task.root);
|
|
233
|
+
const result = await task.run(state);
|
|
234
|
+
await saveAttention(state, path);
|
|
235
|
+
return result;
|
|
236
|
+
} finally {
|
|
237
|
+
await handle.close();
|
|
238
|
+
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function preservePrevious(state, kind, value, now) {
|
|
243
|
+
if (!value) return;
|
|
244
|
+
state.history.push({
|
|
245
|
+
kind,
|
|
246
|
+
recordId: value.id,
|
|
247
|
+
entityId: value.entityId || null,
|
|
248
|
+
supersededAt: now,
|
|
249
|
+
value: { ...value, authority: "context-only" },
|
|
250
|
+
privacy: value.privacy || "private",
|
|
251
|
+
authority: "context-only"
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function digest(value) {
|
|
256
|
+
return createHash("sha256").update(value).digest("hex");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function eventIdentity(kind, scope, summary) {
|
|
260
|
+
const canonical = [kind, scope.entityId || "", scope.groupId || "", scope.projectId || "", scope.taskId || "", summary.trim().toLowerCase()].join("\0");
|
|
261
|
+
return `event:${kind}:${digest(canonical).slice(0, 24)}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function validateKnownScope(graph, { entityId, groupId, projectId, taskId, privacy }) {
|
|
265
|
+
if (!entityId) throw new Error("attention events require an exact known actor identity");
|
|
266
|
+
if (!projectId || !taskId) throw new Error("attention events require exact project and task scope");
|
|
267
|
+
if (entityId && !graph.entities.some((entity) => entity.id === entityId && ["person", "agent"].includes(entity.kind))) {
|
|
268
|
+
throw new Error(`unknown person or agent entity: ${entityId}`);
|
|
269
|
+
}
|
|
270
|
+
if (!graph.entities.some((entity) => entity.id === projectId && entity.kind === "project")) {
|
|
271
|
+
throw new Error(`unknown project entity: ${projectId}`);
|
|
272
|
+
}
|
|
273
|
+
validateGroupScope(privacy, groupId, graph, entityId);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validateEventSummary(summary) {
|
|
277
|
+
if (!summary || typeof summary !== "string") throw new Error("event summary is required");
|
|
278
|
+
const compact = summary.trim().replace(/\s+/g, " ").slice(0, 280);
|
|
279
|
+
if (!compact) throw new Error("event summary is required");
|
|
280
|
+
if (UNSAFE_EVENT_RE.test(compact)) throw new Error("secret, identity, authority, or operational-rights content was rejected");
|
|
281
|
+
return compact;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Record or transition a minimal provider-neutral lifecycle event. */
|
|
285
|
+
export async function recordAttentionEvent({
|
|
286
|
+
root = process.cwd(), id = null, kind, summary, status = null,
|
|
287
|
+
entityId = null, groupId = null, projectId = null, taskId = null,
|
|
288
|
+
privacy = "private", dueAt = null, receiptId, host, hookEvent,
|
|
289
|
+
observedAt = new Date(), catalog: providedCatalog = null
|
|
290
|
+
}) {
|
|
291
|
+
if (!EVENT_KINDS.has(kind)) throw new Error(`unsupported attention event kind: ${kind}`);
|
|
292
|
+
if (!PRIVACY.has(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
293
|
+
if (!ID_RE.test(receiptId || "")) throw new Error("receiptId is required for idempotent lifecycle recording");
|
|
294
|
+
if (!new Set(["claude", "codex", "generic"]).has(host)) throw new Error("event host is invalid");
|
|
295
|
+
if (!HOOK_EVENTS.has(hookEvent)) throw new Error("event provenance must name a supported lifecycle hook");
|
|
296
|
+
for (const [field, value] of Object.entries({ entityId, groupId, projectId, taskId })) {
|
|
297
|
+
if (value !== null && !ID_RE.test(value)) throw new Error(`${field} is invalid`);
|
|
298
|
+
}
|
|
299
|
+
const at = normalizeDate(observedAt, "observedAt");
|
|
300
|
+
const normalizedDueAt = normalizeDate(dueAt, "dueAt", true);
|
|
301
|
+
const normalizedSummary = validateEventSummary(summary);
|
|
302
|
+
const nextStatus = status || (kind === "heartbeat" ? "active" : "open");
|
|
303
|
+
if (!EVENT_STATUSES[kind].has(nextStatus)) throw new Error(`unsupported ${kind} status: ${nextStatus}`);
|
|
304
|
+
const scope = { entityId, groupId, projectId, taskId };
|
|
305
|
+
const eventId = id || eventIdentity(kind, scope, normalizedSummary);
|
|
306
|
+
if (!ID_RE.test(eventId)) throw new Error("event id must be stable and whitespace-free");
|
|
307
|
+
const provenanceDigest = digest(JSON.stringify({ kind, summary: normalizedSummary, status: nextStatus, ...scope }));
|
|
308
|
+
return attentionMutation(root, async (state, catalog, attentionPath) => {
|
|
309
|
+
const duplicate = state.receipts.find((receipt) => receipt.id === receiptId);
|
|
310
|
+
if (duplicate) {
|
|
311
|
+
if (duplicate.eventId !== eventId || duplicate.kind !== kind || duplicate.digest !== provenanceDigest) {
|
|
312
|
+
throw new Error("attention receipt collision detected");
|
|
313
|
+
}
|
|
314
|
+
const event = state.events.find((item) => item.id === duplicate.eventId) || null;
|
|
315
|
+
return { event, duplicate: true, receipt: duplicate, attentionPath };
|
|
316
|
+
}
|
|
317
|
+
const { graph } = await loadGraph(catalog.root, catalog);
|
|
318
|
+
validateKnownScope(graph, { ...scope, privacy });
|
|
319
|
+
const { coordination } = await loadCoordination(catalog.root, catalog);
|
|
320
|
+
const task = coordination.tasks.find((item) => item.id === taskId);
|
|
321
|
+
if (!task || task.projectId !== projectId) throw new Error("attention event task must exist in the exact project scope");
|
|
322
|
+
if (![task.createdBy, task.assigneeId].includes(entityId)) {
|
|
323
|
+
throw new Error("attention event actor must be the task creator or assignee");
|
|
324
|
+
}
|
|
325
|
+
if (task.groupId !== groupId && (task.groupId !== null || groupId !== null)) {
|
|
326
|
+
throw new Error("attention event group scope must match the task");
|
|
327
|
+
}
|
|
328
|
+
if (stricterPrivacy(privacy, task.privacy) !== privacy) {
|
|
329
|
+
throw new Error("attention event privacy cannot be broader than the task");
|
|
330
|
+
}
|
|
331
|
+
const previous = state.events.find((item) => item.id === eventId);
|
|
332
|
+
if (previous && (previous.kind !== kind || previous.entityId !== entityId || previous.groupId !== groupId
|
|
333
|
+
|| previous.projectId !== projectId || previous.taskId !== taskId || previous.privacy !== privacy)) {
|
|
334
|
+
throw new Error("stable attention event identity cannot change kind, scope, or privacy");
|
|
335
|
+
}
|
|
336
|
+
preservePrevious(state, "attention-event", previous, at);
|
|
337
|
+
const provenance = {
|
|
338
|
+
source: "native-lifecycle-hook", host, hookEvent, receiptId,
|
|
339
|
+
observedAt: at,
|
|
340
|
+
digest: provenanceDigest
|
|
341
|
+
};
|
|
342
|
+
const event = {
|
|
343
|
+
id: eventId, kind, summary: normalizedSummary, status: nextStatus,
|
|
344
|
+
entityId, groupId, projectId, taskId, privacy,
|
|
345
|
+
dueAt: normalizedDueAt,
|
|
346
|
+
createdAt: previous?.createdAt || at,
|
|
347
|
+
updatedAt: at,
|
|
348
|
+
occurrenceCount: (previous?.occurrenceCount || 0) + 1,
|
|
349
|
+
provenance,
|
|
350
|
+
authority: "context-only"
|
|
351
|
+
};
|
|
352
|
+
state.events = state.events.filter((item) => item.id !== eventId);
|
|
353
|
+
state.events.push(event);
|
|
354
|
+
state.events.sort((left, right) => left.id.localeCompare(right.id));
|
|
355
|
+
const receipt = { id: receiptId, eventId, kind, digest: provenanceDigest, at, authority: "context-only" };
|
|
356
|
+
state.receipts.push(receipt);
|
|
357
|
+
state.receipts.sort((left, right) => left.at.localeCompare(right.at) || left.id.localeCompare(right.id));
|
|
358
|
+
return { event, duplicate: false, receipt, attentionPath };
|
|
359
|
+
}, providedCatalog);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isGroupMember(graph, groupId, entityId) {
|
|
363
|
+
if (!entityId || entityId === groupId) return true;
|
|
364
|
+
return graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private" && (
|
|
365
|
+
(edge.from === entityId && edge.to === groupId) || (edge.to === entityId && edge.from === groupId)
|
|
366
|
+
));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function validateGroupScope(privacy, groupId, graph, entityId = null) {
|
|
370
|
+
if (privacy === "group") {
|
|
371
|
+
if (!groupId) throw new Error("group privacy requires groupId");
|
|
372
|
+
const group = graph.entities.find((entity) => entity.id === groupId);
|
|
373
|
+
if (!group || group.kind !== "group") throw new Error(`unknown group entity: ${groupId}`);
|
|
374
|
+
if (!isGroupMember(graph, groupId, entityId)) throw new Error(`entity is not a visible member of group: ${groupId}`);
|
|
375
|
+
} else if (groupId !== null && groupId !== undefined) {
|
|
376
|
+
throw new Error("groupId is only valid with group privacy");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function attentionMutation(root, operation, providedCatalog = null) {
|
|
381
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
382
|
+
const { attentionPath } = await loadAttention(catalog.root, catalog);
|
|
383
|
+
return withAttentionLock(attentionPath, { root: catalog.root, run: (state) => operation(state, catalog, attentionPath) });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function upsertAttention({
|
|
387
|
+
root = process.cwd(), id = `signal:${randomUUID()}`, kind, summary, entityId = null,
|
|
388
|
+
dueAt = null, priority = 50, privacy = "private", groupId = null, sourceDocument = null,
|
|
389
|
+
confidence = 0.5, now = new Date()
|
|
390
|
+
}) {
|
|
391
|
+
if (!ID_RE.test(id)) throw new Error("id must be a stable, whitespace-free identifier");
|
|
392
|
+
if (!SIGNAL_KINDS.has(kind)) throw new Error(`unsupported attention kind: ${kind}`);
|
|
393
|
+
if (!summary || typeof summary !== "string") throw new Error("summary is required");
|
|
394
|
+
if (!PRIVACY.has(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
395
|
+
const timestamp = normalizeDate(now, "now");
|
|
396
|
+
const normalizedDueAt = normalizeDate(dueAt, "dueAt", true);
|
|
397
|
+
sourceDocument = normalizeRelativePath(sourceDocument, "sourceDocument");
|
|
398
|
+
return attentionMutation(root, async (state, catalog, attentionPath) => {
|
|
399
|
+
const { graph } = await loadGraph(catalog.root, catalog);
|
|
400
|
+
if (entityId !== null && !graph.entities.some((entity) => entity.id === entityId)) throw new Error(`unknown entity: ${entityId}`);
|
|
401
|
+
validateGroupScope(privacy, groupId, graph, entityId);
|
|
402
|
+
if (sourceDocument !== null && !catalog.documents.some((document) => document.relativePath === sourceDocument)) {
|
|
403
|
+
throw new Error(`unknown source document: ${sourceDocument}`);
|
|
404
|
+
}
|
|
405
|
+
const previous = state.signals.find((signal) => signal.id === id);
|
|
406
|
+
const signal = {
|
|
407
|
+
id,
|
|
408
|
+
kind,
|
|
409
|
+
summary: summary.slice(0, 500),
|
|
410
|
+
entityId,
|
|
411
|
+
dueAt: normalizedDueAt,
|
|
412
|
+
priority: normalizeNumber(priority, "priority", 0, 100),
|
|
413
|
+
privacy,
|
|
414
|
+
groupId,
|
|
415
|
+
sourceDocument,
|
|
416
|
+
confidence: normalizeNumber(confidence, "confidence", 0, 1),
|
|
417
|
+
status: "open",
|
|
418
|
+
createdAt: previous?.createdAt || timestamp,
|
|
419
|
+
updatedAt: timestamp,
|
|
420
|
+
authority: "context-only"
|
|
421
|
+
};
|
|
422
|
+
preservePrevious(state, "attention-signal", previous, timestamp);
|
|
423
|
+
state.signals = state.signals.filter((item) => item.id !== id);
|
|
424
|
+
state.signals.push(signal);
|
|
425
|
+
state.signals.sort((a, b) => a.id.localeCompare(b.id));
|
|
426
|
+
return { signal, attentionPath };
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function resolveAttention({ root = process.cwd(), id, status = "completed", now = new Date() }) {
|
|
431
|
+
if (!ID_RE.test(id || "")) throw new Error("id is required");
|
|
432
|
+
if (!STATUSES.has(status)) throw new Error(`unsupported attention status: ${status}`);
|
|
433
|
+
const timestamp = normalizeDate(now, "now");
|
|
434
|
+
return attentionMutation(root, (state, _catalog, attentionPath) => {
|
|
435
|
+
const previous = state.signals.find((signal) => signal.id === id);
|
|
436
|
+
if (!previous) throw new Error(`unknown attention signal: ${id}`);
|
|
437
|
+
preservePrevious(state, "attention-signal", previous, timestamp);
|
|
438
|
+
const signal = { ...previous, status, updatedAt: timestamp, authority: "context-only" };
|
|
439
|
+
state.signals = state.signals.map((item) => item.id === id ? signal : item);
|
|
440
|
+
return { signal, attentionPath };
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function recordActivity({
|
|
445
|
+
root = process.cwd(), entityId, kind = "interaction", at = new Date(), privacy = "private", groupId = null
|
|
446
|
+
}) {
|
|
447
|
+
if (!ID_RE.test(entityId || "")) throw new Error("entityId is required");
|
|
448
|
+
if (!ACTIVITY_KINDS.has(kind)) throw new Error(`unsupported activity kind: ${kind}`);
|
|
449
|
+
if (!PRIVACY.has(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
450
|
+
const timestamp = normalizeDate(at, "at");
|
|
451
|
+
return attentionMutation(root, async (state, catalog, attentionPath) => {
|
|
452
|
+
const { graph } = await loadGraph(catalog.root, catalog);
|
|
453
|
+
if (!graph.entities.some((entity) => entity.id === entityId)) throw new Error(`unknown entity: ${entityId}`);
|
|
454
|
+
validateGroupScope(privacy, groupId, graph, entityId);
|
|
455
|
+
const activity = {
|
|
456
|
+
id: `activity:${randomUUID()}`,
|
|
457
|
+
entityId,
|
|
458
|
+
kind,
|
|
459
|
+
at: timestamp,
|
|
460
|
+
privacy,
|
|
461
|
+
groupId,
|
|
462
|
+
authority: "context-only"
|
|
463
|
+
};
|
|
464
|
+
state.activities.push(activity);
|
|
465
|
+
state.activities.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
|
|
466
|
+
return { activity, attentionPath };
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function normalizeQuietHours(value) {
|
|
471
|
+
if (value === null || value === false) return null;
|
|
472
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("quietHours must be an object or null");
|
|
473
|
+
return {
|
|
474
|
+
start: normalizeInteger(value.start, "quietHours.start", 0, 23),
|
|
475
|
+
end: normalizeInteger(value.end, "quietHours.end", 0, 23),
|
|
476
|
+
utcOffsetMinutes: normalizeInteger(value.utcOffsetMinutes ?? 0, "quietHours.utcOffsetMinutes", -720, 840)
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export async function configureAttention({ root = process.cwd(), config = {}, now = new Date() }) {
|
|
481
|
+
if (!config || Array.isArray(config) || typeof config !== "object") throw new Error("config must be an object");
|
|
482
|
+
if (!Object.keys(config).length) throw new Error("config must change at least one attention setting");
|
|
483
|
+
const allowed = new Set(["enabled", "minIntervalHours", "entitySilenceDays", "heartbeatStaleMinutes", "maxItems", "quietHours"]);
|
|
484
|
+
const unknown = Object.keys(config).filter((key) => !allowed.has(key));
|
|
485
|
+
if (unknown.length) throw new Error(`unsupported attention config: ${unknown.join(", ")}`);
|
|
486
|
+
const timestamp = normalizeDate(now, "now");
|
|
487
|
+
return attentionMutation(root, (state, _catalog, attentionPath) => {
|
|
488
|
+
preservePrevious(state, "attention-config", { id: "config", ...state.config, privacy: "private" }, timestamp);
|
|
489
|
+
if ("enabled" in config) {
|
|
490
|
+
if (typeof config.enabled !== "boolean") throw new Error("enabled must be boolean");
|
|
491
|
+
state.config.enabled = config.enabled;
|
|
492
|
+
}
|
|
493
|
+
if ("minIntervalHours" in config) state.config.minIntervalHours = normalizeNumber(config.minIntervalHours, "minIntervalHours", 1, 720);
|
|
494
|
+
if ("entitySilenceDays" in config) state.config.entitySilenceDays = normalizeNumber(config.entitySilenceDays, "entitySilenceDays", 1, 3650);
|
|
495
|
+
if ("heartbeatStaleMinutes" in config) state.config.heartbeatStaleMinutes = normalizeInteger(config.heartbeatStaleMinutes, "heartbeatStaleMinutes", 1, 10080);
|
|
496
|
+
if ("maxItems" in config) state.config.maxItems = normalizeInteger(config.maxItems, "maxItems", 1, 20);
|
|
497
|
+
if ("quietHours" in config) state.config.quietHours = normalizeQuietHours(config.quietHours);
|
|
498
|
+
if (!validConfig(state.config)) throw new Error("resulting attention configuration is invalid");
|
|
499
|
+
return { config: state.config, attentionPath };
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function isQuiet(now, quietHours) {
|
|
504
|
+
if (!quietHours) return false;
|
|
505
|
+
const shifted = new Date(now.getTime() + quietHours.utcOffsetMinutes * 60000);
|
|
506
|
+
const hour = shifted.getUTCHours();
|
|
507
|
+
if (quietHours.start === quietHours.end) return true;
|
|
508
|
+
if (quietHours.start < quietHours.end) return hour >= quietHours.start && hour < quietHours.end;
|
|
509
|
+
return hour >= quietHours.start || hour < quietHours.end;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function recentlyPresented(state, key, now) {
|
|
513
|
+
const value = state.presentations[key];
|
|
514
|
+
if (!value) return false;
|
|
515
|
+
return now.getTime() - new Date(value).getTime() < state.config.minIntervalHours * 3600000;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function groupEntityIds(graph, groupId, includePrivate) {
|
|
519
|
+
const ids = new Set();
|
|
520
|
+
if (!groupId) return ids;
|
|
521
|
+
ids.add(groupId);
|
|
522
|
+
for (const edge of graph.entityEdges) {
|
|
523
|
+
if (edge.relation !== "member-of" || (!includePrivate && edge.privacy === "private")) continue;
|
|
524
|
+
if (edge.to === groupId) ids.add(edge.from);
|
|
525
|
+
if (edge.from === groupId) ids.add(edge.to);
|
|
526
|
+
}
|
|
527
|
+
return ids;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function entityVisible(entity, includePrivate, groupEntities) {
|
|
531
|
+
if (!entity) return true;
|
|
532
|
+
if (entity.privacy === "group") return groupEntities.has(entity.id);
|
|
533
|
+
if (entity.privacy === "private") return includePrivate;
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function visible(record, entities, includePrivate, groupId, groupEntities) {
|
|
538
|
+
if (!includePrivate && record.privacy === "private") return false;
|
|
539
|
+
if (record.privacy === "group" && (!groupId || record.groupId !== groupId)) return false;
|
|
540
|
+
if (record.privacy === "group" && record.entityId && !groupEntities.has(record.entityId)) return false;
|
|
541
|
+
if (record.entityId) {
|
|
542
|
+
const entity = entities.get(record.entityId);
|
|
543
|
+
if (!entityVisible(entity, includePrivate, groupEntities)) return false;
|
|
544
|
+
}
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function stricterPrivacy(left, right) {
|
|
549
|
+
const rank = { shared: 0, group: 1, private: 2 };
|
|
550
|
+
return rank[left] >= rank[right] ? left : right;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function looserPrivacy(left, right) {
|
|
554
|
+
const rank = { shared: 0, group: 1, private: 2 };
|
|
555
|
+
return rank[left] <= rank[right] ? left : right;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export async function attentionContext({
|
|
559
|
+
root = process.cwd(), includePrivate = false, focusActive = false,
|
|
560
|
+
markPresented = false, maxItems = null, entityId = null, groupId = null,
|
|
561
|
+
projectId = null, currentTaskId = null, now = new Date(), catalog: providedCatalog = null
|
|
562
|
+
} = {}) {
|
|
563
|
+
const timestamp = normalizeDate(now, "now");
|
|
564
|
+
const current = new Date(timestamp);
|
|
565
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
566
|
+
const { attention, attentionPath } = await loadAttention(catalog.root, catalog);
|
|
567
|
+
if (!validConfig(attention.config)) throw new Error("attention configuration is invalid; inspect or reset the external attention state");
|
|
568
|
+
const { graph } = await loadGraph(catalog.root, catalog);
|
|
569
|
+
const entities = new Map(graph.entities.map((entity) => [entity.id, entity]));
|
|
570
|
+
if (groupId !== null) {
|
|
571
|
+
const group = entities.get(groupId);
|
|
572
|
+
if (!group || group.kind !== "group") throw new Error(`unknown group entity: ${groupId}`);
|
|
573
|
+
}
|
|
574
|
+
const groupEntities = groupEntityIds(graph, groupId, includePrivate);
|
|
575
|
+
let suppressed = null;
|
|
576
|
+
if (!attention.config.enabled) suppressed = "disabled";
|
|
577
|
+
else if (isQuiet(current, attention.config.quietHours)) suppressed = "quiet-hours";
|
|
578
|
+
else if (focusActive) suppressed = "focus-active";
|
|
579
|
+
|
|
580
|
+
const candidates = [];
|
|
581
|
+
if (!suppressed) {
|
|
582
|
+
for (const signal of attention.signals) {
|
|
583
|
+
if (signal.status !== "open" || !visible(signal, entities, includePrivate, groupId, groupEntities)) continue;
|
|
584
|
+
if (!SIGNAL_KINDS.has(signal.kind) || !Number.isFinite(signal.priority) || !Number.isFinite(signal.confidence)) continue;
|
|
585
|
+
if (signal.dueAt && new Date(signal.dueAt) > current) continue;
|
|
586
|
+
if (signal.dueAt && !Number.isFinite(new Date(signal.dueAt).getTime())) continue;
|
|
587
|
+
const key = `cue:${signal.id}`;
|
|
588
|
+
if (recentlyPresented(attention, key, current)) continue;
|
|
589
|
+
const weights = { "unanswered-question": 30, promise: 25, "meaningful-change": 15, "check-in": 5 };
|
|
590
|
+
candidates.push({
|
|
591
|
+
key,
|
|
592
|
+
source: "signal",
|
|
593
|
+
score: signal.priority + weights[signal.kind],
|
|
594
|
+
kind: signal.kind,
|
|
595
|
+
summary: signal.summary,
|
|
596
|
+
entityId: signal.entityId,
|
|
597
|
+
dueAt: signal.dueAt,
|
|
598
|
+
privacy: signal.privacy,
|
|
599
|
+
groupId: signal.groupId || null,
|
|
600
|
+
confidence: signal.confidence,
|
|
601
|
+
authority: "context-only"
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const teamEdges = graph.entityEdges
|
|
606
|
+
.filter((edge) => TEAM_RELATIONS.has(edge.relation))
|
|
607
|
+
.filter((edge) => includePrivate || edge.privacy !== "private")
|
|
608
|
+
.filter((edge) => edge.privacy !== "group" || (
|
|
609
|
+
groupId && [edge.from, edge.to].every((id) => groupEntities.has(id))
|
|
610
|
+
))
|
|
611
|
+
.filter((edge) => [edge.from, edge.to].every((id) => {
|
|
612
|
+
return entityVisible(entities.get(id), includePrivate, groupEntities);
|
|
613
|
+
}));
|
|
614
|
+
const teamIds = new Set(teamEdges.flatMap((edge) => [edge.from, edge.to]));
|
|
615
|
+
for (const entity of graph.entities) {
|
|
616
|
+
if (!teamIds.has(entity.id) || !["person", "agent"].includes(entity.kind)) continue;
|
|
617
|
+
if (!visible({ entityId: entity.id, privacy: entity.privacy, groupId }, entities, includePrivate, groupId, groupEntities)) continue;
|
|
618
|
+
const latestActivity = attention.activities
|
|
619
|
+
.filter((activity) => activity.entityId === entity.id && visible(activity, entities, includePrivate, groupId, groupEntities))
|
|
620
|
+
.sort((a, b) => b.at.localeCompare(a.at))[0];
|
|
621
|
+
const baseline = new Date(latestActivity?.at || entity.updatedAt);
|
|
622
|
+
const silenceDays = (current.getTime() - baseline.getTime()) / 86400000;
|
|
623
|
+
if (silenceDays < attention.config.entitySilenceDays) continue;
|
|
624
|
+
const key = `neglected:${entity.id}`;
|
|
625
|
+
if (recentlyPresented(attention, key, current)) continue;
|
|
626
|
+
const edgePrivacy = teamEdges
|
|
627
|
+
.filter((edge) => edge.from === entity.id || edge.to === entity.id)
|
|
628
|
+
.map((edge) => [edge.from, edge.to].reduce(
|
|
629
|
+
(scope, id) => stricterPrivacy(scope, entities.get(id)?.privacy || "private"),
|
|
630
|
+
edge.privacy
|
|
631
|
+
))
|
|
632
|
+
.reduce((scope, privacy) => scope === null ? privacy : looserPrivacy(scope, privacy), null);
|
|
633
|
+
const privacy = stricterPrivacy(entity.privacy, edgePrivacy || "private");
|
|
634
|
+
candidates.push({
|
|
635
|
+
key,
|
|
636
|
+
source: "relationship",
|
|
637
|
+
score: 40 + Math.min(30, Math.floor(silenceDays - attention.config.entitySilenceDays)),
|
|
638
|
+
kind: "check-in",
|
|
639
|
+
summary: `No recorded interaction with ${entity.displayName || entity.id} for ${Math.floor(silenceDays)} days.`,
|
|
640
|
+
entityId: entity.id,
|
|
641
|
+
dueAt: null,
|
|
642
|
+
privacy,
|
|
643
|
+
groupId: privacy === "group" ? groupId : null,
|
|
644
|
+
confidence: entity.confidence,
|
|
645
|
+
authority: "context-only"
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Focus suppresses unrelated reminders but not a blocker or due promise for
|
|
651
|
+
// the exact task already in focus. Quiet hours and the global switch still
|
|
652
|
+
// suppress every presentation while retaining durable state.
|
|
653
|
+
if (attention.config.enabled && suppressed !== "quiet-hours") {
|
|
654
|
+
for (const event of attention.events) {
|
|
655
|
+
if (!EVENT_KINDS.has(event.kind) || !EVENT_STATUSES[event.kind]?.has(event.status)) continue;
|
|
656
|
+
if (!visible(event, entities, includePrivate, groupId, groupEntities)) continue;
|
|
657
|
+
if (event.entityId !== null && event.entityId !== entityId) continue;
|
|
658
|
+
if (event.projectId !== projectId || event.taskId !== currentTaskId) continue;
|
|
659
|
+
if (event.kind === "heartbeat" && event.status !== "active") continue;
|
|
660
|
+
if (event.kind === "promise" && event.status !== "open") continue;
|
|
661
|
+
if (event.kind === "blocker" && event.status !== "open") continue;
|
|
662
|
+
if (event.dueAt && new Date(event.dueAt) > current) continue;
|
|
663
|
+
if (event.dueAt && !Number.isFinite(new Date(event.dueAt).getTime())) continue;
|
|
664
|
+
if (event.kind === "heartbeat" && !event.dueAt) {
|
|
665
|
+
const staleAt = new Date(event.updatedAt).getTime() + attention.config.heartbeatStaleMinutes * 60000;
|
|
666
|
+
if (staleAt > current.getTime()) continue;
|
|
667
|
+
}
|
|
668
|
+
const key = `event:${event.id}`;
|
|
669
|
+
if (recentlyPresented(attention, key, current)) continue;
|
|
670
|
+
const weights = { blocker: 200, promise: 150, heartbeat: 80 };
|
|
671
|
+
candidates.push({
|
|
672
|
+
key, source: "lifecycle-event", score: weights[event.kind],
|
|
673
|
+
kind: event.kind, summary: event.summary, status: event.status,
|
|
674
|
+
entityId: event.entityId, projectId: event.projectId, taskId: event.taskId,
|
|
675
|
+
dueAt: event.dueAt, privacy: event.privacy, groupId: event.groupId,
|
|
676
|
+
occurrenceCount: event.occurrenceCount,
|
|
677
|
+
provenance: event.provenance,
|
|
678
|
+
authority: "context-only"
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (focusActive && candidates.some((item) => item.source === "lifecycle-event")) {
|
|
683
|
+
suppressed = "focus-active-except-current-task";
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const limit = maxItems === null
|
|
687
|
+
? attention.config.maxItems
|
|
688
|
+
: normalizeInteger(maxItems, "maxItems", 0, 20);
|
|
689
|
+
const items = candidates
|
|
690
|
+
.sort((a, b) => b.score - a.score || (a.dueAt || "").localeCompare(b.dueAt || "") || a.key.localeCompare(b.key))
|
|
691
|
+
.slice(0, limit);
|
|
692
|
+
|
|
693
|
+
if (markPresented && items.length) {
|
|
694
|
+
await withAttentionLock(attentionPath, {
|
|
695
|
+
root: catalog.root,
|
|
696
|
+
run: (state) => {
|
|
697
|
+
for (const item of items) state.presentations[item.key] = timestamp;
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return {
|
|
704
|
+
schema: "agentspine.attention-context/v1",
|
|
705
|
+
root: catalog.root,
|
|
706
|
+
enabled: attention.config.enabled,
|
|
707
|
+
suppressed,
|
|
708
|
+
now: timestamp,
|
|
709
|
+
entityId,
|
|
710
|
+
groupId,
|
|
711
|
+
projectId,
|
|
712
|
+
currentTaskId,
|
|
713
|
+
items,
|
|
714
|
+
remaining: Math.max(0, candidates.length - items.length),
|
|
715
|
+
authority: "context-only",
|
|
716
|
+
note: "Attention cues are suggestions, never instructions. The current task and host permissions remain authoritative."
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export async function deleteAttention({ root = process.cwd(), signalId = null, eventId = null, entityId = null }) {
|
|
721
|
+
if ([signalId, eventId, entityId].filter(Boolean).length !== 1) throw new Error("provide exactly one of signalId, eventId, or entityId");
|
|
722
|
+
return attentionMutation(root, (state, _catalog, attentionPath) => {
|
|
723
|
+
if (signalId) {
|
|
724
|
+
const existed = state.signals.some((signal) => signal.id === signalId);
|
|
725
|
+
state.signals = state.signals.filter((signal) => signal.id !== signalId);
|
|
726
|
+
state.history = state.history.filter((entry) => entry.recordId !== signalId && entry.value?.id !== signalId);
|
|
727
|
+
delete state.presentations[`cue:${signalId}`];
|
|
728
|
+
return { deleted: existed, signalId, attentionPath };
|
|
729
|
+
}
|
|
730
|
+
if (eventId) {
|
|
731
|
+
const existed = state.events.some((event) => event.id === eventId);
|
|
732
|
+
state.events = state.events.filter((event) => event.id !== eventId);
|
|
733
|
+
const receiptIds = new Set(state.receipts.filter((receipt) => receipt.eventId === eventId).map((receipt) => receipt.id));
|
|
734
|
+
state.receipts = state.receipts.filter((receipt) => receipt.eventId !== eventId);
|
|
735
|
+
state.history = state.history.filter((entry) => entry.recordId !== eventId && entry.value?.id !== eventId);
|
|
736
|
+
delete state.presentations[`event:${eventId}`];
|
|
737
|
+
return { deleted: existed, eventId, deletedReceipts: receiptIds.size, attentionPath };
|
|
738
|
+
}
|
|
739
|
+
const signalIds = new Set(state.signals.filter((signal) => signal.entityId === entityId).map((signal) => signal.id));
|
|
740
|
+
const deletedSignals = signalIds.size;
|
|
741
|
+
const deletedActivities = state.activities.filter((activity) => activity.entityId === entityId).length;
|
|
742
|
+
const eventIds = new Set(state.events.filter((event) => event.entityId === entityId).map((event) => event.id));
|
|
743
|
+
const deletedEvents = eventIds.size;
|
|
744
|
+
state.signals = state.signals.filter((signal) => signal.entityId !== entityId);
|
|
745
|
+
state.activities = state.activities.filter((activity) => activity.entityId !== entityId);
|
|
746
|
+
state.events = state.events.filter((event) => event.entityId !== entityId);
|
|
747
|
+
state.receipts = state.receipts.filter((receipt) => !eventIds.has(receipt.eventId));
|
|
748
|
+
state.history = state.history.filter((entry) => entry.entityId !== entityId && entry.value?.entityId !== entityId && !signalIds.has(entry.recordId) && !eventIds.has(entry.recordId));
|
|
749
|
+
for (const key of Object.keys(state.presentations)) {
|
|
750
|
+
if (key === `neglected:${entityId}` || [...signalIds].some((id) => key === `cue:${id}`)) delete state.presentations[key];
|
|
751
|
+
if ([...eventIds].some((id) => key === `event:${id}`)) delete state.presentations[key];
|
|
752
|
+
}
|
|
753
|
+
return { deletedSignals, deletedActivities, deletedEvents, entityId, attentionPath };
|
|
754
|
+
});
|
|
755
|
+
}
|