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/pi/extension.ts
CHANGED
|
@@ -194,12 +194,19 @@ function textResult(text: string, details?: Record<string, unknown>): PactiveToo
|
|
|
194
194
|
return details ? { content: [{ type: "text", text }], details } : { content: [{ type: "text", text }] };
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
const STATUS_PARAMETERS = {
|
|
197
|
+
const STATUS_PARAMETERS = {
|
|
198
|
+
type: "object",
|
|
199
|
+
properties: { assistant: { type: "string", description: "只读:助理名或 id;不传 = 全部助理概览" } },
|
|
200
|
+
additionalProperties: false,
|
|
201
|
+
};
|
|
202
|
+
const ASSISTANTS_PARAMETERS = { type: "object", properties: {}, additionalProperties: false };
|
|
203
|
+
const FLEET_PARAMETERS = { type: "object", properties: {}, additionalProperties: false };
|
|
198
204
|
const SCHEDULE_PARAMETERS = {
|
|
199
205
|
type: "object",
|
|
200
206
|
properties: {
|
|
201
207
|
prompt: { type: "string", description: "任务正文(到点注入给会话的内容)" },
|
|
202
208
|
when: { type: "string", description: "触发时间:时长(10m)/ 时刻(18:30)/ ISO 8601 / every 1h" },
|
|
209
|
+
assistant: { type: "string", description: "记在哪个助理名下(名字或 id);不传 = 默认助理" },
|
|
203
210
|
},
|
|
204
211
|
required: ["prompt"],
|
|
205
212
|
additionalProperties: false,
|
|
@@ -207,12 +214,62 @@ const SCHEDULE_PARAMETERS = {
|
|
|
207
214
|
const CONFIG_PARAMETERS = {
|
|
208
215
|
type: "object",
|
|
209
216
|
properties: {
|
|
210
|
-
key: { type: "string", description: "配置键;缺省返回全部(idle.enabled / idle.thresholdMs / policy
|
|
217
|
+
key: { type: "string", description: "配置键;缺省返回全部(idle.enabled / idle.thresholdMs / policy.* / globalGuard.hourlyLimit)" },
|
|
211
218
|
value: { type: "string", description: "想改成的新值;给了就只返回 `pactive config set <key> <value> --confirm`,不落盘" },
|
|
219
|
+
kind: { type: "string", description: "提议哪种写操作:assistant.add | assistant.remove | assistant.enable(只返回命令,不落盘)" },
|
|
220
|
+
name: { type: "string", description: "kind=assistant.* 时要操作的助理名或 id(缺 id 时用名字)" },
|
|
212
221
|
},
|
|
213
222
|
additionalProperties: false,
|
|
214
223
|
};
|
|
215
224
|
|
|
225
|
+
/** One assistant as the model needs to see it: switch, pending work, next action. */
|
|
226
|
+
function assistantView(core: any, state: any, record: any): Record<string, unknown> {
|
|
227
|
+
const all = core.schedule?.list ? core.schedule.list(state, record.assistantId) : [];
|
|
228
|
+
const active = (Array.isArray(all) ? all : []).filter((t: any) => t && t.active !== false);
|
|
229
|
+
const dueAt = active.map((t: any) => t.dueAt).filter((n: unknown) => Number.isFinite(n));
|
|
230
|
+
return {
|
|
231
|
+
assistantId: record.assistantId,
|
|
232
|
+
name: record.name,
|
|
233
|
+
title: record.title,
|
|
234
|
+
enabled: record.enabled === true,
|
|
235
|
+
role: record.role ?? "",
|
|
236
|
+
tasks: active.length,
|
|
237
|
+
nextDueAt: dueAt.length > 0 ? Math.min(...dueAt) : null,
|
|
238
|
+
lastFiredAt: record.lastFiredAt ?? 0,
|
|
239
|
+
firedCount: record.firedCount ?? 0,
|
|
240
|
+
policy: record.policy ?? {},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function assistantOverview(core: any, state: any): any[] {
|
|
245
|
+
const list = core.assistants?.list ? core.assistants.list(state) : [];
|
|
246
|
+
return list.map((record: any) => assistantView(core, state, record));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* I66: the model only *proposes* — every assistant write comes back as one copy-pasteable
|
|
251
|
+
* command whose `--confirm` a human has to type. Nothing here writes.
|
|
252
|
+
*/
|
|
253
|
+
function assistCommandFor(kind: string, name: string, value: string, fleet: any[]): { ok: boolean; command?: string; reason?: string } {
|
|
254
|
+
const known = fleet.map((a) => a.name);
|
|
255
|
+
if (kind === "assistant.add") {
|
|
256
|
+
if (name === "") return { ok: false, reason: "kind=assistant.add 需要 name" };
|
|
257
|
+
if (known.includes(name)) return { ok: false, reason: `助理已经存在(${name}),已有:${known.join(", ")}` };
|
|
258
|
+
return { ok: true, command: `pactive assistant add ${name} --confirm` };
|
|
259
|
+
}
|
|
260
|
+
if (kind === "assistant.remove" || kind === "assistant.enable") {
|
|
261
|
+
if (name === "") return { ok: false, reason: `kind=${kind} 需要 name` };
|
|
262
|
+
const hit = fleet.find((a) => a.name === name || a.assistantId === name);
|
|
263
|
+
if (!hit) return { ok: false, reason: `未知助理(${name});已有:${known.join(", ") || "(none yet)"}` };
|
|
264
|
+
if (kind === "assistant.enable") {
|
|
265
|
+
const on = value === "" ? !hit.enabled : value === "true" || value === "1" || value === "on";
|
|
266
|
+
return { ok: true, command: `pactive assistant ${on ? "enable" : "disable"} ${hit.name} --confirm` };
|
|
267
|
+
}
|
|
268
|
+
return { ok: true, command: `pactive assistant rm ${hit.name} --confirm` };
|
|
269
|
+
}
|
|
270
|
+
return { ok: false, reason: `未知 kind(${kind});支持 assistant.add / assistant.remove / assistant.enable` };
|
|
271
|
+
}
|
|
272
|
+
|
|
216
273
|
/** Mark an activity kind; never throws. */
|
|
217
274
|
async function markActivity(kind: "user" | "agent" | "session-start" | "session-end" | "tool"): Promise<void> {
|
|
218
275
|
try {
|
|
@@ -246,29 +303,59 @@ export default function activate(pi: PactiveExtensionAPI): void {
|
|
|
246
303
|
name: "pactive_status",
|
|
247
304
|
label: "Pactive Status",
|
|
248
305
|
description:
|
|
249
|
-
"
|
|
250
|
-
"→
|
|
306
|
+
"当用户问「我现在闲不闲」「主动提醒开启了吗」「为什么没触发」「刚才主动做了什么」「盯盘那个助理怎么样了」时用。" +
|
|
307
|
+
"→ 读空闲判定、reason、护栏闸门与任务数;给 assistant 看该助理,不给看全部助理概览。只读,不落盘。",
|
|
251
308
|
parameters: STATUS_PARAMETERS,
|
|
252
|
-
async execute() {
|
|
309
|
+
async execute(_toolCallId: string, params: Record<string, any>) {
|
|
253
310
|
try {
|
|
254
311
|
const core = await loadCore();
|
|
255
312
|
const now = Date.now();
|
|
256
313
|
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
257
314
|
const state = (await readState(core, paths)) ?? core.defaultState();
|
|
315
|
+
const globalGuard = state.globalGuard ?? null;
|
|
316
|
+
const activity = core.getActivity ? core.getActivity(state) : null;
|
|
317
|
+
const requested = typeof params?.assistant === "string" ? params.assistant.trim() : "";
|
|
318
|
+
if (requested !== "") {
|
|
319
|
+
const record = core.assistants?.resolve ? core.assistants.resolve(state, requested) : null;
|
|
320
|
+
if (!record) {
|
|
321
|
+
const names = assistantOverview(core, state).map((a) => a.name);
|
|
322
|
+
return textResult(
|
|
323
|
+
`pactive_status 失败:未知助理(${requested});已知:${names.join(", ") || "(none yet)"}`,
|
|
324
|
+
{ error: true },
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const idle = core.evaluateIdle
|
|
328
|
+
? core.evaluateIdle(state, now, {}, record.assistantId)
|
|
329
|
+
: { idle: false, reason: "requires-action" };
|
|
330
|
+
const guard = core.checkGuard
|
|
331
|
+
? core.checkGuard(state, now, record.assistantId)
|
|
332
|
+
: { pass: false, reason: "requires-action" };
|
|
333
|
+
return textResult(
|
|
334
|
+
JSON.stringify(
|
|
335
|
+
{ host: HOST_KIND, now, assistant: assistantView(core, state, record), idle, guard, globalGuard, activity },
|
|
336
|
+
null,
|
|
337
|
+
2,
|
|
338
|
+
),
|
|
339
|
+
{ host: HOST_KIND },
|
|
340
|
+
);
|
|
341
|
+
}
|
|
258
342
|
const idle = core.evaluateIdle
|
|
259
343
|
? core.evaluateIdle(state, now, state.policy ?? {})
|
|
260
344
|
: { idle: false, reason: "requires-action" };
|
|
261
345
|
const guard = core.checkGuard ? core.checkGuard(state, now) : { pass: false, reason: "requires-action" };
|
|
262
346
|
const tasks = core.schedule?.list ? core.schedule.list(state) : (state.tasks ?? []);
|
|
263
|
-
const
|
|
347
|
+
const fleet = assistantOverview(core, state);
|
|
264
348
|
return textResult(
|
|
265
349
|
JSON.stringify(
|
|
266
350
|
{
|
|
267
351
|
host: HOST_KIND,
|
|
268
352
|
now,
|
|
269
353
|
enabled: state.idle?.enabled ?? false,
|
|
354
|
+
assistants: fleet,
|
|
355
|
+
assistantCount: fleet.length,
|
|
270
356
|
idle,
|
|
271
357
|
guard,
|
|
358
|
+
globalGuard,
|
|
272
359
|
tasks: Array.isArray(tasks) ? tasks.length : 0,
|
|
273
360
|
activity,
|
|
274
361
|
},
|
|
@@ -283,12 +370,55 @@ export default function activate(pi: PactiveExtensionAPI): void {
|
|
|
283
370
|
},
|
|
284
371
|
});
|
|
285
372
|
|
|
373
|
+
pi.registerTool({
|
|
374
|
+
name: "pactive_assistants",
|
|
375
|
+
label: "Pactive Assistants",
|
|
376
|
+
description:
|
|
377
|
+
"当用户问「我有几个助理」「有哪些助理」「盯盘助理开着吗」「各自下次什么时候动作」时用。" +
|
|
378
|
+
"→ 列出全部助理的名称、开关、任务数、下次到期与触发次数。只读,不落盘。",
|
|
379
|
+
parameters: ASSISTANTS_PARAMETERS,
|
|
380
|
+
async execute() {
|
|
381
|
+
try {
|
|
382
|
+
const core = await loadCore();
|
|
383
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
384
|
+
const state = (await readState(core, paths)) ?? core.defaultState();
|
|
385
|
+
const fleet = assistantOverview(core, state);
|
|
386
|
+
return textResult(JSON.stringify({ ok: true, count: fleet.length, assistants: fleet }, null, 2), {
|
|
387
|
+
count: fleet.length,
|
|
388
|
+
});
|
|
389
|
+
} catch (err) {
|
|
390
|
+
return textResult(`pactive_assistants 失败:${errorText(err)}`, { error: true });
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
pi.registerTool({
|
|
396
|
+
name: "pactive_fleet",
|
|
397
|
+
label: "Pactive Fleet",
|
|
398
|
+
description:
|
|
399
|
+
"当用户问「我有几个助理在跑」「盯盘那个助理在哪个 pane」「助理的窗口还活着吗」「上次主动投递送达了吗」时用。" +
|
|
400
|
+
"→ 读助理 ↔ herdr pane 绑定、agent 状态与最近一次投递;pane 状态来自 herdr,未观测到就写 unobserved。只读,不落盘。",
|
|
401
|
+
parameters: FLEET_PARAMETERS,
|
|
402
|
+
async execute() {
|
|
403
|
+
try {
|
|
404
|
+
const core = await loadCore();
|
|
405
|
+
const fleetView = await import(new URL("../src/hosts/herdr/fleet-view.mjs", import.meta.url).href);
|
|
406
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
407
|
+
const state = (await readState(core, paths)) ?? core.defaultState();
|
|
408
|
+
const payload = fleetView.fleetPayload(core, state, paths, process.env);
|
|
409
|
+
return textResult(JSON.stringify(payload, null, 2), { count: payload.count });
|
|
410
|
+
} catch (err) {
|
|
411
|
+
return textResult(`pactive_fleet 失败:${errorText(err)}`, { error: true });
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
|
|
286
416
|
pi.registerTool({
|
|
287
417
|
name: "pactive_schedule",
|
|
288
418
|
label: "Pactive Schedule",
|
|
289
419
|
description:
|
|
290
420
|
"当用户说「10 分钟后提醒我喝水」「18:30 提醒我开会」「每小时检查一次」时用。" +
|
|
291
|
-
"→ 登记一条定时任务并立即落盘;when 支持 10m / 18:30 / ISO 8601 / every 1h
|
|
421
|
+
"→ 登记一条定时任务并立即落盘;when 支持 10m / 18:30 / ISO 8601 / every 1h;assistant 不填就记在默认助理名下。" +
|
|
292
422
|
"会写盘,需要人工确认(只在用户明确要求时调用)。",
|
|
293
423
|
parameters: SCHEDULE_PARAMETERS,
|
|
294
424
|
async execute(_toolCallId: string, params: Record<string, any>) {
|
|
@@ -302,22 +432,40 @@ export default function activate(pi: PactiveExtensionAPI): void {
|
|
|
302
432
|
}
|
|
303
433
|
const core = await loadCore();
|
|
304
434
|
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
435
|
+
const requested = typeof params?.assistant === "string" ? params.assistant.trim() : "";
|
|
436
|
+
let assistantId: string | null = null;
|
|
437
|
+
if (requested !== "") {
|
|
438
|
+
const before = (await readState(core, paths)) ?? core.defaultState();
|
|
439
|
+
const record = core.assistants?.resolve ? core.assistants.resolve(before, requested) : null;
|
|
440
|
+
if (!record) {
|
|
441
|
+
const names = assistantOverview(core, before).map((a) => a.name);
|
|
442
|
+
return textResult(
|
|
443
|
+
`pactive_schedule 失败:未知助理(${requested});已知:${names.join(", ") || "(none yet)"}`,
|
|
444
|
+
{ error: true },
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
assistantId = record.assistantId;
|
|
448
|
+
}
|
|
305
449
|
const next = await mutateState(core, paths, (state) => {
|
|
450
|
+
const targetId =
|
|
451
|
+
assistantId ??
|
|
452
|
+
(core.assistants?.ensureDefault ? core.assistants.ensureDefault(state).assistantId : null);
|
|
306
453
|
const out = core.schedule.add(
|
|
307
454
|
state,
|
|
308
|
-
{ ...spec, prompt, content: prompt, owner: state.identity?.agentId },
|
|
455
|
+
{ ...spec, prompt, content: prompt, owner: state.identity?.agentId, assistantId: targetId },
|
|
309
456
|
now,
|
|
310
457
|
);
|
|
311
458
|
if (out && typeof out === "object" && out.state) return out.state;
|
|
312
459
|
return out && Array.isArray(out.tasks) ? out : state;
|
|
313
460
|
});
|
|
314
|
-
const tasks = core.schedule?.list ? core.schedule.list(next) : (next.tasks ?? []);
|
|
461
|
+
const tasks = core.schedule?.list ? core.schedule.list(next, assistantId ?? undefined) : (next.tasks ?? []);
|
|
315
462
|
const created = Array.isArray(tasks) ? tasks[tasks.length - 1] : null;
|
|
316
463
|
return textResult(
|
|
317
464
|
JSON.stringify(
|
|
318
465
|
{
|
|
319
466
|
ok: true,
|
|
320
467
|
taskId: created?.id ?? created?.taskId ?? null,
|
|
468
|
+
assistantId: created?.assistantId ?? assistantId,
|
|
321
469
|
dueAt: created?.dueAt ?? spec.at ?? null,
|
|
322
470
|
kind: spec.kind,
|
|
323
471
|
when: spec.when,
|
|
@@ -338,22 +486,47 @@ export default function activate(pi: PactiveExtensionAPI): void {
|
|
|
338
486
|
name: "pactive_config",
|
|
339
487
|
label: "Pactive Config",
|
|
340
488
|
description:
|
|
341
|
-
"
|
|
342
|
-
"→
|
|
489
|
+
"当用户问「主动提醒的开关开着吗」「阈值是多少」「安静时间是几点」「帮我把主动提醒打开」「再给我加一个盯盘的助理」时用。" +
|
|
490
|
+
"→ 读配置值与默认值;要改配置或增删 / 开关助理时,只返回一条需要人工确认的可复制命令。只读,不落盘。",
|
|
343
491
|
parameters: CONFIG_PARAMETERS,
|
|
344
492
|
async execute(_toolCallId: string, params: Record<string, any>) {
|
|
345
493
|
try {
|
|
346
|
-
// Read-only by construction: a write request returns the command, never a write (contract §2.3).
|
|
494
|
+
// Read-only by construction: a write request returns the command, never a write (contract §2.3 / I66).
|
|
347
495
|
const core = await loadCore();
|
|
348
496
|
const described = core.config?.describe ? core.config.describe() : { schema: {}, defaults: {} };
|
|
349
497
|
const known: string[] = Object.keys(described.defaults);
|
|
350
498
|
const key = typeof params?.key === "string" ? params.key.trim() : "";
|
|
351
499
|
const rawValue = params?.value === undefined || params?.value === null ? "" : String(params.value).trim();
|
|
500
|
+
const kind = typeof params?.kind === "string" ? params.kind.trim() : "";
|
|
501
|
+
const name = typeof params?.name === "string" ? params.name.trim() : "";
|
|
502
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
503
|
+
const state = (await readState(core, paths)) ?? core.defaultState();
|
|
504
|
+
const fleet = assistantOverview(core, state);
|
|
505
|
+
|
|
506
|
+
if (kind !== "") {
|
|
507
|
+
const command = assistCommandFor(kind, name, rawValue, fleet);
|
|
508
|
+
if (!command.ok) return textResult(`pactive_config 失败:${command.reason}`, { error: true });
|
|
509
|
+
return textResult(
|
|
510
|
+
JSON.stringify(
|
|
511
|
+
{
|
|
512
|
+
ok: false,
|
|
513
|
+
wrote: false,
|
|
514
|
+
reason: "need_human_confirmation",
|
|
515
|
+
command: command.command,
|
|
516
|
+
kind,
|
|
517
|
+
before: { assistants: fleet.length, names: fleet.map((a) => a.name) },
|
|
518
|
+
hint: "不要自己改状态文件;把 command 原样给用户,等他执行后下一轮用 pactive_assistants 复核。",
|
|
519
|
+
},
|
|
520
|
+
null,
|
|
521
|
+
2,
|
|
522
|
+
),
|
|
523
|
+
{ needHumanConfirmation: true },
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
352
527
|
if (key !== "" && !known.includes(key)) {
|
|
353
528
|
return textResult(`pactive_config 失败:未知配置键(${key});已知:${known.join(", ")}`, { error: true });
|
|
354
529
|
}
|
|
355
|
-
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
356
|
-
const state = (await readState(core, paths)) ?? core.defaultState();
|
|
357
530
|
const values = (key === "" ? known : [key]).map((k: string) => ({
|
|
358
531
|
key: k,
|
|
359
532
|
value: core.config?.get ? core.config.get(state, k) : null,
|
|
@@ -361,7 +534,10 @@ export default function activate(pi: PactiveExtensionAPI): void {
|
|
|
361
534
|
humanGated: described.schema?.[k]?.humanGated === true,
|
|
362
535
|
}));
|
|
363
536
|
if (rawValue === "") {
|
|
364
|
-
return textResult(
|
|
537
|
+
return textResult(
|
|
538
|
+
JSON.stringify({ ok: true, readOnly: true, wrote: false, values, assistants: fleet.length }, null, 2),
|
|
539
|
+
{ readOnly: true },
|
|
540
|
+
);
|
|
365
541
|
}
|
|
366
542
|
return textResult(
|
|
367
543
|
JSON.stringify(
|
|
@@ -23,6 +23,9 @@ description: >-
|
|
|
23
23
|
| 「把那个定时任务取消」 | **不写**:给出 `pactive cancel <taskId> --confirm`,让用户自己执行 |
|
|
24
24
|
| 「刚才主动提醒都发生了什么?」 | 调 `pactive_history`,只报可核验的事实(时间戳 + 事件种类) |
|
|
25
25
|
| 「告诉另一个会话我有空」 | 调 `pactive_tell` `{to, body}`;回执是 `queued` 就照实说**还没送到** |
|
|
26
|
+
| 「我有几个助理?」「盯盘助理开着吗?」 | 调 `pactive_assistants`(只读);要单个详情调 `pactive_status` `{assistant}` |
|
|
27
|
+
| 「再给我加一个盯盘的助理」「把巡检关掉」 | **不写**:调 `pactive_config` `{kind:"assistant.add"\|"assistant.remove"\|"assistant.enable", name}` 拿命令 |
|
|
28
|
+
| 「10 分钟后提醒我看盘片」并且有多个助理 | 调 `pactive_schedule` `{prompt, when, assistant}`,把任务记到指定助理名下 |
|
|
26
29
|
|
|
27
30
|
## 写请求的三步(硬规则)
|
|
28
31
|
|
|
@@ -45,6 +48,11 @@ description: >-
|
|
|
45
48
|
- **四闸门缺一不可**:控制权已交回 + 不忙 + 超过阈值 + 护栏通过,才可能触发。
|
|
46
49
|
- **`queued` ≠ 已送达**:对端离线时消息进 outbox,等对端上线再补投;回执写 `queued` 就是还没送到。
|
|
47
50
|
- **配置是人工闸门**:只有人能加 `--confirm`;你给命令,他执行。
|
|
51
|
+
- **助理是一等对象**(I61–I66):`assistantId` 形如 `盯盘#<短哈希>`;建/删/开关都走 `pactive assistant … --confirm`;
|
|
52
|
+
新建助理默认 `enabled=false`(装上不等于打扰);删助理会连带删掉它的任务,**不留孤儿**;重命名不改 id。
|
|
53
|
+
- **按助理隔离 + 全局闸门**:每个助理各有一份计数/冷却,A 的冷却压不住 B;但全局每小时总投递有上限(默认 24),
|
|
54
|
+
超限时每个助理都记 `reason=global-limit`,不会静默丢。
|
|
55
|
+
- **`tick` 是最小驱动**:`pactive tick` 跑一轮「到期任务 + 空闲判定」,可投递项落 ledger(`delivered` / `blocked`+reason)。
|
|
48
56
|
- **Stop hook 最多阻断一次**:同一轮里只阻断一次,避免死循环;`UserPromptSubmit` 会重置这个计数。
|
|
49
57
|
|
|
50
58
|
## `when` 语法
|
|
@@ -64,7 +72,13 @@ ISO 8601 时间戳(绝对时刻)、`every 1h`(周期)。缺省 `10m`。
|
|
|
64
72
|
```
|
|
65
73
|
pactive config get [<key>] # 只读:当前值与默认值
|
|
66
74
|
pactive config set <key> <value> --confirm # 写;缺 --confirm → exit 2 且零写盘
|
|
67
|
-
pactive schedule "<prompt>" --when <when> [--confirm]
|
|
68
|
-
pactive tasks
|
|
75
|
+
pactive schedule "<prompt>" --when <when> [--assistant <名字>] [--confirm]
|
|
76
|
+
pactive tasks [--assistant <名字>] # id / 类型 / 下次到期 / 归属
|
|
69
77
|
pactive cancel <taskId> [--confirm]
|
|
78
|
+
pactive tick [--assistant <名字>] # 跑一轮:到期任务 + 空闲判定 → ledger
|
|
79
|
+
pactive assistant add <name> [--title …] [--role …] [--scope <glob>] --confirm
|
|
80
|
+
pactive assistant ls | show <name|id> # 只读
|
|
81
|
+
pactive assistant enable|disable <name|id> --confirm
|
|
82
|
+
pactive assistant rename <name|id> <newName> --confirm
|
|
83
|
+
pactive assistant rm <name|id> --confirm # 连带删它的任务
|
|
70
84
|
```
|
package/skills/pactive/SKILL.md
CHANGED
|
@@ -23,6 +23,9 @@ description: >-
|
|
|
23
23
|
| 「把那个定时任务取消」 | **不写**:给出 `pactive cancel <taskId> --confirm`,让用户自己执行 |
|
|
24
24
|
| 「刚才主动提醒都发生了什么?」 | 调 `pactive_history`,只报可核验的事实(时间戳 + 事件种类) |
|
|
25
25
|
| 「告诉另一个会话我有空」 | 调 `pactive_tell` `{to, body}`;回执是 `queued` 就照实说**还没送到** |
|
|
26
|
+
| 「我有几个助理?」「盯盘助理开着吗?」 | 调 `pactive_assistants`(只读);要单个详情调 `pactive_status` `{assistant}` |
|
|
27
|
+
| 「再给我加一个盯盘的助理」「把巡检关掉」 | **不写**:调 `pactive_config` `{kind:"assistant.add"\|"assistant.remove"\|"assistant.enable", name}` 拿命令 |
|
|
28
|
+
| 「10 分钟后提醒我看盘片」并且有多个助理 | 调 `pactive_schedule` `{prompt, when, assistant}`,把任务记到指定助理名下 |
|
|
26
29
|
|
|
27
30
|
## 写请求的三步(硬规则)
|
|
28
31
|
|
|
@@ -45,6 +48,11 @@ description: >-
|
|
|
45
48
|
- **四闸门缺一不可**:控制权已交回 + 不忙 + 超过阈值 + 护栏通过,才可能触发。
|
|
46
49
|
- **`queued` ≠ 已送达**:对端离线时消息进 outbox,等对端上线再补投;回执写 `queued` 就是还没送到。
|
|
47
50
|
- **配置是人工闸门**:只有人能加 `--confirm`;你给命令,他执行。
|
|
51
|
+
- **助理是一等对象**(I61–I66):`assistantId` 形如 `盯盘#<短哈希>`;建/删/开关都走 `pactive assistant … --confirm`;
|
|
52
|
+
新建助理默认 `enabled=false`(装上不等于打扰);删助理会连带删掉它的任务,**不留孤儿**;重命名不改 id。
|
|
53
|
+
- **按助理隔离 + 全局闸门**:每个助理各有一份计数/冷却,A 的冷却压不住 B;但全局每小时总投递有上限(默认 24),
|
|
54
|
+
超限时每个助理都记 `reason=global-limit`,不会静默丢。
|
|
55
|
+
- **`tick` 是最小驱动**:`pactive tick` 跑一轮「到期任务 + 空闲判定」,可投递项落 ledger(`delivered` / `blocked`+reason)。
|
|
48
56
|
- **Stop hook 最多阻断一次**:同一轮里只阻断一次,避免死循环;`UserPromptSubmit` 会重置这个计数。
|
|
49
57
|
|
|
50
58
|
## `when` 语法
|
|
@@ -64,7 +72,13 @@ ISO 8601 时间戳(绝对时刻)、`every 1h`(周期)。缺省 `10m`。
|
|
|
64
72
|
```
|
|
65
73
|
pactive config get [<key>] # 只读:当前值与默认值
|
|
66
74
|
pactive config set <key> <value> --confirm # 写;缺 --confirm → exit 2 且零写盘
|
|
67
|
-
pactive schedule "<prompt>" --when <when> [--confirm]
|
|
68
|
-
pactive tasks
|
|
75
|
+
pactive schedule "<prompt>" --when <when> [--assistant <名字>] [--confirm]
|
|
76
|
+
pactive tasks [--assistant <名字>] # id / 类型 / 下次到期 / 归属
|
|
69
77
|
pactive cancel <taskId> [--confirm]
|
|
78
|
+
pactive tick [--assistant <名字>] # 跑一轮:到期任务 + 空闲判定 → ledger
|
|
79
|
+
pactive assistant add <name> [--title …] [--role …] [--scope <glob>] --confirm
|
|
80
|
+
pactive assistant ls | show <name|id> # 只读
|
|
81
|
+
pactive assistant enable|disable <name|id> --confirm
|
|
82
|
+
pactive assistant rename <name|id> <newName> --confirm
|
|
83
|
+
pactive assistant rm <name|id> --confirm # 连带删它的任务
|
|
70
84
|
```
|
|
@@ -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
|
+
};
|