pactive 0.0.2-alpha.1 → 0.0.4-alpha.1
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/.opcteam-plugin/plugin.json +8 -4
- package/BREAKING-CHANGES.md +14 -3
- package/README.md +86 -6
- package/bin/pactive.mjs +917 -4
- package/commands/pactive.md +4 -1
- package/dsh/index.js +214 -6
- package/mcp/tools/definitions.mjs +205 -8
- package/package.json +1 -1
- package/pi/extension.ts +246 -8
- package/pi/skills/pactive/SKILL.md +57 -20
- package/skills/pactive/SKILL.md +57 -20
- package/src/core/assistants.mjs +237 -0
- package/src/core/config.mjs +40 -3
- package/src/core/fleet.mjs +80 -0
- package/src/core/guard.mjs +117 -32
- package/src/core/idle.mjs +11 -8
- package/src/core/index.mjs +3 -0
- package/src/core/scheduler.mjs +16 -4
- package/src/core/state.mjs +134 -12
- package/src/core/tick.mjs +128 -0
- package/src/hosts/herdr/fleet-view.mjs +31 -0
- package/src/hosts/herdr/index.mjs +429 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { pactiveError } from './errors.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Assistants: the fleet of logical proactive helpers that share one state root.
|
|
7
|
+
*
|
|
8
|
+
* Assistant ids are `<name>#<ownerKey short hash>`: stable and readable, and a rename
|
|
9
|
+
* keeps the id (contract §2 / I61 / I65). The `default` assistant is the implicit
|
|
10
|
+
* single assistant of a schema-v1 state; it is created lazily so a fresh install
|
|
11
|
+
* lists no assistants until somebody names one.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_ASSISTANT_NAME = 'default';
|
|
15
|
+
|
|
16
|
+
const POLICY_KEYS = ['hourlyLimit', 'minIntervalMs', 'cooldownMs', 'quietStart', 'quietEnd'];
|
|
17
|
+
|
|
18
|
+
/** `<name>#<12 hex of sha256(ownerKey)>` — deterministic per owner, stable across restarts. */
|
|
19
|
+
export function assistantIdFor(name, ownerKey = '') {
|
|
20
|
+
const hash = crypto.createHash('sha256').update(String(ownerKey ?? '')).digest('hex').slice(0, 12);
|
|
21
|
+
return `${slug(name)}#${hash}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function defaultAssistantId(ownerKey = '') {
|
|
25
|
+
return assistantIdFor(DEFAULT_ASSISTANT_NAME, ownerKey);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function slug(name) {
|
|
29
|
+
const text = normalizeName(name, { strict: false });
|
|
30
|
+
return text.replace(/\s+/g, '-');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeName(name, options = {}) {
|
|
34
|
+
const text = typeof name === 'string' ? name.trim() : '';
|
|
35
|
+
if (text === '') {
|
|
36
|
+
if (options.strict === false) return DEFAULT_ASSISTANT_NAME;
|
|
37
|
+
throw pactiveError('ASSISTANT_NAME_REQUIRED', 'an assistant needs a name');
|
|
38
|
+
}
|
|
39
|
+
if (text.includes('#')) {
|
|
40
|
+
throw pactiveError('ASSISTANT_NAME_INVALID', `an assistant name must not contain "#": ${text}`);
|
|
41
|
+
}
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Only the keys the user actually set — an absent key falls back to the global policy. */
|
|
46
|
+
function normalizePolicy(raw) {
|
|
47
|
+
const out = {};
|
|
48
|
+
if (!raw || typeof raw !== 'object') return out;
|
|
49
|
+
for (const key of POLICY_KEYS) {
|
|
50
|
+
if (raw[key] !== undefined && raw[key] !== null) out[key] = raw[key];
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The herdr binding of one assistant (contract §2.1): `{paneId, agentName, host, surface, tabId, boundAt}`.
|
|
57
|
+
* Anything without a usable `paneId` is "not bound" — a half-written record must not look bound.
|
|
58
|
+
*/
|
|
59
|
+
export function normalizePane(raw) {
|
|
60
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
61
|
+
const paneId = typeof raw.paneId === 'string' && raw.paneId !== '' ? raw.paneId : null;
|
|
62
|
+
if (!paneId) return null;
|
|
63
|
+
return {
|
|
64
|
+
paneId,
|
|
65
|
+
agentName: typeof raw.agentName === 'string' && raw.agentName !== '' ? raw.agentName : null,
|
|
66
|
+
// No host name is invented here: the adapter records which host the pane runs (I6 keeps
|
|
67
|
+
// src/core host-agnostic), so an unset host stays `null` instead of defaulting to one.
|
|
68
|
+
host: typeof raw.host === 'string' && raw.host !== '' ? raw.host : null,
|
|
69
|
+
surface: raw.surface === 'pane' ? 'pane' : raw.surface === 'agent' ? 'agent' : (raw.agentName ? 'agent' : 'pane'),
|
|
70
|
+
tabId: typeof raw.tabId === 'string' && raw.tabId !== '' ? raw.tabId : null,
|
|
71
|
+
boundAt: Number.isFinite(raw.boundAt) ? raw.boundAt : 0,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function makeAssistant(spec = {}, ownerKey = '', now = Date.now()) {
|
|
76
|
+
const name = normalizeName(spec.name);
|
|
77
|
+
const assistantId = typeof spec.assistantId === 'string' && spec.assistantId
|
|
78
|
+
? spec.assistantId
|
|
79
|
+
: assistantIdFor(name, ownerKey);
|
|
80
|
+
const scope = spec.scope && typeof spec.scope === 'object' ? spec.scope : {};
|
|
81
|
+
const delivery = spec.delivery && typeof spec.delivery === 'object' ? spec.delivery : {};
|
|
82
|
+
return {
|
|
83
|
+
assistantId,
|
|
84
|
+
name,
|
|
85
|
+
title: typeof spec.title === 'string' && spec.title.trim() !== '' ? spec.title.trim() : name,
|
|
86
|
+
role: typeof spec.role === 'string' ? spec.role : '',
|
|
87
|
+
scope: {
|
|
88
|
+
projectHash: typeof scope.projectHash === 'string' && scope.projectHash ? scope.projectHash : null,
|
|
89
|
+
globs: Array.isArray(scope.globs) ? scope.globs.filter((g) => typeof g === 'string' && g !== '') : [],
|
|
90
|
+
},
|
|
91
|
+
enabled: spec.enabled === true,
|
|
92
|
+
policy: normalizePolicy(spec.policy),
|
|
93
|
+
delivery: {
|
|
94
|
+
host: typeof delivery.host === 'string' && delivery.host ? delivery.host : null,
|
|
95
|
+
sessionId: typeof delivery.sessionId === 'string' && delivery.sessionId ? delivery.sessionId : null,
|
|
96
|
+
fallback: typeof delivery.fallback === 'string' && delivery.fallback ? delivery.fallback : 'outbox',
|
|
97
|
+
},
|
|
98
|
+
// I75/I76: the display instance (herdr pane), never the identity. `null` = not bound yet.
|
|
99
|
+
pane: normalizePane(spec.pane),
|
|
100
|
+
createdAt: Number.isFinite(spec.createdAt) ? spec.createdAt : now,
|
|
101
|
+
lastFiredAt: Number.isFinite(spec.lastFiredAt) ? spec.lastFiredAt : 0,
|
|
102
|
+
firedCount: Number.isFinite(spec.firedCount) ? spec.firedCount : 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Tolerant read-side normalisation: a dirty record must not crash the state load. */
|
|
107
|
+
export function normalizeAssistant(raw, ownerKey = '', now = Date.now()) {
|
|
108
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
109
|
+
const name = typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name : DEFAULT_ASSISTANT_NAME;
|
|
110
|
+
const assistantId = typeof raw.assistantId === 'string' && raw.assistantId ? raw.assistantId : assistantIdFor(name, ownerKey);
|
|
111
|
+
return makeAssistant({ ...raw, name, assistantId }, ownerKey, now);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function normalizeAssistantTable(raw, ownerKey = '', now = Date.now()) {
|
|
115
|
+
const out = {};
|
|
116
|
+
if (!raw || typeof raw !== 'object') return out;
|
|
117
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
118
|
+
const record = normalizeAssistant(value, ownerKey, now);
|
|
119
|
+
if (!record) continue;
|
|
120
|
+
out[key] = record;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function tableOf(state, options = {}) {
|
|
126
|
+
if (!state.assistants || typeof state.assistants !== 'object') {
|
|
127
|
+
if (options.init !== true) return {};
|
|
128
|
+
state.assistants = {};
|
|
129
|
+
}
|
|
130
|
+
return state.assistants;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ownerKeyOf(state) {
|
|
134
|
+
return typeof state?.identity?.ownerKey === 'string' ? state.identity.ownerKey : '';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function byName(a, b) {
|
|
138
|
+
return a.name.localeCompare(b.name);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const assistants = {
|
|
142
|
+
/** Add one assistant. A duplicate name/id is an explicit error, never a silent overwrite. */
|
|
143
|
+
add(state, spec = {}, now = Date.now()) {
|
|
144
|
+
const table = tableOf(state, { init: true });
|
|
145
|
+
const name = normalizeName(spec.name);
|
|
146
|
+
const record = makeAssistant({ ...spec, name }, ownerKeyOf(state), now);
|
|
147
|
+
if (table[record.assistantId]) {
|
|
148
|
+
throw pactiveError('ASSISTANT_EXISTS', `assistant already exists: ${record.assistantId}`);
|
|
149
|
+
}
|
|
150
|
+
const clash = Object.values(table).find((a) => a && a.name === name);
|
|
151
|
+
if (clash) throw pactiveError('ASSISTANT_NAME_TAKEN', `assistant name already taken: ${name}`);
|
|
152
|
+
table[record.assistantId] = record;
|
|
153
|
+
return record;
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
list(state) {
|
|
157
|
+
return Object.values(tableOf(state)).filter((a) => a && typeof a === 'object').sort(byName);
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/** Resolve by exact id, then by name. Unknown → null (the caller decides the exit code). */
|
|
161
|
+
resolve(state, ref) {
|
|
162
|
+
const text = typeof ref === 'string' ? ref.trim() : '';
|
|
163
|
+
if (text === '') return null;
|
|
164
|
+
const table = tableOf(state);
|
|
165
|
+
if (table[text]) return table[text];
|
|
166
|
+
return Object.values(table).find((a) => a && a.name === text) ?? null;
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
get(state, ref) {
|
|
170
|
+
return assistants.resolve(state, ref);
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
setEnabled(state, ref, enabled) {
|
|
174
|
+
const record = assistants.resolve(state, ref);
|
|
175
|
+
if (!record) return null;
|
|
176
|
+
record.enabled = enabled === true;
|
|
177
|
+
return record;
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
/** Bind one assistant to one herdr pane (I79: the caller replaces, never adds a second pane). */
|
|
181
|
+
bindPane(state, ref, binding, now = Date.now()) {
|
|
182
|
+
const record = assistants.resolve(state, ref);
|
|
183
|
+
if (!record) return null;
|
|
184
|
+
const pane = normalizePane({ ...binding, boundAt: Number.isFinite(binding?.boundAt) ? binding.boundAt : now });
|
|
185
|
+
if (!pane) return null;
|
|
186
|
+
record.pane = pane;
|
|
187
|
+
return record;
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
/** Drop the binding only — the assistant, its tasks and its ledger history stay (I75). */
|
|
191
|
+
unbindPane(state, ref) {
|
|
192
|
+
const record = assistants.resolve(state, ref);
|
|
193
|
+
if (!record) return null;
|
|
194
|
+
record.pane = null;
|
|
195
|
+
return record;
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
/** Remove the assistant AND its tasks (I65: no orphans). The ledger keeps the history. */
|
|
199
|
+
remove(state, ref) {
|
|
200
|
+
const table = tableOf(state, { init: true });
|
|
201
|
+
const record = assistants.resolve(state, ref);
|
|
202
|
+
if (!record) return null;
|
|
203
|
+
const id = record.assistantId;
|
|
204
|
+
delete table[id];
|
|
205
|
+
if (state.guardByAssistant && typeof state.guardByAssistant === 'object') delete state.guardByAssistant[id];
|
|
206
|
+
const before = Array.isArray(state.tasks) ? state.tasks.length : 0;
|
|
207
|
+
state.tasks = (Array.isArray(state.tasks) ? state.tasks : []).filter((t) => t && t.assistantId !== id);
|
|
208
|
+
return { removed: record, tasks: before - state.tasks.length };
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/** Rename keeps the id: state, ledger history and tasks stay bound to the same assistant. */
|
|
212
|
+
rename(state, ref, newName) {
|
|
213
|
+
const table = tableOf(state, { init: true });
|
|
214
|
+
const record = assistants.resolve(state, ref);
|
|
215
|
+
if (!record) return null;
|
|
216
|
+
const name = normalizeName(newName);
|
|
217
|
+
const clash = Object.values(table).find((a) => a && a.assistantId !== record.assistantId && a.name === name);
|
|
218
|
+
if (clash) throw pactiveError('ASSISTANT_NAME_TAKEN', `assistant name already taken: ${name}`);
|
|
219
|
+
record.name = name;
|
|
220
|
+
if (!record.title || record.title === ref) record.title = name;
|
|
221
|
+
return record;
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
/** The implicit `default` assistant, created on first use (never enabled silently). */
|
|
225
|
+
ensureDefault(state, now = Date.now()) {
|
|
226
|
+
const existing = assistants.resolve(state, DEFAULT_ASSISTANT_NAME);
|
|
227
|
+
if (existing) return existing;
|
|
228
|
+
const table = tableOf(state, { init: true });
|
|
229
|
+
const record = makeAssistant({ name: DEFAULT_ASSISTANT_NAME }, ownerKeyOf(state), now);
|
|
230
|
+
table[record.assistantId] = record;
|
|
231
|
+
return record;
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
defaultId(state) {
|
|
235
|
+
return defaultAssistantId(ownerKeyOf(state));
|
|
236
|
+
},
|
|
237
|
+
};
|
package/src/core/config.mjs
CHANGED
|
@@ -8,11 +8,28 @@ export const CONFIG_SCHEMA = {
|
|
|
8
8
|
'policy.hourlyLimit': { type: 'number', default: 6, humanGated: true, min: 0 },
|
|
9
9
|
'policy.minIntervalMs': { type: 'number', default: 600000, humanGated: true, min: 0 },
|
|
10
10
|
'policy.cooldownMs': { type: 'number', default: 1800000, humanGated: true, min: 0 },
|
|
11
|
+
// I64: one fleet-wide ceiling; `0` means "never deliver", not "unlimited".
|
|
12
|
+
'globalGuard.hourlyLimit': { type: 'number', default: 24, humanGated: true, min: 0 },
|
|
11
13
|
};
|
|
12
14
|
|
|
15
|
+
/** Keys an assistant may override (I63): the throttling policy, nothing global. */
|
|
16
|
+
export const ASSISTANT_POLICY_KEYS = [
|
|
17
|
+
'policy.quietStart',
|
|
18
|
+
'policy.quietEnd',
|
|
19
|
+
'policy.hourlyLimit',
|
|
20
|
+
'policy.minIntervalMs',
|
|
21
|
+
'policy.cooldownMs',
|
|
22
|
+
];
|
|
23
|
+
|
|
13
24
|
export const config = {
|
|
14
|
-
|
|
25
|
+
/** `assistantId` reads the assistant's override first, then falls back to the global value. */
|
|
26
|
+
get(state, key, assistantId) {
|
|
15
27
|
const spec = specOf(key);
|
|
28
|
+
if (typeof assistantId === 'string' && assistantId !== '') {
|
|
29
|
+
const leaf = assistantLeaf(key);
|
|
30
|
+
const override = leaf === null ? undefined : state?.assistants?.[assistantId]?.policy?.[leaf];
|
|
31
|
+
if (override !== undefined && override !== null) return override;
|
|
32
|
+
}
|
|
16
33
|
const current = readPath(state, key);
|
|
17
34
|
return current === undefined ? spec.default : current;
|
|
18
35
|
},
|
|
@@ -24,10 +41,13 @@ export const config = {
|
|
|
24
41
|
defaults[key] = spec.default;
|
|
25
42
|
if (spec.humanGated) humanGatedKeys.push(key);
|
|
26
43
|
}
|
|
27
|
-
return { schema: CONFIG_SCHEMA, defaults, humanGatedKeys };
|
|
44
|
+
return { schema: CONFIG_SCHEMA, defaults, humanGatedKeys, assistantKeys: [...ASSISTANT_POLICY_KEYS] };
|
|
28
45
|
},
|
|
29
46
|
|
|
30
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Gated keys only change with an explicit confirmation; without one nothing is written.
|
|
49
|
+
* With `options.assistantId` the value becomes that assistant's override (policy keys only).
|
|
50
|
+
*/
|
|
31
51
|
setHumanGated(state, key, value, options = {}) {
|
|
32
52
|
const spec = specOf(key);
|
|
33
53
|
const confirm = options && options.confirm === true;
|
|
@@ -37,11 +57,28 @@ export const config = {
|
|
|
37
57
|
if (!validValue(spec, value)) {
|
|
38
58
|
return { ok: false, key, reason: 'invalid-value' };
|
|
39
59
|
}
|
|
60
|
+
const assistantId = typeof options.assistantId === 'string' ? options.assistantId : '';
|
|
61
|
+
if (assistantId !== '') {
|
|
62
|
+
const leaf = assistantLeaf(key);
|
|
63
|
+
if (leaf === null) {
|
|
64
|
+
return { ok: false, key, reason: 'not-per-assistant', assistantId };
|
|
65
|
+
}
|
|
66
|
+
const record = state?.assistants?.[assistantId];
|
|
67
|
+
if (!record) return { ok: false, key, reason: 'unknown-assistant', assistantId };
|
|
68
|
+
if (!record.policy || typeof record.policy !== 'object') record.policy = {};
|
|
69
|
+
record.policy[leaf] = value;
|
|
70
|
+
return { ok: true, key, value, assistantId };
|
|
71
|
+
}
|
|
40
72
|
writePath(state, key, value);
|
|
41
73
|
return { ok: true, key, value };
|
|
42
74
|
},
|
|
43
75
|
};
|
|
44
76
|
|
|
77
|
+
function assistantLeaf(key) {
|
|
78
|
+
if (!ASSISTANT_POLICY_KEYS.includes(key)) return null;
|
|
79
|
+
return key.slice('policy.'.length);
|
|
80
|
+
}
|
|
81
|
+
|
|
45
82
|
function specOf(key) {
|
|
46
83
|
const spec = Object.prototype.hasOwnProperty.call(CONFIG_SCHEMA, key) ? CONFIG_SCHEMA[key] : null;
|
|
47
84
|
if (!spec) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Fleet view: the machine-checkable answer to "which assistant lives in which pane" (I76).
|
|
2
|
+
//
|
|
3
|
+
// This module is deliberately pure — no subprocess, no herdr call. The caller injects live state
|
|
4
|
+
// through `statusOf(binding)`, so `fleet ls` stays readable even when herdr is absent (I78: absent
|
|
5
|
+
// means "status unknown", never a fake `idle`).
|
|
6
|
+
import { assistants } from './assistants.mjs';
|
|
7
|
+
import { ledger } from './lineage.mjs';
|
|
8
|
+
|
|
9
|
+
/** `delivered|queued|unreachable` events → the most recent one per assistant. */
|
|
10
|
+
export function lastDeliveryByAssistant(events = []) {
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
13
|
+
if (!event || typeof event !== 'object') continue;
|
|
14
|
+
if (!['delivered', 'queued', 'unreachable', 'blocked'].includes(event.event)) continue;
|
|
15
|
+
const id = typeof event.assistantId === 'string' ? event.assistantId : '';
|
|
16
|
+
if (id === '') continue;
|
|
17
|
+
const previous = out[id];
|
|
18
|
+
const ts = Number.isFinite(event.ts) ? event.ts : 0;
|
|
19
|
+
if (previous && previous.ts > ts) continue;
|
|
20
|
+
out[id] = {
|
|
21
|
+
ts,
|
|
22
|
+
event: event.event,
|
|
23
|
+
paneId: event.paneId ?? null,
|
|
24
|
+
reason: event.reason ?? null,
|
|
25
|
+
prompt: event.prompt ?? null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One row per assistant: identity + binding + live status + last delivery.
|
|
33
|
+
* `statusOf(binding)` returns a `herdr agent_status` string or null when unobservable.
|
|
34
|
+
*/
|
|
35
|
+
export function fleetRows(state, options = {}) {
|
|
36
|
+
const statusOf = typeof options.statusOf === 'function' ? options.statusOf : () => null;
|
|
37
|
+
const last = options.last ?? {};
|
|
38
|
+
return assistants.list(state).map((record) => {
|
|
39
|
+
const pane = record.pane ?? null;
|
|
40
|
+
const bound = pane !== null;
|
|
41
|
+
const observed = bound ? statusOf(pane) : null;
|
|
42
|
+
const delivered = last[record.assistantId] ?? null;
|
|
43
|
+
return {
|
|
44
|
+
assistantId: record.assistantId,
|
|
45
|
+
name: record.name,
|
|
46
|
+
enabled: record.enabled === true,
|
|
47
|
+
bound,
|
|
48
|
+
paneId: pane?.paneId ?? null,
|
|
49
|
+
agentName: pane?.agentName ?? null,
|
|
50
|
+
host: pane?.host ?? record.delivery?.host ?? null,
|
|
51
|
+
surface: pane?.surface ?? null,
|
|
52
|
+
tabId: pane?.tabId ?? null,
|
|
53
|
+
boundAt: pane?.boundAt ?? 0,
|
|
54
|
+
// `null` = not observed (herdr absent, or a degraded pane surface). Never printed as idle.
|
|
55
|
+
status: observed ?? null,
|
|
56
|
+
lastDeliveryAt: delivered?.ts ?? 0,
|
|
57
|
+
lastDeliveryEvent: delivered?.event ?? null,
|
|
58
|
+
lastDeliveryPaneId: delivered?.paneId ?? null,
|
|
59
|
+
lastDeliveryReason: delivered?.reason ?? null,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read the ledger once and build the "recent delivery" column. */
|
|
65
|
+
export function readLastDeliveries(ledgerFile, limit = 500) {
|
|
66
|
+
if (typeof ledgerFile !== 'string' || ledgerFile === '') return {};
|
|
67
|
+
try {
|
|
68
|
+
return lastDeliveryByAssistant(ledger.tail(ledgerFile, limit));
|
|
69
|
+
} catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const fleet = {
|
|
75
|
+
view(state, options = {}) {
|
|
76
|
+
const last = options.last ?? readLastDeliveries(options.ledgerFile);
|
|
77
|
+
return fleetRows(state, { ...options, last });
|
|
78
|
+
},
|
|
79
|
+
lastDeliveries: readLastDeliveries,
|
|
80
|
+
};
|
package/src/core/guard.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import { defaultAssistantId } from './assistants.mjs';
|
|
2
|
+
|
|
3
|
+
export const GUARD_REASONS = ['quiet', 'hourly-limit', 'global-limit', 'min-interval', 'cooldown'];
|
|
2
4
|
export const HOUR_WINDOW_MS = 3600000;
|
|
5
|
+
export const GLOBAL_HOURLY_LIMIT_DEFAULT = 24;
|
|
3
6
|
|
|
4
7
|
/** Quiet hours are local wall clock windows, "HH:MM"; a window may wrap midnight. */
|
|
5
8
|
export function inQuietWindow(quietStart, quietEnd, now) {
|
|
@@ -24,14 +27,89 @@ function toMinutes(value) {
|
|
|
24
27
|
return hours * 60 + minutes;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
/**
|
|
28
|
-
export function
|
|
30
|
+
/** Either an assistant id, a name, an assistant record, or nothing (= the default assistant). */
|
|
31
|
+
export function resolveAssistantId(state, assistant) {
|
|
32
|
+
if (typeof assistant === 'string' && assistant.trim() !== '') {
|
|
33
|
+
const text = assistant.trim();
|
|
34
|
+
const table = state?.assistants && typeof state.assistants === 'object' ? state.assistants : {};
|
|
35
|
+
if (table[text]) return text;
|
|
36
|
+
const byName = Object.values(table).find((a) => a && a.name === text);
|
|
37
|
+
if (byName && typeof byName.assistantId === 'string') return byName.assistantId;
|
|
38
|
+
// An unknown name keeps its own bucket: a typo must never charge the default assistant's budget.
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
if (assistant && typeof assistant === 'object' && typeof assistant.assistantId === 'string') {
|
|
42
|
+
return assistant.assistantId;
|
|
43
|
+
}
|
|
44
|
+
return defaultAssistantId(state?.identity?.ownerKey ?? '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Per-assistant policy override on top of the global policy; absent keys fall back (I63). */
|
|
48
|
+
export function effectivePolicy(state, assistantId) {
|
|
49
|
+
const base = state?.policy && typeof state.policy === 'object' ? state.policy : {};
|
|
50
|
+
const out = { ...base };
|
|
51
|
+
const record = state?.assistants && typeof state.assistants === 'object' ? state.assistants[assistantId] : null;
|
|
52
|
+
const override = record && record.policy && typeof record.policy === 'object' ? record.policy : {};
|
|
53
|
+
for (const [key, value] of Object.entries(override)) {
|
|
54
|
+
if (value !== undefined && value !== null) out[key] = value;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function freshGuard() {
|
|
60
|
+
return { hourWindowStart: 0, hourCount: 0, lastTriggerAt: 0, lastTriggerEpoch: -1 };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The guard bucket of one assistant.
|
|
65
|
+
* The `default` assistant keeps using `state.guard`: that field IS the schema-v1 single-assistant
|
|
66
|
+
* counter block, so the default assistant needs no second copy that could drift.
|
|
67
|
+
*/
|
|
68
|
+
export function guardOf(state, assistantId) {
|
|
69
|
+
if (!state.guard || typeof state.guard !== 'object') state.guard = freshGuard();
|
|
70
|
+
if (!state.guardByAssistant || typeof state.guardByAssistant !== 'object') state.guardByAssistant = {};
|
|
71
|
+
const bucket = state.guardByAssistant[assistantId];
|
|
72
|
+
if (bucket && typeof bucket === 'object') {
|
|
73
|
+
if (!Number.isFinite(bucket.lastTriggerEpoch)) bucket.lastTriggerEpoch = -1;
|
|
74
|
+
return bucket;
|
|
75
|
+
}
|
|
76
|
+
if (assistantId === defaultAssistantId(state.identity?.ownerKey ?? '')) {
|
|
77
|
+
if (!Number.isFinite(state.guard.lastTriggerEpoch)) state.guard.lastTriggerEpoch = -1;
|
|
78
|
+
return state.guard;
|
|
79
|
+
}
|
|
80
|
+
const fresh = freshGuard();
|
|
81
|
+
state.guardByAssistant[assistantId] = fresh;
|
|
82
|
+
return fresh;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function globalOf(state) {
|
|
86
|
+
if (!state.globalGuard || typeof state.globalGuard !== 'object') {
|
|
87
|
+
state.globalGuard = { hourWindowStart: 0, hourCount: 0, hourlyLimit: GLOBAL_HOURLY_LIMIT_DEFAULT };
|
|
88
|
+
}
|
|
89
|
+
const g = state.globalGuard;
|
|
90
|
+
if (!Number.isFinite(g.hourlyLimit)) g.hourlyLimit = GLOBAL_HOURLY_LIMIT_DEFAULT;
|
|
91
|
+
if (!Number.isFinite(g.hourCount)) g.hourCount = 0;
|
|
92
|
+
if (!Number.isFinite(g.hourWindowStart)) g.hourWindowStart = 0;
|
|
93
|
+
return g;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function windowOpen(windowStart, ts) {
|
|
97
|
+
return !(windowStart > 0 && ts - windowStart >= HOUR_WINDOW_MS);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The gates: quiet window, per-assistant hourly limit, optional interval/cooldown, global ceiling.
|
|
102
|
+
* `options.skipIntervalGates` is for user-scheduled tasks: min-interval/cooldown protect against
|
|
103
|
+
* proactive nagging, they must not swallow a reminder the user asked for.
|
|
104
|
+
*/
|
|
105
|
+
export function checkGuard(state, now = Date.now(), assistant, options = {}) {
|
|
29
106
|
const ts = Number.isFinite(now) ? now : Date.now();
|
|
30
|
-
const
|
|
31
|
-
const
|
|
107
|
+
const assistantId = resolveAssistantId(state, assistant);
|
|
108
|
+
const policy = effectivePolicy(state, assistantId);
|
|
109
|
+
const guard = guardOf(state, assistantId);
|
|
32
110
|
|
|
33
111
|
if (inQuietWindow(policy.quietStart, policy.quietEnd, ts)) {
|
|
34
|
-
return { pass: false, reason: 'quiet' };
|
|
112
|
+
return { pass: false, reason: 'quiet', assistantId };
|
|
35
113
|
}
|
|
36
114
|
|
|
37
115
|
const limit = Number.isFinite(policy.hourlyLimit) ? policy.hourlyLimit : 6;
|
|
@@ -39,57 +117,64 @@ export function checkGuard(state, now = Date.now()) {
|
|
|
39
117
|
const count = Number.isFinite(guard.hourCount) ? guard.hourCount : 0;
|
|
40
118
|
// The window is only rolled over when it really expired; a count without a
|
|
41
119
|
// window start still counts for the current hour.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return { pass: false, reason: 'hourly-limit' };
|
|
120
|
+
if (windowOpen(windowStart, ts) && count >= limit) {
|
|
121
|
+
return { pass: false, reason: 'hourly-limit', assistantId };
|
|
45
122
|
}
|
|
46
123
|
|
|
47
124
|
const lastTriggerAt = Number.isFinite(guard.lastTriggerAt) ? guard.lastTriggerAt : 0;
|
|
48
|
-
if (lastTriggerAt > 0) {
|
|
125
|
+
if (lastTriggerAt > 0 && options.skipIntervalGates !== true) {
|
|
49
126
|
const minIntervalMs = Number.isFinite(policy.minIntervalMs) ? policy.minIntervalMs : 600000;
|
|
50
127
|
if (ts - lastTriggerAt < minIntervalMs) {
|
|
51
|
-
return { pass: false, reason: 'min-interval' };
|
|
128
|
+
return { pass: false, reason: 'min-interval', assistantId };
|
|
52
129
|
}
|
|
53
130
|
const cooldownMs = Number.isFinite(policy.cooldownMs) ? policy.cooldownMs : 1800000;
|
|
54
131
|
if (ts - lastTriggerAt < cooldownMs) {
|
|
55
|
-
return { pass: false, reason: 'cooldown' };
|
|
132
|
+
return { pass: false, reason: 'cooldown', assistantId };
|
|
56
133
|
}
|
|
57
134
|
}
|
|
58
135
|
|
|
59
|
-
|
|
136
|
+
const global = globalOf(state);
|
|
137
|
+
if (windowOpen(global.hourWindowStart, ts) && global.hourCount >= global.hourlyLimit) {
|
|
138
|
+
return { pass: false, reason: 'global-limit', assistantId };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { pass: true, reason: 'idle', assistantId };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function bumpWindow(bucket, ts) {
|
|
145
|
+
const windowStart = Number.isFinite(bucket.hourWindowStart) ? bucket.hourWindowStart : 0;
|
|
146
|
+
if (windowStart > 0 && ts - windowStart >= HOUR_WINDOW_MS) {
|
|
147
|
+
bucket.hourWindowStart = ts;
|
|
148
|
+
bucket.hourCount = 0;
|
|
149
|
+
} else if (windowStart === 0) {
|
|
150
|
+
bucket.hourWindowStart = ts;
|
|
151
|
+
}
|
|
152
|
+
bucket.hourCount = (Number.isFinite(bucket.hourCount) ? bucket.hourCount : 0) + 1;
|
|
60
153
|
}
|
|
61
154
|
|
|
62
155
|
/**
|
|
63
156
|
* Reserve the right to trigger once for the current activity epoch.
|
|
64
157
|
* Blocks are reported with a reason; the reservation is recorded in the state.
|
|
158
|
+
* `options.ignoreEpoch` is for explicit user tasks (two reminders may share one epoch).
|
|
65
159
|
*/
|
|
66
|
-
export function reserveTrigger(state, now = Date.now()) {
|
|
160
|
+
export function reserveTrigger(state, now = Date.now(), assistant, options = {}) {
|
|
67
161
|
const ts = Number.isFinite(now) ? now : Date.now();
|
|
68
162
|
const epoch = Number.isFinite(state.epoch) ? state.epoch : 0;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
const guard = state.guard;
|
|
73
|
-
if (!Number.isFinite(guard.lastTriggerEpoch)) guard.lastTriggerEpoch = -1;
|
|
163
|
+
const assistantId = resolveAssistantId(state, assistant);
|
|
164
|
+
const guard = guardOf(state, assistantId);
|
|
74
165
|
|
|
75
|
-
const gate = checkGuard(state, ts);
|
|
166
|
+
const gate = checkGuard(state, ts, assistantId, options);
|
|
76
167
|
if (!gate.pass) {
|
|
77
|
-
return { reserved: false, epoch, reason: gate.reason };
|
|
168
|
+
return { reserved: false, epoch, reason: gate.reason, assistantId };
|
|
78
169
|
}
|
|
79
|
-
if (guard.lastTriggerEpoch === epoch) {
|
|
170
|
+
if (options.ignoreEpoch !== true && guard.lastTriggerEpoch === epoch) {
|
|
80
171
|
// one trigger per activity epoch; a new user or agent action opens the next one
|
|
81
|
-
return { reserved: false, epoch, reason: 'requires-action' };
|
|
172
|
+
return { reserved: false, epoch, reason: 'requires-action', assistantId };
|
|
82
173
|
}
|
|
83
174
|
|
|
84
|
-
|
|
85
|
-
if (windowStart > 0 && ts - windowStart >= HOUR_WINDOW_MS) {
|
|
86
|
-
guard.hourWindowStart = ts;
|
|
87
|
-
guard.hourCount = 0;
|
|
88
|
-
} else if (windowStart === 0) {
|
|
89
|
-
guard.hourWindowStart = ts;
|
|
90
|
-
}
|
|
91
|
-
guard.hourCount = (Number.isFinite(guard.hourCount) ? guard.hourCount : 0) + 1;
|
|
175
|
+
bumpWindow(guard, ts);
|
|
92
176
|
guard.lastTriggerAt = ts;
|
|
93
177
|
guard.lastTriggerEpoch = epoch;
|
|
94
|
-
|
|
178
|
+
bumpWindow(globalOf(state), ts);
|
|
179
|
+
return { reserved: true, epoch, assistantId };
|
|
95
180
|
}
|
package/src/core/idle.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { checkGuard } from './guard.mjs';
|
|
1
|
+
import { checkGuard, effectivePolicy, resolveAssistantId } from './guard.mjs';
|
|
2
2
|
|
|
3
3
|
export const IDLE_REASONS = [
|
|
4
4
|
'idle', 'disabled', 'no-control', 'busy', 'threshold',
|
|
5
|
-
'quiet', 'hourly-limit', 'min-interval', 'cooldown', 'requires-action',
|
|
5
|
+
'quiet', 'hourly-limit', 'global-limit', 'min-interval', 'cooldown', 'requires-action',
|
|
6
6
|
];
|
|
7
7
|
|
|
8
8
|
const POLICY_KEYS = ['quietStart', 'quietEnd', 'hourlyLimit', 'minIntervalMs', 'cooldownMs'];
|
|
@@ -10,22 +10,26 @@ const POLICY_KEYS = ['quietStart', 'quietEnd', 'hourlyLimit', 'minIntervalMs', '
|
|
|
10
10
|
/**
|
|
11
11
|
* Four gates must all open before a proactive prompt is allowed:
|
|
12
12
|
* control returned, agent not busy, activity threshold reached, guard open.
|
|
13
|
+
* `assistant` (name / id / record) selects whose switch, policy override and guard bucket apply.
|
|
13
14
|
*/
|
|
14
|
-
export function evaluateIdle(state, now = Date.now(), policy = {}) {
|
|
15
|
+
export function evaluateIdle(state, now = Date.now(), policy = {}, assistant) {
|
|
15
16
|
const ts = Number.isFinite(now) ? now : Date.now();
|
|
16
17
|
const p = policy && typeof policy === 'object' ? policy : {};
|
|
17
18
|
const idleCfg = { ...(state.idle || {}), ...(p.idle || {}) };
|
|
19
|
+
const assistantId = resolveAssistantId(state, assistant);
|
|
20
|
+
const record = state.assistants && typeof state.assistants === 'object' ? state.assistants[assistantId] : null;
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
// I63: every assistant carries its own switch; without one the legacy single switch applies.
|
|
23
|
+
const enabled = firstDefined(p.enabled, p.idleEnabled, record ? record.enabled : undefined, idleCfg.enabled, false);
|
|
20
24
|
const thresholdMs = firstDefined(p.idleThresholdMs, p.thresholdMs, idleCfg.thresholdMs, 0);
|
|
21
25
|
|
|
22
|
-
const guardState = { ...state, policy: { ...(state
|
|
26
|
+
const guardState = { ...state, policy: { ...effectivePolicy(state, assistantId) } };
|
|
23
27
|
for (const key of POLICY_KEYS) {
|
|
24
28
|
if (p[key] !== undefined) guardState.policy[key] = p[key];
|
|
25
29
|
}
|
|
26
30
|
if (p.policy && typeof p.policy === 'object') Object.assign(guardState.policy, p.policy);
|
|
27
31
|
|
|
28
|
-
const guard = checkGuard(guardState, ts);
|
|
32
|
+
const guard = checkGuard(guardState, ts, assistantId);
|
|
29
33
|
const gates = {
|
|
30
34
|
control: state.controlReturned === true,
|
|
31
35
|
notBusy: state.busy === false,
|
|
@@ -41,7 +45,7 @@ export function evaluateIdle(state, now = Date.now(), policy = {}) {
|
|
|
41
45
|
else if (!gates.guard) reason = guard.reason;
|
|
42
46
|
else reason = 'idle';
|
|
43
47
|
|
|
44
|
-
return { idle: reason === 'idle', reason, gates };
|
|
48
|
+
return { idle: reason === 'idle', reason, gates, assistantId };
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
function firstDefined(...values) {
|
|
@@ -50,4 +54,3 @@ function firstDefined(...values) {
|
|
|
50
54
|
}
|
|
51
55
|
return undefined;
|
|
52
56
|
}
|
|
53
|
-
|
package/src/core/index.mjs
CHANGED
|
@@ -5,6 +5,9 @@ export { makeIdentity } from './identity.mjs';
|
|
|
5
5
|
export { markActivity, markControlReturned, getActivity } from './activity.mjs';
|
|
6
6
|
export { evaluateIdle } from './idle.mjs';
|
|
7
7
|
export { checkGuard, reserveTrigger } from './guard.mjs';
|
|
8
|
+
export { assistants, normalizePane } from './assistants.mjs';
|
|
9
|
+
export { fleet } from './fleet.mjs';
|
|
10
|
+
export { runTick } from './tick.mjs';
|
|
8
11
|
export { schedule } from './scheduler.mjs';
|
|
9
12
|
export { mailbox } from './mailbox.mjs';
|
|
10
13
|
export { ledger } from './lineage.mjs';
|