pactive 0.0.1-alpha.1 → 0.0.3-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 +4 -4
- package/BREAKING-CHANGES.md +23 -7
- package/README.md +57 -49
- package/agents/{acti-observer.md → pactive-observer.md} +4 -4
- package/bin/pactive.mjs +720 -0
- package/commands/pactive.md +49 -0
- package/dsh/cordis.patch.yml +4 -4
- package/dsh/index.js +77 -25
- package/hooks/post-tool-use.mjs +1 -1
- package/hooks/session-end.mjs +1 -1
- package/hooks/session-start.mjs +1 -1
- package/hooks/stop.mjs +2 -2
- package/hooks/user-prompt-submit.mjs +2 -2
- package/mcp/server.mjs +4 -4
- package/mcp/tools/_core.mjs +6 -6
- package/mcp/tools/definitions.mjs +65 -14
- package/package.json +4 -5
- package/pi/extension.ts +98 -36
- package/pi/skills/pactive/SKILL.md +70 -0
- package/skills/pactive/SKILL.md +70 -0
- package/src/core/config.mjs +2 -2
- package/src/core/errors.mjs +1 -1
- package/src/core/identity.mjs +2 -2
- package/src/core/lineage.mjs +2 -2
- package/src/core/lock.mjs +2 -2
- package/src/core/paths.mjs +6 -6
- package/src/core/scheduler.mjs +11 -7
- package/src/core/state.mjs +7 -7
- package/src/hosts/dsh/index.mjs +12 -12
- package/src/hosts/errors.mjs +5 -5
- package/src/hosts/pi/index.mjs +1 -1
- package/src/hosts/shared/index.mjs +3 -3
- package/bin/acti.mjs +0 -439
- package/commands/acti.md +0 -46
- package/pi/skills/acti/SKILL.md +0 -47
- package/skills/acti/SKILL.md +0 -47
package/pi/extension.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Pactive — Pi extension (the single shipped entry (Pi loads this .ts directly via jiti; there is no dist copy)).
|
|
3
3
|
*
|
|
4
4
|
* Surface:
|
|
5
|
-
* - tools: `
|
|
6
|
-
*
|
|
5
|
+
* - tools: `pactive_status`, `pactive_schedule`, `pactive_config` (read-only: proposes a
|
|
6
|
+
* `pactive config set … --confirm` command instead of writing — contract §2.3 / I59)
|
|
7
|
+
* - command: `/pactive status|tasks|schedule <when> <prompt>|help`
|
|
7
8
|
* - events: session_start / input / agent_start / tool_execution_start /
|
|
8
9
|
* message_end / agent_settled / session_shutdown → activity + control tracking
|
|
9
10
|
*
|
|
@@ -21,12 +22,12 @@
|
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
/** Minimal mirror of the host API (kept local so the extension stays dependency-free). */
|
|
24
|
-
export interface
|
|
25
|
+
export interface PactiveToolResult {
|
|
25
26
|
content: Array<{ type: "text"; text: string }>;
|
|
26
27
|
details?: Record<string, unknown>;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export interface
|
|
30
|
+
export interface PactiveToolDefinition {
|
|
30
31
|
name: string;
|
|
31
32
|
label?: string;
|
|
32
33
|
description: string;
|
|
@@ -37,22 +38,22 @@ export interface ActiToolDefinition {
|
|
|
37
38
|
signal?: AbortSignal,
|
|
38
39
|
onUpdate?: (update: unknown) => void,
|
|
39
40
|
ctx?: unknown,
|
|
40
|
-
): Promise<
|
|
41
|
+
): Promise<PactiveToolResult>;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
export interface
|
|
44
|
-
registerTool(tool:
|
|
44
|
+
export interface PactiveExtensionAPI {
|
|
45
|
+
registerTool(tool: PactiveToolDefinition): void;
|
|
45
46
|
registerCommand(
|
|
46
47
|
name: string,
|
|
47
48
|
definition: {
|
|
48
49
|
description: string;
|
|
49
|
-
handler(args: string, ctx:
|
|
50
|
+
handler(args: string, ctx: PactiveUiContext): Promise<void> | void;
|
|
50
51
|
},
|
|
51
52
|
): void;
|
|
52
53
|
on(event: string, handler: (event: any, ctx?: any) => void): void;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
export interface
|
|
56
|
+
export interface PactiveUiContext {
|
|
56
57
|
ui?: { notify?: (message: string, level?: string) => void };
|
|
57
58
|
sessionManager?: { getSessionId?: () => string | undefined };
|
|
58
59
|
cwd?: string;
|
|
@@ -73,7 +74,7 @@ function errorText(err: unknown): string {
|
|
|
73
74
|
async function loadCore(): Promise<any> {
|
|
74
75
|
if (cachedCore) return cachedCore;
|
|
75
76
|
const env = process.env;
|
|
76
|
-
const explicit = env.
|
|
77
|
+
const explicit = env.PACTIVE_CORE_MODULE;
|
|
77
78
|
const candidates = explicit ? [explicit, ...CORE_CANDIDATES] : CORE_CANDIDATES;
|
|
78
79
|
const tried: string[] = [];
|
|
79
80
|
for (const candidate of candidates) {
|
|
@@ -92,7 +93,7 @@ async function loadCore(): Promise<any> {
|
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
throw new Error(
|
|
95
|
-
`
|
|
96
|
+
`pactive: core module not found (tried: ${tried.join(", ")}). Build src/core before loading the extension.`,
|
|
96
97
|
);
|
|
97
98
|
}
|
|
98
99
|
|
|
@@ -189,7 +190,7 @@ function parseWhen(when: unknown, now = Date.now()): { kind: string; when: strin
|
|
|
189
190
|
return null;
|
|
190
191
|
}
|
|
191
192
|
|
|
192
|
-
function textResult(text: string, details?: Record<string, unknown>):
|
|
193
|
+
function textResult(text: string, details?: Record<string, unknown>): PactiveToolResult {
|
|
193
194
|
return details ? { content: [{ type: "text", text }], details } : { content: [{ type: "text", text }] };
|
|
194
195
|
}
|
|
195
196
|
|
|
@@ -203,6 +204,14 @@ const SCHEDULE_PARAMETERS = {
|
|
|
203
204
|
required: ["prompt"],
|
|
204
205
|
additionalProperties: false,
|
|
205
206
|
};
|
|
207
|
+
const CONFIG_PARAMETERS = {
|
|
208
|
+
type: "object",
|
|
209
|
+
properties: {
|
|
210
|
+
key: { type: "string", description: "配置键;缺省返回全部(idle.enabled / idle.thresholdMs / policy.*)" },
|
|
211
|
+
value: { type: "string", description: "想改成的新值;给了就只返回 `pactive config set <key> <value> --confirm`,不落盘" },
|
|
212
|
+
},
|
|
213
|
+
additionalProperties: false,
|
|
214
|
+
};
|
|
206
215
|
|
|
207
216
|
/** Mark an activity kind; never throws. */
|
|
208
217
|
async function markActivity(kind: "user" | "agent" | "session-start" | "session-end" | "tool"): Promise<void> {
|
|
@@ -214,7 +223,7 @@ async function markActivity(kind: "user" | "agent" | "session-start" | "session-
|
|
|
214
223
|
return next && typeof next === "object" ? next : state;
|
|
215
224
|
});
|
|
216
225
|
} catch (err) {
|
|
217
|
-
process.stderr.write(`[
|
|
226
|
+
process.stderr.write(`[pactive] markActivity(${kind}): ${errorText(err)}\n`);
|
|
218
227
|
}
|
|
219
228
|
}
|
|
220
229
|
|
|
@@ -228,16 +237,17 @@ async function markControlReturned(): Promise<void> {
|
|
|
228
237
|
return next && typeof next === "object" ? next : state;
|
|
229
238
|
});
|
|
230
239
|
} catch (err) {
|
|
231
|
-
process.stderr.write(`[
|
|
240
|
+
process.stderr.write(`[pactive] markControlReturned: ${errorText(err)}\n`);
|
|
232
241
|
}
|
|
233
242
|
}
|
|
234
243
|
|
|
235
|
-
export default function activate(pi:
|
|
244
|
+
export default function activate(pi: PactiveExtensionAPI): void {
|
|
236
245
|
pi.registerTool({
|
|
237
|
-
name: "
|
|
238
|
-
label: "
|
|
246
|
+
name: "pactive_status",
|
|
247
|
+
label: "Pactive Status",
|
|
239
248
|
description:
|
|
240
|
-
"
|
|
249
|
+
"当用户问「我现在闲不闲」「主动提醒开启了吗」「为什么没触发」「刚才主动做了什么」时用。" +
|
|
250
|
+
"→ 读当前空闲判定、reason、护栏四闸门、任务数与最近活动。只读,不落盘。",
|
|
241
251
|
parameters: STATUS_PARAMETERS,
|
|
242
252
|
async execute() {
|
|
243
253
|
try {
|
|
@@ -268,24 +278,27 @@ export default function activate(pi: ActiExtensionAPI): void {
|
|
|
268
278
|
{ host: HOST_KIND },
|
|
269
279
|
);
|
|
270
280
|
} catch (err) {
|
|
271
|
-
return textResult(`
|
|
281
|
+
return textResult(`pactive_status 失败:${errorText(err)}`, { error: true });
|
|
272
282
|
}
|
|
273
283
|
},
|
|
274
284
|
});
|
|
275
285
|
|
|
276
286
|
pi.registerTool({
|
|
277
|
-
name: "
|
|
278
|
-
label: "
|
|
279
|
-
description:
|
|
287
|
+
name: "pactive_schedule",
|
|
288
|
+
label: "Pactive Schedule",
|
|
289
|
+
description:
|
|
290
|
+
"当用户说「10 分钟后提醒我喝水」「18:30 提醒我开会」「每小时检查一次」时用。" +
|
|
291
|
+
"→ 登记一条定时任务并立即落盘;when 支持 10m / 18:30 / ISO 8601 / every 1h。" +
|
|
292
|
+
"会写盘,需要人工确认(只在用户明确要求时调用)。",
|
|
280
293
|
parameters: SCHEDULE_PARAMETERS,
|
|
281
294
|
async execute(_toolCallId: string, params: Record<string, any>) {
|
|
282
295
|
try {
|
|
283
296
|
const prompt = typeof params?.prompt === "string" ? params.prompt.trim() : "";
|
|
284
|
-
if (prompt === "") return textResult("
|
|
297
|
+
if (prompt === "") return textResult("pactive_schedule 失败:prompt 必填", { error: true });
|
|
285
298
|
const now = Date.now();
|
|
286
299
|
const spec = parseWhen(params?.when, now);
|
|
287
300
|
if (!spec) {
|
|
288
|
-
return textResult(`
|
|
301
|
+
return textResult(`pactive_schedule 失败:无法解析 when(${String(params?.when)})`, { error: true });
|
|
289
302
|
}
|
|
290
303
|
const core = await loadCore();
|
|
291
304
|
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
@@ -316,21 +329,70 @@ export default function activate(pi: ActiExtensionAPI): void {
|
|
|
316
329
|
{ taskId: created?.id ?? null },
|
|
317
330
|
);
|
|
318
331
|
} catch (err) {
|
|
319
|
-
return textResult(`
|
|
332
|
+
return textResult(`pactive_schedule 失败:${errorText(err)}`, { error: true });
|
|
320
333
|
}
|
|
321
334
|
},
|
|
322
335
|
});
|
|
323
336
|
|
|
324
|
-
pi.
|
|
325
|
-
|
|
326
|
-
|
|
337
|
+
pi.registerTool({
|
|
338
|
+
name: "pactive_config",
|
|
339
|
+
label: "Pactive Config",
|
|
340
|
+
description:
|
|
341
|
+
"当用户问「主动提醒的开关开着吗」「阈值是多少」「安静时间是几点」「帮我把主动提醒打开」时用。" +
|
|
342
|
+
"→ 读配置值与默认值;要改配置时只返回一条需要人工确认的可复制命令。只读,不落盘。",
|
|
343
|
+
parameters: CONFIG_PARAMETERS,
|
|
344
|
+
async execute(_toolCallId: string, params: Record<string, any>) {
|
|
345
|
+
try {
|
|
346
|
+
// Read-only by construction: a write request returns the command, never a write (contract §2.3).
|
|
347
|
+
const core = await loadCore();
|
|
348
|
+
const described = core.config?.describe ? core.config.describe() : { schema: {}, defaults: {} };
|
|
349
|
+
const known: string[] = Object.keys(described.defaults);
|
|
350
|
+
const key = typeof params?.key === "string" ? params.key.trim() : "";
|
|
351
|
+
const rawValue = params?.value === undefined || params?.value === null ? "" : String(params.value).trim();
|
|
352
|
+
if (key !== "" && !known.includes(key)) {
|
|
353
|
+
return textResult(`pactive_config 失败:未知配置键(${key});已知:${known.join(", ")}`, { error: true });
|
|
354
|
+
}
|
|
355
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
356
|
+
const state = (await readState(core, paths)) ?? core.defaultState();
|
|
357
|
+
const values = (key === "" ? known : [key]).map((k: string) => ({
|
|
358
|
+
key: k,
|
|
359
|
+
value: core.config?.get ? core.config.get(state, k) : null,
|
|
360
|
+
default: described.defaults[k] ?? null,
|
|
361
|
+
humanGated: described.schema?.[k]?.humanGated === true,
|
|
362
|
+
}));
|
|
363
|
+
if (rawValue === "") {
|
|
364
|
+
return textResult(JSON.stringify({ ok: true, readOnly: true, wrote: false, values }, null, 2), { readOnly: true });
|
|
365
|
+
}
|
|
366
|
+
return textResult(
|
|
367
|
+
JSON.stringify(
|
|
368
|
+
{
|
|
369
|
+
ok: false,
|
|
370
|
+
wrote: false,
|
|
371
|
+
reason: "need_human_confirmation",
|
|
372
|
+
command: `pactive config set ${key} ${rawValue} --confirm`,
|
|
373
|
+
hint: "不要自己改状态文件;把 command 原样给用户,等他执行后下一轮用 pactive_status 复核。",
|
|
374
|
+
values,
|
|
375
|
+
},
|
|
376
|
+
null,
|
|
377
|
+
2,
|
|
378
|
+
),
|
|
379
|
+
{ needHumanConfirmation: true },
|
|
380
|
+
);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
return textResult(`pactive_config 失败:${errorText(err)}`, { error: true });
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
pi.registerCommand("pactive", {
|
|
387
|
+
description: "Pactive:status | tasks | schedule <when> <prompt> | help",
|
|
388
|
+
async handler(args: string, ctx: PactiveUiContext) {
|
|
327
389
|
const notify = (message: string, level = "info") => {
|
|
328
390
|
try {
|
|
329
391
|
ctx?.ui?.notify?.(message, level);
|
|
330
392
|
} catch {
|
|
331
393
|
/* the host may not have a UI in non-interactive modes */
|
|
332
394
|
}
|
|
333
|
-
process.stderr.write(`[
|
|
395
|
+
process.stderr.write(`[pactive] ${message}\n`);
|
|
334
396
|
};
|
|
335
397
|
const parts = String(args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
336
398
|
const sub = parts[0] ?? "status";
|
|
@@ -357,7 +419,7 @@ export default function activate(pi: ActiExtensionAPI): void {
|
|
|
357
419
|
notify(
|
|
358
420
|
list.length === 0
|
|
359
421
|
? "没有定时任务"
|
|
360
|
-
: `
|
|
422
|
+
: `pactive: ${list.length} 个定时任务\n` +
|
|
361
423
|
list.map((task: any) => `- [${task?.id ?? "?"}] ${task?.prompt ?? task?.content ?? ""}`).join("\n"),
|
|
362
424
|
);
|
|
363
425
|
return;
|
|
@@ -366,12 +428,12 @@ export default function activate(pi: ActiExtensionAPI): void {
|
|
|
366
428
|
const when = parts[1] ?? "10m";
|
|
367
429
|
const prompt = parts.slice(2).join(" ");
|
|
368
430
|
if (prompt === "") {
|
|
369
|
-
notify("用法:/
|
|
431
|
+
notify("用法:/pactive schedule <when> <prompt>", "warning");
|
|
370
432
|
return;
|
|
371
433
|
}
|
|
372
434
|
const spec = parseWhen(when, now);
|
|
373
435
|
if (!spec) {
|
|
374
|
-
notify(`
|
|
436
|
+
notify(`pactive: 无法解析 when(${when})`, "warning");
|
|
375
437
|
return;
|
|
376
438
|
}
|
|
377
439
|
const next = await mutateState(core, paths, (current) => {
|
|
@@ -385,12 +447,12 @@ export default function activate(pi: ActiExtensionAPI): void {
|
|
|
385
447
|
});
|
|
386
448
|
const tasks = core.schedule?.list ? core.schedule.list(next) : (next.tasks ?? []);
|
|
387
449
|
const created = Array.isArray(tasks) ? tasks[tasks.length - 1] : null;
|
|
388
|
-
notify(`
|
|
450
|
+
notify(`pactive: 已登记定时任务 ${created?.id ?? "-"}(${when})`);
|
|
389
451
|
return;
|
|
390
452
|
}
|
|
391
|
-
notify("用法:/
|
|
453
|
+
notify("用法:/pactive status | tasks | schedule <when> <prompt> | help", "warning");
|
|
392
454
|
} catch (err) {
|
|
393
|
-
notify(`
|
|
455
|
+
notify(`pactive 失败:${errorText(err)}`, "error");
|
|
394
456
|
}
|
|
395
457
|
},
|
|
396
458
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pactive
|
|
3
|
+
description: >-
|
|
4
|
+
用自然语言操作 pactive(主动提醒 / 定时任务 / 跨会话消息 / 事件账本)。用户说「我现在闲不闲」
|
|
5
|
+
「10 分钟后提醒我喝水」「帮我开启主动提醒」「刚才主动提醒都发生了什么」时加载本技能。
|
|
6
|
+
写操作一律不静默落盘:给出可复制命令 `pactive config set … --confirm`,用户执行后下一轮用
|
|
7
|
+
`pactive_status` / `pactive_config` 复核;含 reason 枚举、「when」语法与排障。
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# pactive:用户会怎么说,你就怎么做
|
|
11
|
+
|
|
12
|
+
**默认全部用自然语言。** 用户不会说「把 `idle.enabled` 置为 true」,他会说「帮我开启主动提醒」。
|
|
13
|
+
你的工作是把这句话翻译成**一次只读查询**,或**一条可复制的命令**——不是替他改状态文件。
|
|
14
|
+
|
|
15
|
+
## 用户会怎么说 → 你做什么
|
|
16
|
+
|
|
17
|
+
| 用户说 | 你做什么 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| 「我现在闲不闲?」「为什么没触发?」 | 调 `pactive_status`,读 `idle.reason` 与护栏结论,照实解释 |
|
|
20
|
+
| 「主动提醒开启了吗?」「阈值是多少?」 | 调 `pactive_config`(只读),报当前值与默认值 |
|
|
21
|
+
| 「10 分钟后提醒我喝水」「18:30 提醒我开会」「每小时检查一次」 | 调 `pactive_schedule` `{prompt, when}`(用户已明确要求,会写盘) |
|
|
22
|
+
| 「帮我开启主动提醒」「把阈值改成一小时」 | **不写**:调 `pactive_config` 拿命令,把**一条**命令交给用户 |
|
|
23
|
+
| 「把那个定时任务取消」 | **不写**:给出 `pactive cancel <taskId> --confirm`,让用户自己执行 |
|
|
24
|
+
| 「刚才主动提醒都发生了什么?」 | 调 `pactive_history`,只报可核验的事实(时间戳 + 事件种类) |
|
|
25
|
+
| 「告诉另一个会话我有空」 | 调 `pactive_tell` `{to, body}`;回执是 `queued` 就照实说**还没送到** |
|
|
26
|
+
|
|
27
|
+
## 写请求的三步(硬规则)
|
|
28
|
+
|
|
29
|
+
1. **不静默写状态。** 开关、阈值、取消任务一律不由你落盘:不要改 `state.json`,不要绕过 `--confirm`。
|
|
30
|
+
模型侧改配置只会得到 `need_human_confirmation`,这是设计,不是故障。
|
|
31
|
+
2. **给一条可直接复制的命令。** 只给一条,不要列一堆选项:
|
|
32
|
+
```
|
|
33
|
+
pactive config set idle.enabled true --confirm
|
|
34
|
+
pactive cancel <taskId> --confirm
|
|
35
|
+
```
|
|
36
|
+
同时说明它会写盘、写到哪里(默认 `~/.pactive/state.json`,可用 `PACTIVE_STATE_DIR` 覆盖)。
|
|
37
|
+
3. **下一轮复核。** 用户说「好了/执行了」之后,再调 `pactive_status`(或 `pactive_config`)确认真的生效,
|
|
38
|
+
然后回报实测值。**没复核就说「已开启」等于撒谎。**
|
|
39
|
+
|
|
40
|
+
## 关键语义(别踩)
|
|
41
|
+
|
|
42
|
+
- **默认不打扰**:`idle.enabled` 默认 `false`。没打开开关,空闲永远不会触发主动行为。
|
|
43
|
+
- **`reason` 是枚举**:`idle | disabled | no-control | busy | threshold | quiet | hourly-limit | min-interval | cooldown | requires-action`。
|
|
44
|
+
没触发时先看 `reason`,不要猜。
|
|
45
|
+
- **四闸门缺一不可**:控制权已交回 + 不忙 + 超过阈值 + 护栏通过,才可能触发。
|
|
46
|
+
- **`queued` ≠ 已送达**:对端离线时消息进 outbox,等对端上线再补投;回执写 `queued` 就是还没送到。
|
|
47
|
+
- **配置是人工闸门**:只有人能加 `--confirm`;你给命令,他执行。
|
|
48
|
+
- **Stop hook 最多阻断一次**:同一轮里只阻断一次,避免死循环;`UserPromptSubmit` 会重置这个计数。
|
|
49
|
+
|
|
50
|
+
## `when` 语法
|
|
51
|
+
|
|
52
|
+
`10m` / `2h` / `30s`(相对时长)、`18:30`(当天时刻,已过则顺延到明天)、
|
|
53
|
+
ISO 8601 时间戳(绝对时刻)、`every 1h`(周期)。缺省 `10m`。
|
|
54
|
+
|
|
55
|
+
## 排障(先查,再说话)
|
|
56
|
+
|
|
57
|
+
- `reason=disabled` → 开关没开;`reason=busy` → 宿主还在忙;`reason=threshold` → 还没到阈值。
|
|
58
|
+
- 任务登记了但没触发 → 先看 `pactive_history` 里有没有 `triggered` 记录;没有说明调度器没跑到。
|
|
59
|
+
- 消息一直是 `queued` → 对端不在线;看 `pactive_status` 的 peers。
|
|
60
|
+
- 「没记录」只说明**没有证据**,不等于「没问题」——照实这么说。
|
|
61
|
+
|
|
62
|
+
## 命令面(给人用的,你只在需要时引用)
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
pactive config get [<key>] # 只读:当前值与默认值
|
|
66
|
+
pactive config set <key> <value> --confirm # 写;缺 --confirm → exit 2 且零写盘
|
|
67
|
+
pactive schedule "<prompt>" --when <when> [--confirm]
|
|
68
|
+
pactive tasks # id / 类型 / 下次到期 / 归属
|
|
69
|
+
pactive cancel <taskId> [--confirm]
|
|
70
|
+
```
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pactive
|
|
3
|
+
description: >-
|
|
4
|
+
用自然语言操作 pactive(主动提醒 / 定时任务 / 跨会话消息 / 事件账本)。用户说「我现在闲不闲」
|
|
5
|
+
「10 分钟后提醒我喝水」「帮我开启主动提醒」「刚才主动提醒都发生了什么」时加载本技能。
|
|
6
|
+
写操作一律不静默落盘:给出可复制命令 `pactive config set … --confirm`,用户执行后下一轮用
|
|
7
|
+
`pactive_status` / `pactive_config` 复核;含 reason 枚举、「when」语法与排障。
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# pactive:用户会怎么说,你就怎么做
|
|
11
|
+
|
|
12
|
+
**默认全部用自然语言。** 用户不会说「把 `idle.enabled` 置为 true」,他会说「帮我开启主动提醒」。
|
|
13
|
+
你的工作是把这句话翻译成**一次只读查询**,或**一条可复制的命令**——不是替他改状态文件。
|
|
14
|
+
|
|
15
|
+
## 用户会怎么说 → 你做什么
|
|
16
|
+
|
|
17
|
+
| 用户说 | 你做什么 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| 「我现在闲不闲?」「为什么没触发?」 | 调 `pactive_status`,读 `idle.reason` 与护栏结论,照实解释 |
|
|
20
|
+
| 「主动提醒开启了吗?」「阈值是多少?」 | 调 `pactive_config`(只读),报当前值与默认值 |
|
|
21
|
+
| 「10 分钟后提醒我喝水」「18:30 提醒我开会」「每小时检查一次」 | 调 `pactive_schedule` `{prompt, when}`(用户已明确要求,会写盘) |
|
|
22
|
+
| 「帮我开启主动提醒」「把阈值改成一小时」 | **不写**:调 `pactive_config` 拿命令,把**一条**命令交给用户 |
|
|
23
|
+
| 「把那个定时任务取消」 | **不写**:给出 `pactive cancel <taskId> --confirm`,让用户自己执行 |
|
|
24
|
+
| 「刚才主动提醒都发生了什么?」 | 调 `pactive_history`,只报可核验的事实(时间戳 + 事件种类) |
|
|
25
|
+
| 「告诉另一个会话我有空」 | 调 `pactive_tell` `{to, body}`;回执是 `queued` 就照实说**还没送到** |
|
|
26
|
+
|
|
27
|
+
## 写请求的三步(硬规则)
|
|
28
|
+
|
|
29
|
+
1. **不静默写状态。** 开关、阈值、取消任务一律不由你落盘:不要改 `state.json`,不要绕过 `--confirm`。
|
|
30
|
+
模型侧改配置只会得到 `need_human_confirmation`,这是设计,不是故障。
|
|
31
|
+
2. **给一条可直接复制的命令。** 只给一条,不要列一堆选项:
|
|
32
|
+
```
|
|
33
|
+
pactive config set idle.enabled true --confirm
|
|
34
|
+
pactive cancel <taskId> --confirm
|
|
35
|
+
```
|
|
36
|
+
同时说明它会写盘、写到哪里(默认 `~/.pactive/state.json`,可用 `PACTIVE_STATE_DIR` 覆盖)。
|
|
37
|
+
3. **下一轮复核。** 用户说「好了/执行了」之后,再调 `pactive_status`(或 `pactive_config`)确认真的生效,
|
|
38
|
+
然后回报实测值。**没复核就说「已开启」等于撒谎。**
|
|
39
|
+
|
|
40
|
+
## 关键语义(别踩)
|
|
41
|
+
|
|
42
|
+
- **默认不打扰**:`idle.enabled` 默认 `false`。没打开开关,空闲永远不会触发主动行为。
|
|
43
|
+
- **`reason` 是枚举**:`idle | disabled | no-control | busy | threshold | quiet | hourly-limit | min-interval | cooldown | requires-action`。
|
|
44
|
+
没触发时先看 `reason`,不要猜。
|
|
45
|
+
- **四闸门缺一不可**:控制权已交回 + 不忙 + 超过阈值 + 护栏通过,才可能触发。
|
|
46
|
+
- **`queued` ≠ 已送达**:对端离线时消息进 outbox,等对端上线再补投;回执写 `queued` 就是还没送到。
|
|
47
|
+
- **配置是人工闸门**:只有人能加 `--confirm`;你给命令,他执行。
|
|
48
|
+
- **Stop hook 最多阻断一次**:同一轮里只阻断一次,避免死循环;`UserPromptSubmit` 会重置这个计数。
|
|
49
|
+
|
|
50
|
+
## `when` 语法
|
|
51
|
+
|
|
52
|
+
`10m` / `2h` / `30s`(相对时长)、`18:30`(当天时刻,已过则顺延到明天)、
|
|
53
|
+
ISO 8601 时间戳(绝对时刻)、`every 1h`(周期)。缺省 `10m`。
|
|
54
|
+
|
|
55
|
+
## 排障(先查,再说话)
|
|
56
|
+
|
|
57
|
+
- `reason=disabled` → 开关没开;`reason=busy` → 宿主还在忙;`reason=threshold` → 还没到阈值。
|
|
58
|
+
- 任务登记了但没触发 → 先看 `pactive_history` 里有没有 `triggered` 记录;没有说明调度器没跑到。
|
|
59
|
+
- 消息一直是 `queued` → 对端不在线;看 `pactive_status` 的 peers。
|
|
60
|
+
- 「没记录」只说明**没有证据**,不等于「没问题」——照实这么说。
|
|
61
|
+
|
|
62
|
+
## 命令面(给人用的,你只在需要时引用)
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
pactive config get [<key>] # 只读:当前值与默认值
|
|
66
|
+
pactive config set <key> <value> --confirm # 写;缺 --confirm → exit 2 且零写盘
|
|
67
|
+
pactive schedule "<prompt>" --when <when> [--confirm]
|
|
68
|
+
pactive tasks # id / 类型 / 下次到期 / 归属
|
|
69
|
+
pactive cancel <taskId> [--confirm]
|
|
70
|
+
```
|
package/src/core/config.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { pactiveError } from './errors.mjs';
|
|
2
2
|
|
|
3
3
|
export const CONFIG_SCHEMA = {
|
|
4
4
|
'idle.enabled': { type: 'boolean', default: false, humanGated: true },
|
|
@@ -45,7 +45,7 @@ export const config = {
|
|
|
45
45
|
function specOf(key) {
|
|
46
46
|
const spec = Object.prototype.hasOwnProperty.call(CONFIG_SCHEMA, key) ? CONFIG_SCHEMA[key] : null;
|
|
47
47
|
if (!spec) {
|
|
48
|
-
throw
|
|
48
|
+
throw pactiveError('CONFIG_KEY_UNKNOWN', `unknown config key: ${key}`);
|
|
49
49
|
}
|
|
50
50
|
return spec;
|
|
51
51
|
}
|
package/src/core/errors.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Shared error helpers. The contract freezes one public error-code constant.
|
|
2
2
|
export const NOT_IMPLEMENTED = 'NOT_IMPLEMENTED';
|
|
3
3
|
|
|
4
|
-
export function
|
|
4
|
+
export function pactiveError(code, message) {
|
|
5
5
|
const err = new Error(message);
|
|
6
6
|
err.code = code;
|
|
7
7
|
return err;
|
package/src/core/identity.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { pactiveError } from './errors.mjs';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Stable identity of an agent session.
|
|
@@ -14,7 +14,7 @@ export function makeIdentity({ name, hostKind, sessionId, cwd } = {}) {
|
|
|
14
14
|
const h = typeof hostKind === 'string' ? hostKind.trim() : '';
|
|
15
15
|
const s = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
16
16
|
if (!n || !h || !s) {
|
|
17
|
-
throw
|
|
17
|
+
throw pactiveError('IDENTITY_INCOMPLETE', 'name, hostKind and sessionId are required');
|
|
18
18
|
}
|
|
19
19
|
const projectHash = hashProject(cwd ?? process.cwd());
|
|
20
20
|
return {
|
package/src/core/lineage.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { pactiveError } from './errors.mjs';
|
|
5
5
|
|
|
6
6
|
let tmpSeq = 0;
|
|
7
7
|
|
|
8
8
|
/** Append-only event ledger: one JSON object per line, private file. */
|
|
9
9
|
export const ledger = {
|
|
10
10
|
append(file, event) {
|
|
11
|
-
if (typeof file !== 'string' || !file) throw
|
|
11
|
+
if (typeof file !== 'string' || !file) throw pactiveError('LEDGER_PATH_REQUIRED', 'ledger path is required');
|
|
12
12
|
const dir = path.dirname(file);
|
|
13
13
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
14
14
|
try {
|
package/src/core/lock.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { pactiveError } from './errors.mjs';
|
|
5
5
|
|
|
6
6
|
export const LOCK_STALE_MS = 30000;
|
|
7
7
|
export const LOCK_TIMEOUT_MS = 10000;
|
|
@@ -53,7 +53,7 @@ export async function withLock(locksDir, name, fn, opts = {}) {
|
|
|
53
53
|
continue;
|
|
54
54
|
}
|
|
55
55
|
if (Date.now() > deadline) {
|
|
56
|
-
throw
|
|
56
|
+
throw pactiveError('LOCK_TIMEOUT', `could not acquire lock ${lockFile}`);
|
|
57
57
|
}
|
|
58
58
|
await sleep(20);
|
|
59
59
|
}
|
package/src/core/paths.mjs
CHANGED
|
@@ -2,9 +2,9 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { pactiveError } from './errors.mjs';
|
|
6
6
|
|
|
7
|
-
export const DEFAULT_ROOT_DIRNAME = '.
|
|
7
|
+
export const DEFAULT_ROOT_DIRNAME = '.pactive';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Resolve the state root and every file location derived from it.
|
|
@@ -12,9 +12,9 @@ export const DEFAULT_ROOT_DIRNAME = '.acti';
|
|
|
12
12
|
*/
|
|
13
13
|
export function resolvePaths(hostKind, env = process.env) {
|
|
14
14
|
const kind = typeof hostKind === 'string' ? hostKind.trim() : '';
|
|
15
|
-
if (!kind) throw
|
|
15
|
+
if (!kind) throw pactiveError('HOST_KIND_REQUIRED', 'hostKind is required');
|
|
16
16
|
|
|
17
|
-
const source = env && typeof env.
|
|
17
|
+
const source = env && typeof env.PACTIVE_STATE_DIR === 'string' ? env.PACTIVE_STATE_DIR.trim() : '';
|
|
18
18
|
const root = path.resolve(source || path.join(os.homedir(), DEFAULT_ROOT_DIRNAME));
|
|
19
19
|
|
|
20
20
|
let stat = null;
|
|
@@ -25,12 +25,12 @@ export function resolvePaths(hostKind, env = process.env) {
|
|
|
25
25
|
}
|
|
26
26
|
if (stat) {
|
|
27
27
|
if (!stat.isDirectory()) {
|
|
28
|
-
throw
|
|
28
|
+
throw pactiveError('STATE_ROOT_INVALID', `state root is not a directory: ${root}`);
|
|
29
29
|
}
|
|
30
30
|
try {
|
|
31
31
|
fs.accessSync(root, fs.constants.W_OK);
|
|
32
32
|
} catch {
|
|
33
|
-
throw
|
|
33
|
+
throw pactiveError('STATE_ROOT_NOT_WRITABLE', `state root is not writable: ${root}`);
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
package/src/core/scheduler.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { pactiveError } from './errors.mjs';
|
|
2
2
|
|
|
3
3
|
export const TASK_KINDS = ['in', 'at', 'every'];
|
|
4
4
|
|
|
@@ -10,28 +10,30 @@ let seq = 0;
|
|
|
10
10
|
*/
|
|
11
11
|
export const schedule = {
|
|
12
12
|
add(state, spec, now = Date.now()) {
|
|
13
|
-
if (!spec || typeof spec !== 'object') throw
|
|
13
|
+
if (!spec || typeof spec !== 'object') throw pactiveError('TASK_SPEC_REQUIRED', 'a task spec is required');
|
|
14
14
|
const ts = Number.isFinite(now) ? now : Date.now();
|
|
15
15
|
const kind = spec.kind ?? (Number.isFinite(spec.at) ? 'at' : 'in');
|
|
16
16
|
if (!TASK_KINDS.includes(kind)) {
|
|
17
|
-
throw
|
|
17
|
+
throw pactiveError('TASK_KIND_UNKNOWN', `unknown task kind: ${kind}`);
|
|
18
18
|
}
|
|
19
19
|
if (!Array.isArray(state.tasks)) state.tasks = [];
|
|
20
20
|
|
|
21
21
|
let dueAt;
|
|
22
22
|
let everyMs = 0;
|
|
23
23
|
if (kind === 'at') {
|
|
24
|
-
if (!Number.isFinite(spec.at)) throw
|
|
24
|
+
if (!Number.isFinite(spec.at)) throw pactiveError('TASK_AT_REQUIRED', 'an "at" task needs spec.at');
|
|
25
25
|
dueAt = spec.at;
|
|
26
26
|
} else if (kind === 'every') {
|
|
27
27
|
everyMs = Number.isFinite(spec.everyMs) ? spec.everyMs : Number.isFinite(spec.intervalMs) ? spec.intervalMs : 0;
|
|
28
|
-
if (everyMs <= 0) throw
|
|
28
|
+
if (everyMs <= 0) throw pactiveError('TASK_INTERVAL_REQUIRED', 'an "every" task needs a positive interval');
|
|
29
29
|
dueAt = ts + everyMs;
|
|
30
30
|
} else {
|
|
31
|
+
// A relative delay may arrive either as a duration (delayMs/afterMs/ms) or already
|
|
32
|
+
// resolved to an absolute time (spec.at — what every host-surface parseWhen produces).
|
|
31
33
|
const delay = Number.isFinite(spec.delayMs) ? spec.delayMs
|
|
32
34
|
: Number.isFinite(spec.afterMs) ? spec.afterMs
|
|
33
|
-
: Number.isFinite(spec.ms) ? spec.ms :
|
|
34
|
-
dueAt = ts + delay;
|
|
35
|
+
: Number.isFinite(spec.ms) ? spec.ms : null;
|
|
36
|
+
dueAt = delay === null && Number.isFinite(spec.at) ? spec.at : ts + (delay ?? 0);
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
const owner = spec.owner ?? state.identity?.ownerKey ?? '';
|
|
@@ -48,6 +50,8 @@ export const schedule = {
|
|
|
48
50
|
everyMs,
|
|
49
51
|
label: spec.label ?? null,
|
|
50
52
|
payload: spec.payload ?? null,
|
|
53
|
+
// The prompt is what the trigger injects later; dropping it registers an empty reminder.
|
|
54
|
+
prompt: spec.prompt ?? spec.content ?? null,
|
|
51
55
|
createdAt: ts,
|
|
52
56
|
lastRunAt: 0,
|
|
53
57
|
active: true,
|
package/src/core/state.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { pactiveError } from './errors.mjs';
|
|
5
5
|
import { ensureDir, withLock } from './lock.mjs';
|
|
6
6
|
import { ledger } from './lineage.mjs';
|
|
7
7
|
|
|
@@ -38,7 +38,7 @@ export function defaultState() {
|
|
|
38
38
|
|
|
39
39
|
/** Accept either a paths object (from resolvePaths) or a state file path. */
|
|
40
40
|
export function toPaths(target) {
|
|
41
|
-
if (!target) throw
|
|
41
|
+
if (!target) throw pactiveError('STATE_TARGET_REQUIRED', 'a paths object or state file path is required');
|
|
42
42
|
if (typeof target === 'string') {
|
|
43
43
|
const stateFile = path.resolve(target);
|
|
44
44
|
const root = path.dirname(stateFile);
|
|
@@ -54,7 +54,7 @@ export function toPaths(target) {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
if (typeof target.stateFile !== 'string' || !target.stateFile) {
|
|
57
|
-
throw
|
|
57
|
+
throw pactiveError('STATE_TARGET_REQUIRED', 'a paths object or state file path is required');
|
|
58
58
|
}
|
|
59
59
|
const root = target.root ?? path.dirname(target.stateFile);
|
|
60
60
|
return {
|
|
@@ -111,7 +111,7 @@ export function loadState(target) {
|
|
|
111
111
|
try {
|
|
112
112
|
const parsed = JSON.parse(raw);
|
|
113
113
|
if (!parsed || typeof parsed !== 'object' || parsed.schemaVersion !== SCHEMA_VERSION) {
|
|
114
|
-
throw
|
|
114
|
+
throw pactiveError('STATE_SCHEMA_UNSUPPORTED', `unsupported schemaVersion in ${paths.stateFile}`);
|
|
115
115
|
}
|
|
116
116
|
return normalize(parsed);
|
|
117
117
|
} catch (err) {
|
|
@@ -143,10 +143,10 @@ function quarantine(paths, cause) {
|
|
|
143
143
|
export function saveState(target, state) {
|
|
144
144
|
const paths = toPaths(target);
|
|
145
145
|
if (!state || typeof state !== 'object') {
|
|
146
|
-
throw
|
|
146
|
+
throw pactiveError('STATE_REQUIRED', 'a state object is required');
|
|
147
147
|
}
|
|
148
148
|
if (state.schemaVersion !== SCHEMA_VERSION) {
|
|
149
|
-
throw
|
|
149
|
+
throw pactiveError('STATE_SCHEMA_UNSUPPORTED', `expected schemaVersion ${SCHEMA_VERSION}`);
|
|
150
150
|
}
|
|
151
151
|
ensureStateDirs(paths);
|
|
152
152
|
const dir = path.dirname(paths.stateFile);
|
|
@@ -168,7 +168,7 @@ export function saveState(target, state) {
|
|
|
168
168
|
|
|
169
169
|
/** Read-modify-write under a lock; the callback may be async and may mutate in place. */
|
|
170
170
|
export async function mutateState(target, fn) {
|
|
171
|
-
if (typeof fn !== 'function') throw
|
|
171
|
+
if (typeof fn !== 'function') throw pactiveError('MUTATOR_REQUIRED', 'mutateState needs a function');
|
|
172
172
|
const paths = toPaths(target);
|
|
173
173
|
ensureStateDirs(paths);
|
|
174
174
|
return withLock(paths.locksDir, 'state', async () => {
|