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.
@@ -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
- get(state, key) {
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
- /** Gated keys only change with an explicit confirmation; without one nothing is written. */
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
+ };
@@ -1,5 +1,8 @@
1
- export const GUARD_REASONS = ['quiet', 'hourly-limit', 'min-interval', 'cooldown'];
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
- /** The four gates: quiet window, hourly limit, minimum interval, cooldown. */
28
- export function checkGuard(state, now = Date.now()) {
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 policy = state.policy || {};
31
- const guard = state.guard || {};
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
- const windowExpired = windowStart > 0 && ts - windowStart >= HOUR_WINDOW_MS;
43
- if (!windowExpired && count >= limit) {
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
- return { pass: true, reason: 'idle' };
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
- if (!state.guard || typeof state.guard !== 'object') {
70
- state.guard = { hourWindowStart: 0, hourCount: 0, lastTriggerAt: 0, lastTriggerEpoch: -1 };
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
- const windowStart = Number.isFinite(guard.hourWindowStart) ? guard.hourWindowStart : 0;
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
- return { reserved: true, epoch };
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
- const enabled = firstDefined(p.enabled, p.idleEnabled, idleCfg.enabled, false);
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.policy || {}) } };
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
-
@@ -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';
@@ -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
- list(state) {
72
- return Array.isArray(state.tasks) ? [...state.tasks] : [];
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"). */
@@ -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
- export const SCHEMA_VERSION = 1;
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
- // lastTriggerEpoch is the extra field the frozen shape does not carry: the
31
- // "one trigger per activity epoch" rule needs the epoch of the last trigger.
32
- guard: { hourWindowStart: 0, hourCount: 0, lastTriggerAt: 0, lastTriggerEpoch: -1 },
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 = { ...base.guard, ...(raw.guard || {}) };
89
- if (!Number.isFinite(state.guard.lastTriggerEpoch)) state.guard.lastTriggerEpoch = -1;
90
- state.tasks = Array.isArray(raw.tasks) ? raw.tasks : [];
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
- const parsed = JSON.parse(raw);
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) {