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.
@@ -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'];
@@ -28,26 +29,34 @@ export const schedule = {
28
29
  if (everyMs <= 0) throw pactiveError('TASK_INTERVAL_REQUIRED', 'an "every" task needs a positive interval');
29
30
  dueAt = ts + everyMs;
30
31
  } else {
32
+ // A relative delay may arrive either as a duration (delayMs/afterMs/ms) or already
33
+ // resolved to an absolute time (spec.at — what every host-surface parseWhen produces).
31
34
  const delay = Number.isFinite(spec.delayMs) ? spec.delayMs
32
35
  : Number.isFinite(spec.afterMs) ? spec.afterMs
33
- : Number.isFinite(spec.ms) ? spec.ms : 0;
34
- dueAt = ts + delay;
36
+ : Number.isFinite(spec.ms) ? spec.ms : null;
37
+ dueAt = delay === null && Number.isFinite(spec.at) ? spec.at : ts + (delay ?? 0);
35
38
  }
36
39
 
37
40
  const owner = spec.owner ?? state.identity?.ownerKey ?? '';
38
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 ?? '');
39
45
 
40
46
  const existing = state.tasks.find((t) => t.id === id && t.owner === owner && t.dueAt === dueAt && t.active !== false);
41
47
  if (existing) return existing;
42
48
 
43
49
  const task = {
44
50
  id,
51
+ assistantId,
45
52
  kind,
46
53
  dueAt,
47
54
  owner,
48
55
  everyMs,
49
56
  label: spec.label ?? null,
50
57
  payload: spec.payload ?? null,
58
+ // The prompt is what the trigger injects later; dropping it registers an empty reminder.
59
+ prompt: spec.prompt ?? spec.content ?? null,
51
60
  createdAt: ts,
52
61
  lastRunAt: 0,
53
62
  active: true,
@@ -64,8 +73,11 @@ export const schedule = {
64
73
  return state.tasks.length !== before;
65
74
  },
66
75
 
67
- list(state) {
68
- 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);
69
81
  },
70
82
 
71
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) {
@@ -0,0 +1,128 @@
1
+ import { defaultAssistantId } from './assistants.mjs';
2
+ import { reserveTrigger } from './guard.mjs';
3
+ import { evaluateIdle } from './idle.mjs';
4
+ import { ledger } from './lineage.mjs';
5
+
6
+ /** Reasons that mean "this really was going to fire but the throttle held it back". */
7
+ export const THROTTLE_REASONS = ['quiet', 'hourly-limit', 'global-limit', 'min-interval', 'cooldown'];
8
+
9
+ /**
10
+ * One drive round: due tasks + one idle sweep per enabled assistant,结果落 ledger。
11
+ *
12
+ * This is the minimal closed loop before a daemon exists: `pactive tick` runs it once, so the
13
+ * fleet is testable unattended. A blocked task stays active (retried next tick) — losing a
14
+ * reminder because the guard was closed would be silent data loss.
15
+ *
16
+ * ledger events (append-only, per assistantId): `triggered` + `delivered`, or `blocked`+reason.
17
+ */
18
+ export function runTick(state, now = Date.now(), options = {}) {
19
+ const ts = Number.isFinite(now) ? now : Date.now();
20
+ const ledgerFile = typeof options.ledgerFile === 'string' && options.ledgerFile ? options.ledgerFile : null;
21
+ const only = typeof options.assistantId === 'string' && options.assistantId !== '' ? options.assistantId : null;
22
+ const record = (event) => {
23
+ if (ledgerFile) ledger.append(ledgerFile, event);
24
+ return event;
25
+ };
26
+
27
+ const delivered = [];
28
+ const blocked = [];
29
+ const fallbackId = defaultAssistantId(state.identity?.ownerKey ?? '');
30
+ const table = state.assistants && typeof state.assistants === 'object' ? state.assistants : {};
31
+ const tasks = Array.isArray(state.tasks) ? state.tasks : [];
32
+
33
+ const queued = [];
34
+ const unreachable = [];
35
+ /**
36
+ * Deliver one prompt and let the receipt decide the ledger event (contract §2.2):
37
+ * a bound pane that only *admitted* the text is `queued`, never `delivered` (I77).
38
+ */
39
+ const deliverOne = (base) => {
40
+ const assistant = table[base.assistantId] ?? null;
41
+ const pane = assistant?.pane ?? null;
42
+ if (pane && typeof options.paneDeliver === 'function') {
43
+ const receipt = options.paneDeliver({ ...base, pane }) ?? { state: 'queued', reason: 'no-receipt' };
44
+ const common = { assistantId: base.assistantId, paneId: pane.paneId, agentName: pane.agentName ?? null, ...base };
45
+ if (receipt.state === 'accepted') {
46
+ return delivered.push(record({
47
+ event: 'delivered',
48
+ ...common,
49
+ surface: 'herdr',
50
+ status: receipt.status ?? null,
51
+ }));
52
+ }
53
+ if (receipt.state === 'unreachable') {
54
+ // The pane is gone: drop the binding so the next `fleet up` can rebuild it. The
55
+ // assistant, its tasks and its history stay (I75).
56
+ assistant.pane = null;
57
+ return unreachable.push(record({ event: 'unreachable', ...common, reason: receipt.reason ?? 'unreachable' }));
58
+ }
59
+ return queued.push(record({ event: 'queued', ...common, reason: receipt.reason ?? 'queued' }));
60
+ }
61
+ // No pane: the in-process/outbox path (unchanged), but `paneId` is always present.
62
+ return delivered.push(record({ event: 'delivered', paneId: null, ...base }));
63
+ };
64
+
65
+ // ① Due tasks (explicit user reminders): min-interval/cooldown do not apply, the user asked.
66
+ for (const task of tasks) {
67
+ if (!task || typeof task !== 'object' || task.active === false) continue;
68
+ if (!Number.isFinite(task.dueAt) || task.dueAt > ts) continue;
69
+ const assistantId = typeof task.assistantId === 'string' && task.assistantId ? task.assistantId : fallbackId;
70
+ if (only && assistantId !== only) continue;
71
+ const res = reserveTrigger(state, ts, assistantId, { ignoreEpoch: true, skipIntervalGates: true });
72
+ if (res.reserved) {
73
+ task.lastRunAt = ts;
74
+ if (task.kind === 'every') task.dueAt = ts + (Number.isFinite(task.everyMs) ? task.everyMs : 0);
75
+ else task.active = false;
76
+ const assistant = table[assistantId];
77
+ if (assistant) {
78
+ assistant.lastFiredAt = ts;
79
+ assistant.firedCount = (Number.isFinite(assistant.firedCount) ? assistant.firedCount : 0) + 1;
80
+ }
81
+ record({ event: 'triggered', assistantId, taskId: task.id, taskKind: task.kind, source: 'tick' });
82
+ deliverOne({
83
+ assistantId,
84
+ taskId: task.id,
85
+ taskKind: task.kind,
86
+ source: 'tick',
87
+ prompt: task.prompt ?? null,
88
+ });
89
+ } else {
90
+ blocked.push(record({ event: 'blocked', assistantId, taskId: task.id, source: 'tick', reason: res.reason }));
91
+ }
92
+ }
93
+
94
+ // ② Idle sweep: only enabled assistants, only throttle reasons are recorded (no ledger spam).
95
+ for (const [assistantId, assistant] of Object.entries(table)) {
96
+ if (!assistant || assistant.enabled !== true) continue;
97
+ if (only && assistantId !== only) continue;
98
+ const result = evaluateIdle(state, ts, {}, assistantId);
99
+ if (result.idle) {
100
+ const res = reserveTrigger(state, ts, assistantId);
101
+ if (res.reserved) {
102
+ assistant.lastFiredAt = ts;
103
+ assistant.firedCount = (Number.isFinite(assistant.firedCount) ? assistant.firedCount : 0) + 1;
104
+ record({ event: 'triggered', assistantId, source: 'tick', reason: 'idle' });
105
+ deliverOne({ assistantId, source: 'tick', reason: 'idle' });
106
+ } else {
107
+ blocked.push(record({ event: 'blocked', assistantId, source: 'tick', reason: res.reason }));
108
+ }
109
+ } else if (THROTTLE_REASONS.includes(result.reason)) {
110
+ blocked.push(record({ event: 'blocked', assistantId, source: 'tick', reason: result.reason }));
111
+ }
112
+ }
113
+
114
+ return {
115
+ now: ts,
116
+ assistantId: only,
117
+ assistants: Object.keys(table).length,
118
+ delivered: delivered.length,
119
+ blocked: blocked.length,
120
+ queued: queued.length,
121
+ unreachable: unreachable.length,
122
+ deliveries: delivered,
123
+ queued_events: queued,
124
+ unreachable_events: unreachable,
125
+ blocks: blocked,
126
+ pending: tasks.filter((t) => t && t.active !== false).length,
127
+ };
128
+ }
@@ -0,0 +1,31 @@
1
+ // The fleet view shared by every tool surface (MCP / Pi / DSH) and the CLI.
2
+ //
3
+ // It is a thin join: the *binding* comes from pactive state (I76), the *status* comes from herdr
4
+ // (`agent list`). When herdr cannot be asked, the status is `null` → rendered as "unobserved";
5
+ // it is never guessed (I78).
6
+ import * as herdr from './index.mjs';
7
+
8
+ /** herdr `agent list` → `{available, ok, reason, map: agentName → agent_status}`. */
9
+ export function herdrStatus(env = process.env) {
10
+ const probe = herdr.available({ env });
11
+ const map = new Map();
12
+ if (!probe.ok) return { available: false, ok: false, reason: probe.reason, map };
13
+ const listed = herdr.list({ env });
14
+ if (listed.ok) for (const agent of listed.agents) if (agent.agentName) map.set(agent.agentName, agent.status);
15
+ return { available: true, ok: listed.ok, reason: listed.ok ? null : listed.reason, map };
16
+ }
17
+
18
+ /** `{ok, count, herdr, rows}` — one row per assistant: identity ↔ pane ↔ status ↔ last delivery. */
19
+ export function fleetPayload(core, state, paths, env = process.env) {
20
+ const status = herdrStatus(env);
21
+ const rows = core.fleet.view(state, {
22
+ ledgerFile: paths.ledgerFile,
23
+ statusOf: (pane) => (pane.agentName ? status.map.get(pane.agentName) ?? null : null),
24
+ });
25
+ return {
26
+ ok: true,
27
+ count: rows.length,
28
+ herdr: { available: status.available, reason: status.reason },
29
+ rows,
30
+ };
31
+ }