pactive 0.0.3-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 +84 -10
- package/bin/pactive.mjs +650 -18
- package/dsh/index.js +170 -14
- package/mcp/tools/definitions.mjs +159 -13
- package/package.json +1 -1
- package/pi/extension.ts +191 -15
- package/pi/skills/pactive/SKILL.md +16 -2
- package/skills/pactive/SKILL.md +16 -2
- 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 +10 -2
- 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
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';
|
package/src/core/scheduler.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultAssistantId } from './assistants.mjs';
|
|
1
2
|
import { pactiveError } from './errors.mjs';
|
|
2
3
|
|
|
3
4
|
export const TASK_KINDS = ['in', 'at', 'every'];
|
|
@@ -38,12 +39,16 @@ export const schedule = {
|
|
|
38
39
|
|
|
39
40
|
const owner = spec.owner ?? state.identity?.ownerKey ?? '';
|
|
40
41
|
const id = spec.id ?? `task-${ts}-${(seq += 1)}`;
|
|
42
|
+
// Every task belongs to exactly one assistant (contract §2 / I65): an unqualified task lands
|
|
43
|
+
// on the default assistant, never on "whoever happens to be first".
|
|
44
|
+
const assistantId = spec.assistantId ?? defaultAssistantId(state.identity?.ownerKey ?? '');
|
|
41
45
|
|
|
42
46
|
const existing = state.tasks.find((t) => t.id === id && t.owner === owner && t.dueAt === dueAt && t.active !== false);
|
|
43
47
|
if (existing) return existing;
|
|
44
48
|
|
|
45
49
|
const task = {
|
|
46
50
|
id,
|
|
51
|
+
assistantId,
|
|
47
52
|
kind,
|
|
48
53
|
dueAt,
|
|
49
54
|
owner,
|
|
@@ -68,8 +73,11 @@ export const schedule = {
|
|
|
68
73
|
return state.tasks.length !== before;
|
|
69
74
|
},
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
/** All tasks, or only one assistant's tasks when `assistantId` is given. */
|
|
77
|
+
list(state, assistantId) {
|
|
78
|
+
const all = Array.isArray(state.tasks) ? [...state.tasks] : [];
|
|
79
|
+
if (typeof assistantId !== 'string' || assistantId === '') return all;
|
|
80
|
+
return all.filter((t) => t && t.assistantId === assistantId);
|
|
73
81
|
},
|
|
74
82
|
|
|
75
83
|
/** Returns the tasks that are due now and consumes them (once for "in"/"at"). */
|
package/src/core/state.mjs
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_ASSISTANT_NAME,
|
|
6
|
+
defaultAssistantId,
|
|
7
|
+
makeAssistant,
|
|
8
|
+
normalizeAssistantTable,
|
|
9
|
+
} from './assistants.mjs';
|
|
4
10
|
import { pactiveError } from './errors.mjs';
|
|
5
11
|
import { ensureDir, withLock } from './lock.mjs';
|
|
6
12
|
import { ledger } from './lineage.mjs';
|
|
7
13
|
|
|
8
|
-
|
|
14
|
+
/** Schema v2 adds the assistant fleet (contract §2 / I61–I66). */
|
|
15
|
+
export const SCHEMA_VERSION = 2;
|
|
16
|
+
export const LEGACY_SCHEMA_VERSION = 1;
|
|
17
|
+
export const GLOBAL_HOURLY_LIMIT_DEFAULT = 24;
|
|
9
18
|
|
|
10
19
|
let tmpSeq = 0;
|
|
11
20
|
|
|
21
|
+
export function defaultGuard() {
|
|
22
|
+
return { hourWindowStart: 0, hourCount: 0, lastTriggerAt: 0, lastTriggerEpoch: -1 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function defaultGlobalGuard() {
|
|
26
|
+
return { hourWindowStart: 0, hourCount: 0, hourlyLimit: GLOBAL_HOURLY_LIMIT_DEFAULT };
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
export function defaultState() {
|
|
13
30
|
return {
|
|
14
31
|
schemaVersion: SCHEMA_VERSION,
|
|
@@ -27,9 +44,14 @@ export function defaultState() {
|
|
|
27
44
|
minIntervalMs: 600000,
|
|
28
45
|
cooldownMs: 1800000,
|
|
29
46
|
},
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
guard:
|
|
47
|
+
// `guard` stays the guard bucket of the `default` assistant (the schema-v1 shape is
|
|
48
|
+
// exactly one assistant's counters, so no migration shim is needed for it).
|
|
49
|
+
guard: defaultGuard(),
|
|
50
|
+
// Every named assistant gets its own bucket: A's cooldown must not throttle B (I62).
|
|
51
|
+
guardByAssistant: {},
|
|
52
|
+
// One fleet-wide ceiling no amount of assistants can bypass (I64).
|
|
53
|
+
globalGuard: defaultGlobalGuard(),
|
|
54
|
+
assistants: {},
|
|
33
55
|
tasks: [],
|
|
34
56
|
peers: {},
|
|
35
57
|
outbox: [],
|
|
@@ -78,6 +100,42 @@ export function ensureStateDirs(paths) {
|
|
|
78
100
|
return paths;
|
|
79
101
|
}
|
|
80
102
|
|
|
103
|
+
function normalizeGuard(raw, fallback) {
|
|
104
|
+
const guard = { ...fallback, ...(raw && typeof raw === 'object' ? raw : {}) };
|
|
105
|
+
if (!Number.isFinite(guard.hourWindowStart)) guard.hourWindowStart = 0;
|
|
106
|
+
if (!Number.isFinite(guard.hourCount)) guard.hourCount = 0;
|
|
107
|
+
if (!Number.isFinite(guard.lastTriggerAt)) guard.lastTriggerAt = 0;
|
|
108
|
+
if (!Number.isFinite(guard.lastTriggerEpoch)) guard.lastTriggerEpoch = -1;
|
|
109
|
+
return guard;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeGuardTable(raw) {
|
|
113
|
+
const out = {};
|
|
114
|
+
if (!raw || typeof raw !== 'object') return out;
|
|
115
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
116
|
+
if (typeof key !== 'string' || key === '') continue;
|
|
117
|
+
out[key] = normalizeGuard(value, defaultGuard());
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeTasks(raw, fallbackAssistantId) {
|
|
123
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const task of list) {
|
|
126
|
+
// A non-object entry is dropped, but the caller can see the count change: the read path
|
|
127
|
+
// normalises rather than inventing fields on garbage.
|
|
128
|
+
if (!task || typeof task !== 'object') continue;
|
|
129
|
+
out.push({
|
|
130
|
+
...task,
|
|
131
|
+
assistantId: typeof task.assistantId === 'string' && task.assistantId
|
|
132
|
+
? task.assistantId
|
|
133
|
+
: fallbackAssistantId,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
81
139
|
function normalize(raw) {
|
|
82
140
|
const base = defaultState();
|
|
83
141
|
const state = { ...base, ...raw };
|
|
@@ -85,9 +143,13 @@ function normalize(raw) {
|
|
|
85
143
|
state.identity = { ...base.identity, ...(raw.identity || {}) };
|
|
86
144
|
state.idle = { ...base.idle, ...(raw.idle || {}) };
|
|
87
145
|
state.policy = { ...base.policy, ...(raw.policy || {}) };
|
|
88
|
-
state.guard =
|
|
89
|
-
|
|
90
|
-
state.
|
|
146
|
+
state.guard = normalizeGuard(raw.guard, base.guard);
|
|
147
|
+
state.guardByAssistant = normalizeGuardTable(raw.guardByAssistant);
|
|
148
|
+
state.globalGuard = { ...base.globalGuard, ...(raw.globalGuard || {}) };
|
|
149
|
+
if (!Number.isFinite(state.globalGuard.hourlyLimit)) state.globalGuard.hourlyLimit = GLOBAL_HOURLY_LIMIT_DEFAULT;
|
|
150
|
+
const ownerKey = typeof state.identity.ownerKey === 'string' ? state.identity.ownerKey : '';
|
|
151
|
+
state.assistants = normalizeAssistantTable(raw.assistants, ownerKey);
|
|
152
|
+
state.tasks = normalizeTasks(raw.tasks, defaultAssistantId(ownerKey));
|
|
91
153
|
state.peers = raw.peers && typeof raw.peers === 'object' ? raw.peers : {};
|
|
92
154
|
state.outbox = Array.isArray(raw.outbox) ? raw.outbox : [];
|
|
93
155
|
state.busy = raw.busy === true;
|
|
@@ -99,6 +161,57 @@ function normalize(raw) {
|
|
|
99
161
|
return state;
|
|
100
162
|
}
|
|
101
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Schema v1 → v2: one single-assistant state becomes the `default` assistant.
|
|
166
|
+
*
|
|
167
|
+
* Failure is explicit (I20): a v1 file whose tasks cannot be carried over must raise instead of
|
|
168
|
+
* being quarantined into an empty state, because "quarantine and start fresh" looks identical to
|
|
169
|
+
* "the user never had anything".
|
|
170
|
+
*/
|
|
171
|
+
export function migrateStateV1(raw, now = Date.now()) {
|
|
172
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
173
|
+
throw pactiveError('STATE_MIGRATION_FAILED', 'schema v1 state is not an object');
|
|
174
|
+
}
|
|
175
|
+
if (raw.tasks !== undefined && !Array.isArray(raw.tasks)) {
|
|
176
|
+
throw pactiveError('STATE_MIGRATION_FAILED', 'schema v1 state has a non-array tasks field; refusing to drop it');
|
|
177
|
+
}
|
|
178
|
+
const tasks = Array.isArray(raw.tasks) ? raw.tasks : [];
|
|
179
|
+
const ownerKey = typeof raw.identity?.ownerKey === 'string' ? raw.identity.ownerKey : '';
|
|
180
|
+
const id = defaultAssistantId(ownerKey);
|
|
181
|
+
const assistant = makeAssistant(
|
|
182
|
+
{
|
|
183
|
+
assistantId: id,
|
|
184
|
+
name: DEFAULT_ASSISTANT_NAME,
|
|
185
|
+
title: DEFAULT_ASSISTANT_NAME,
|
|
186
|
+
// I15: "installed" is not "enabled" — the migrated assistant inherits the old switch.
|
|
187
|
+
enabled: raw.idle?.enabled === true,
|
|
188
|
+
createdAt: Number.isFinite(raw.createdAt) ? raw.createdAt : now,
|
|
189
|
+
},
|
|
190
|
+
ownerKey,
|
|
191
|
+
now,
|
|
192
|
+
);
|
|
193
|
+
const migratedTasks = tasks.map((task) => {
|
|
194
|
+
if (!task || typeof task !== 'object') return task;
|
|
195
|
+
return { ...task, assistantId: id };
|
|
196
|
+
});
|
|
197
|
+
if (migratedTasks.length !== tasks.length) {
|
|
198
|
+
throw pactiveError('STATE_MIGRATION_FAILED', 'schema v1 tasks could not be carried over without loss');
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
...raw,
|
|
202
|
+
schemaVersion: SCHEMA_VERSION,
|
|
203
|
+
assistants: { [id]: assistant },
|
|
204
|
+
tasks: migratedTasks,
|
|
205
|
+
guardByAssistant: {},
|
|
206
|
+
globalGuard: defaultGlobalGuard(),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** True when the state file on disk still speaks schema v1 (the CLI reports it). */
|
|
211
|
+
export function needsMigration(parsed) {
|
|
212
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.schemaVersion === LEGACY_SCHEMA_VERSION;
|
|
213
|
+
}
|
|
214
|
+
|
|
102
215
|
export function loadState(target) {
|
|
103
216
|
const paths = toPaths(target);
|
|
104
217
|
let raw;
|
|
@@ -108,16 +221,25 @@ export function loadState(target) {
|
|
|
108
221
|
if (err.code === 'ENOENT') return defaultState();
|
|
109
222
|
throw err;
|
|
110
223
|
}
|
|
224
|
+
let parsed;
|
|
111
225
|
try {
|
|
112
|
-
|
|
113
|
-
if (!parsed || typeof parsed !== 'object' || parsed.schemaVersion !== SCHEMA_VERSION) {
|
|
114
|
-
throw pactiveError('STATE_SCHEMA_UNSUPPORTED', `unsupported schemaVersion in ${paths.stateFile}`);
|
|
115
|
-
}
|
|
116
|
-
return normalize(parsed);
|
|
226
|
+
parsed = JSON.parse(raw);
|
|
117
227
|
} catch (err) {
|
|
118
228
|
quarantine(paths, err);
|
|
119
229
|
return defaultState();
|
|
120
230
|
}
|
|
231
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
232
|
+
quarantine(paths, { code: 'STATE_SCHEMA_UNSUPPORTED' });
|
|
233
|
+
return defaultState();
|
|
234
|
+
}
|
|
235
|
+
if (parsed.schemaVersion === SCHEMA_VERSION) return normalize(parsed);
|
|
236
|
+
if (needsMigration(parsed)) {
|
|
237
|
+
// Migration happens on read (read-only callers such as `status`/`doctor` never write);
|
|
238
|
+
// the next write persists v2 through saveState.
|
|
239
|
+
return normalize(migrateStateV1(parsed));
|
|
240
|
+
}
|
|
241
|
+
quarantine(paths, { code: 'STATE_SCHEMA_UNSUPPORTED' });
|
|
242
|
+
return defaultState();
|
|
121
243
|
}
|
|
122
244
|
|
|
123
245
|
function quarantine(paths, cause) {
|