blun-king-cli 9.1.308 → 9.1.309

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.
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+
5
+ const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
6
+ const SUBJECT_KINDS = new Set(['human', 'agent', 'task']);
7
+ const SIGNAL_KINDS = new Set(['heartbeat', 'commitment', 'task_blocked']);
8
+ const HEARTBEAT_STALE_MS = 5 * 60 * 1000;
9
+ const BLOCKED_TASK_STALE_MS = 10 * 60 * 1000;
10
+ const MAX_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
11
+
12
+ function fail() {
13
+ const error = new Error('ATTENTION_INVALID_INPUT');
14
+ error.code = 'ATTENTION_INVALID_INPUT';
15
+ throw error;
16
+ }
17
+
18
+ function exactKeys(value, keys) {
19
+ return value && typeof value === 'object' && !Array.isArray(value)
20
+ && Object.keys(value).every((key) => keys.has(key));
21
+ }
22
+
23
+ function safeId(value) {
24
+ const text = String(value ?? '').trim();
25
+ return SAFE_ID_RE.test(text) ? text : '';
26
+ }
27
+
28
+ function instant(value) {
29
+ if (value === null || value === undefined) return null;
30
+ const milliseconds = Date.parse(String(value));
31
+ return Number.isFinite(milliseconds) ? milliseconds : null;
32
+ }
33
+
34
+ function normalizeQuietHours(value) {
35
+ if (!exactKeys(value, new Set(['startHour', 'endHour']))) fail();
36
+ const startHour = Number(value.startHour);
37
+ const endHour = Number(value.endHour);
38
+ if (!Number.isInteger(startHour) || startHour < 0 || startHour > 23
39
+ || !Number.isInteger(endHour) || endHour < 0 || endHour > 23
40
+ || startHour === endHour) fail();
41
+ return { startHour, endHour };
42
+ }
43
+
44
+ function isQuietTime(nowMs, offsetMinutes, quietHours) {
45
+ const localHour = new Date(nowMs + offsetMinutes * 60 * 1000).getUTCHours();
46
+ return quietHours.startHour < quietHours.endHour
47
+ ? localHour >= quietHours.startHour && localHour < quietHours.endHour
48
+ : localHour >= quietHours.startHour || localHour < quietHours.endHour;
49
+ }
50
+
51
+ function signalReason(subjectKind, signal, nowMs) {
52
+ if (!exactKeys(signal, new Set([
53
+ 'kind', 'heartbeatAt', 'activeTask', 'expectedBy', 'blockedSince',
54
+ 'evidenceId', 'evidenceScope', 'confidence',
55
+ ]))) fail();
56
+ if (!SIGNAL_KINDS.has(signal.kind)) fail();
57
+ const evidenceId = safeId(signal.evidenceId);
58
+ const evidenceScope = safeId(signal.evidenceScope);
59
+ const confidence = Number(signal.confidence);
60
+ if (!evidenceId || !evidenceScope || !Number.isFinite(confidence)
61
+ || confidence < 0 || confidence > 1) fail();
62
+ if (confidence < 0.5) return null;
63
+
64
+ if (signal.kind === 'heartbeat') {
65
+ if (!exactKeys(signal, new Set(['kind', 'heartbeatAt', 'activeTask', 'evidenceId', 'evidenceScope', 'confidence']))
66
+ || subjectKind !== 'agent' || signal.activeTask !== true) return null;
67
+ const heartbeatAt = instant(signal.heartbeatAt);
68
+ if (heartbeatAt === null || heartbeatAt > nowMs) fail();
69
+ return nowMs - heartbeatAt >= HEARTBEAT_STALE_MS
70
+ ? { reason: 'missing_heartbeat', anchor: new Date(heartbeatAt).toISOString(), evidenceId, evidenceScope, confidence }
71
+ : null;
72
+ }
73
+ if (signal.kind === 'commitment') {
74
+ if (!exactKeys(signal, new Set(['kind', 'expectedBy', 'evidenceId', 'evidenceScope', 'confidence']))) fail();
75
+ const expectedBy = instant(signal.expectedBy);
76
+ if (expectedBy === null) fail();
77
+ return nowMs > expectedBy
78
+ ? { reason: 'overdue_commitment', anchor: new Date(expectedBy).toISOString(), evidenceId, evidenceScope, confidence }
79
+ : null;
80
+ }
81
+ if (!exactKeys(signal, new Set(['kind', 'blockedSince', 'evidenceId', 'evidenceScope', 'confidence'])) || subjectKind !== 'task') return null;
82
+ const blockedSince = instant(signal.blockedSince);
83
+ if (blockedSince === null || blockedSince > nowMs) fail();
84
+ return nowMs - blockedSince >= BLOCKED_TASK_STALE_MS
85
+ ? { reason: 'blocked_task', anchor: new Date(blockedSince).toISOString(), evidenceId, evidenceScope, confidence }
86
+ : null;
87
+ }
88
+
89
+ function buildAttentionCandidate(input) {
90
+ const allowed = new Set([
91
+ 'tenantId', 'subjectKind', 'subjectId', 'responsibleAgent', 'currentAgent', 'now', 'signal',
92
+ 'absenceUntil', 'timezoneOffsetMinutes', 'quietHours', 'cooldownMs', 'allowedChannel',
93
+ ]);
94
+ if (!exactKeys(input, allowed)) fail();
95
+ const tenantId = safeId(input.tenantId);
96
+ const subjectKind = String(input.subjectKind ?? '');
97
+ const subjectId = safeId(input.subjectId);
98
+ const responsibleAgent = safeId(input.responsibleAgent);
99
+ const currentAgent = safeId(input.currentAgent);
100
+ const allowedChannel = safeId(input.allowedChannel);
101
+ const nowMs = instant(input.now);
102
+ const offsetMinutes = Number(input.timezoneOffsetMinutes);
103
+ const cooldownMs = Number(input.cooldownMs);
104
+ if (!tenantId || !SUBJECT_KINDS.has(subjectKind) || !subjectId || !responsibleAgent
105
+ || !currentAgent || !allowedChannel || nowMs === null
106
+ || !Number.isInteger(offsetMinutes) || offsetMinutes < -840 || offsetMinutes > 840
107
+ || !Number.isSafeInteger(cooldownMs) || cooldownMs < 1000 || cooldownMs > MAX_COOLDOWN_MS) fail();
108
+ if (currentAgent !== responsibleAgent) return null;
109
+
110
+ const absenceUntil = instant(input.absenceUntil);
111
+ if (input.absenceUntil !== null && absenceUntil === null) fail();
112
+ if (absenceUntil !== null && absenceUntil > nowMs) return null;
113
+ const quietHours = normalizeQuietHours(input.quietHours);
114
+ if (isQuietTime(nowMs, offsetMinutes, quietHours)) return null;
115
+
116
+ const reason = signalReason(subjectKind, input.signal, nowMs);
117
+ if (reason === null) return null;
118
+ const digest = crypto.createHash('sha256')
119
+ .update([tenantId, subjectKind, subjectId, reason.reason, reason.evidenceId].join('\0'))
120
+ .digest('hex')
121
+ .slice(0, 40);
122
+ return {
123
+ candidate_id: `attention-${digest}`,
124
+ subject_kind: subjectKind,
125
+ subject_id: subjectId,
126
+ reason: reason.reason,
127
+ evidence_id: reason.evidenceId,
128
+ evidence_scope: reason.evidenceScope,
129
+ evidence_at: reason.anchor,
130
+ confidence: reason.confidence,
131
+ responsible_agent: responsibleAgent,
132
+ allowed_channel: allowedChannel,
133
+ detected_at: new Date(nowMs).toISOString(),
134
+ cooldown_ms: cooldownMs,
135
+ requires_runtime_authorization: true,
136
+ };
137
+ }
138
+
139
+ module.exports = {
140
+ BLOCKED_TASK_STALE_MS,
141
+ HEARTBEAT_STALE_MS,
142
+ buildAttentionCandidate,
143
+ };
@@ -147,6 +147,14 @@ function initialize(db) {
147
147
  );
148
148
  CREATE INDEX IF NOT EXISTS cognitive_observations_stream
149
149
  ON cognitive_observations (tenant_id, agent_id, occurred_at, observation_id);
150
+ CREATE TABLE IF NOT EXISTS cognitive_attention_claims (
151
+ tenant_id TEXT NOT NULL,
152
+ candidate_id TEXT NOT NULL,
153
+ claimed_by TEXT NOT NULL,
154
+ claimed_at TEXT NOT NULL,
155
+ next_allowed_at TEXT NOT NULL,
156
+ PRIMARY KEY (tenant_id, candidate_id)
157
+ );
150
158
  `);
151
159
  }
152
160
 
@@ -235,10 +243,57 @@ function openCognitiveStateStore({ home } = {}) {
235
243
  return { valid: true, events: rows.length, head_hash: previousHash };
236
244
  }
237
245
 
246
+ function claimAttention(input) {
247
+ const allowedInput = new Set(['tenantId', 'candidate', 'claimedBy', 'claimedAt']);
248
+ const allowedCandidate = new Set([
249
+ 'candidate_id', 'subject_kind', 'subject_id', 'reason', 'responsible_agent',
250
+ 'evidence_id', 'evidence_scope', 'evidence_at', 'confidence', 'allowed_channel',
251
+ 'detected_at', 'cooldown_ms', 'requires_runtime_authorization',
252
+ ]);
253
+ if (!exactKeys(input, allowedInput) || !exactKeys(input.candidate, allowedCandidate)
254
+ || hasForbiddenKey(input)) fail('COGNITIVE_FORBIDDEN_FIELD');
255
+ const tenant = safeId(input.tenantId);
256
+ const candidateId = safeId(input.candidate.candidate_id);
257
+ const claimedBy = safeId(input.claimedBy);
258
+ const claimedAtMs = Date.parse(String(input.claimedAt ?? ''));
259
+ const cooldownMs = Number(input.candidate.cooldown_ms);
260
+ if (!tenant || !candidateId || !claimedBy || !Number.isFinite(claimedAtMs)
261
+ || !Number.isSafeInteger(cooldownMs) || cooldownMs < 1000
262
+ || input.candidate.requires_runtime_authorization !== true) fail('COGNITIVE_INVALID_EVENT');
263
+ const claimedAt = new Date(claimedAtMs).toISOString();
264
+ const nextAllowedAt = new Date(claimedAtMs + cooldownMs).toISOString();
265
+ db.exec('BEGIN IMMEDIATE');
266
+ try {
267
+ const existing = db.prepare(`SELECT claimed_by, next_allowed_at FROM cognitive_attention_claims
268
+ WHERE tenant_id = ? AND candidate_id = ?`).get(tenant, candidateId);
269
+ if (existing && Date.parse(existing.next_allowed_at) > claimedAtMs) {
270
+ db.exec('COMMIT');
271
+ return {
272
+ claimed: false,
273
+ claimed_by: existing.claimed_by,
274
+ next_allowed_at: existing.next_allowed_at,
275
+ };
276
+ }
277
+ db.prepare(`INSERT INTO cognitive_attention_claims
278
+ (tenant_id, candidate_id, claimed_by, claimed_at, next_allowed_at) VALUES (?, ?, ?, ?, ?)
279
+ ON CONFLICT(tenant_id, candidate_id) DO UPDATE SET
280
+ claimed_by = excluded.claimed_by,
281
+ claimed_at = excluded.claimed_at,
282
+ next_allowed_at = excluded.next_allowed_at`)
283
+ .run(tenant, candidateId, claimedBy, claimedAt, nextAllowedAt);
284
+ db.exec('COMMIT');
285
+ return { claimed: true, claimed_by: claimedBy, next_allowed_at: nextAllowedAt };
286
+ } catch (error) {
287
+ try { db.exec('ROLLBACK'); } catch {}
288
+ throw error;
289
+ }
290
+ }
291
+
238
292
  return {
239
293
  commit,
240
294
  read,
241
295
  verify,
296
+ claimAttention,
242
297
  close: () => {
243
298
  try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch {}
244
299
  db.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blun-king-cli",
3
- "version": "9.1.308",
3
+ "version": "9.1.309",
4
4
  "description": "BLUN CLI - your own AI agent with a Telegram channel. Get it done. With BLUN.",
5
5
  "license": "MIT",
6
6
  "bin": {