pi-anchorloop 0.0.0-stage → 2.0.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/LICENSE +21 -0
- package/README.md +63 -2
- package/README.zh-TW.md +64 -0
- package/hooks/validate_tool_use.py +88 -0
- package/hooks/validators/__init__.py +1 -0
- package/hooks/validators/constants.py +47 -0
- package/hooks/validators/durable_validator.py +89 -0
- package/hooks/validators/rule_validator.py +42 -0
- package/hooks/validators/skill_validator.py +144 -0
- package/hooks/validators/utils.py +71 -0
- package/package.json +44 -4
- package/pi/anchor-gate.ts +84 -0
- package/pi/decision-tool.ts +88 -0
- package/pi/second-thought.ts +242 -0
- package/pi/support/validators.ts +403 -0
- package/pi/ttsr.ts +516 -0
- package/pi/validate-tool-use.ts +27 -0
- package/skills/agent-terse/SKILL.md +46 -0
- package/skills/anchorloop/PRINCIPLES.md +34 -0
- package/skills/anchorloop/SKILL.md +76 -0
- package/skills/anchorloop/playbooks/autonomous-run.md +12 -0
- package/skills/anchorloop/playbooks/pause.md +10 -0
- package/skills/anchorloop/playbooks/pickup.md +11 -0
- package/skills/answering/SKILL.md +19 -0
- package/skills/answering/playbooks/compare.md +9 -0
- package/skills/answering/playbooks/explain.md +9 -0
- package/skills/coding/MINI-SDD.md +61 -0
- package/skills/coding/SKILL.md +27 -0
- package/skills/coding/playbooks/bug-fix.md +11 -0
- package/skills/coding/playbooks/feature.md +15 -0
- package/skills/coding/playbooks/perf.md +10 -0
- package/skills/coding/playbooks/prototype.md +14 -0
- package/skills/coding/playbooks/refactoring.md +12 -0
- package/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/skills/domain-modeling/SKILL.md +48 -0
- package/skills/grilling/SKILL.md +30 -0
- package/skills/human-feedback/SKILL.md +83 -0
- package/skills/inspecting/SKILL.md +49 -0
- package/skills/inspecting/playbooks/diff-review.md +57 -0
- package/skills/inspecting/playbooks/qa-release.md +9 -0
- package/skills/investigating/SKILL.md +22 -0
- package/skills/investigating/playbooks/diagnose.md +9 -0
- package/skills/investigating/playbooks/research.md +9 -0
- package/skills/solid-loop/SKILL.md +50 -0
- package/skills/solid-loop/playbooks/apply-friction.md +15 -0
- package/skills/solid-loop/playbooks/author-skill.md +11 -0
- package/skills/solid-loop/playbooks/bootstrap.md +10 -0
- package/skills/solid-loop/references/template-skill.md +26 -0
- package/skills/writing/SKILL.md +16 -0
- package/skills/writing/playbooks/comments.md +10 -0
- package/skills/writing/playbooks/draft.md +10 -0
- package/skills/writing/playbooks/tighten.md +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-anchorloop",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "Agent workflow skills for pi: one entry skill per task type, each with playbooks, over a shared Align → Anchor → Act → Verify loop, plus loop extensions (anchor gate, decision trail tool, Jev second thought, skill validator, stream rules).",
|
|
5
|
+
"author": "Wei Hung",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/wayne930242/anchorloop.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/wayne930242/anchorloop#readme",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package",
|
|
15
|
+
"pi",
|
|
16
|
+
"pi-coding-agent",
|
|
17
|
+
"anchorloop",
|
|
18
|
+
"agent-workflow",
|
|
19
|
+
"skills",
|
|
20
|
+
"playbooks"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"pi",
|
|
24
|
+
"skills",
|
|
25
|
+
"hooks/validate_tool_use.py",
|
|
26
|
+
"hooks/validators/*.py",
|
|
27
|
+
"README.md",
|
|
28
|
+
"README.zh-TW.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./pi/validate-tool-use.ts",
|
|
34
|
+
"./pi/ttsr.ts",
|
|
35
|
+
"./pi/anchor-gate.ts",
|
|
36
|
+
"./pi/decision-tool.ts",
|
|
37
|
+
"./pi/second-thought.ts"
|
|
38
|
+
],
|
|
39
|
+
"skills": [
|
|
40
|
+
"./skills"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi extension: warns once when write or edit runs before the assistant has
|
|
3
|
+
* stated a `Reality anchor:` line since the user's latest message.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
const ANCHOR = /Reality anchor:/;
|
|
12
|
+
const EXEMPT_IN_PROJECT = ["scratch", "docs/specs"];
|
|
13
|
+
const EXEMPT_ROOTS = [tmpdir(), "/tmp", "/var/folders"];
|
|
14
|
+
const EXEMPT_EXTENSIONS = [".md", ".txt", ".html", ".htm"];
|
|
15
|
+
|
|
16
|
+
export const WARNING =
|
|
17
|
+
"anchorloop anchor gate: a change ran before the reply stated " +
|
|
18
|
+
"`Reality anchor: <the exact check that observes the result, and its checkpoint>`.";
|
|
19
|
+
|
|
20
|
+
type Block = { type: string; text?: string };
|
|
21
|
+
type Message = { role?: string; content?: string | Block[] };
|
|
22
|
+
|
|
23
|
+
function statesAnchor(message: Message): boolean {
|
|
24
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) return false;
|
|
25
|
+
return message.content.some((block) => block.type === "text" && ANCHOR.test(block.text ?? ""));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The real path of `path`, resolving symlinks such as /var → /private/var through its nearest existing parent. */
|
|
29
|
+
function real(path: string): string {
|
|
30
|
+
if (existsSync(path)) return realpathSync(path);
|
|
31
|
+
const parent = dirname(path);
|
|
32
|
+
return parent === path ? path : join(real(parent), basename(path));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function within(root: string, target: string): boolean {
|
|
36
|
+
const rel = relative(root, target);
|
|
37
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Markdown, text, and HTML files, scratch and spec folders in the project, and temp files outside it; a project that lives in a temp dir is still gated. */
|
|
41
|
+
export function exempt(filePath: string, cwd: string): boolean {
|
|
42
|
+
if (EXEMPT_EXTENSIONS.includes(extname(filePath).toLowerCase())) return true;
|
|
43
|
+
const root = real(cwd);
|
|
44
|
+
const target = real(resolve(cwd, filePath));
|
|
45
|
+
if (within(root, target)) return EXEMPT_IN_PROJECT.some((dir) => within(join(root, dir), target));
|
|
46
|
+
return EXEMPT_ROOTS.some((tmp) => within(real(tmp), target));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function (pi: ExtensionAPI) {
|
|
50
|
+
let anchored = false;
|
|
51
|
+
let warned = false;
|
|
52
|
+
|
|
53
|
+
pi.on("session_start", (_event, ctx) => {
|
|
54
|
+
anchored = false;
|
|
55
|
+
const branch = ctx.sessionManager.getBranch() as Array<{ type: string; message?: Message }>;
|
|
56
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
57
|
+
const message = branch[i].type === "message" ? branch[i].message : undefined;
|
|
58
|
+
if (!message) continue;
|
|
59
|
+
if (message.role === "user") return;
|
|
60
|
+
if (statesAnchor(message)) {
|
|
61
|
+
anchored = true;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// User messages that steer a running loop end here too, so the gate closes for each of them.
|
|
68
|
+
pi.on("message_end", (event) => {
|
|
69
|
+
const message = event.message as Message;
|
|
70
|
+
if (message.role === "user") {
|
|
71
|
+
anchored = false;
|
|
72
|
+
warned = false;
|
|
73
|
+
}
|
|
74
|
+
else if (statesAnchor(message)) anchored = true;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
pi.on("tool_call", (event, ctx) => {
|
|
78
|
+
if (anchored || warned || (event.toolName !== "write" && event.toolName !== "edit")) return;
|
|
79
|
+
const filePath = (event.input as { path?: unknown }).path;
|
|
80
|
+
if (typeof filePath !== "string" || exempt(filePath, ctx.cwd)) return;
|
|
81
|
+
warned = true;
|
|
82
|
+
ctx.ui.notify(WARNING, "warning");
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi extension: the `anchorloop_decision` tool appends single-line rows to a
|
|
3
|
+
* decision trail TSV. Adapted from pstack's show-me-your-work log.sh
|
|
4
|
+
* (MIT, (c) 2026 Lauren Tan).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeSync } from "node:fs";
|
|
8
|
+
import { dirname, relative, resolve } from "node:path";
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
const HEADER = ["ts", "decision", "why", "evidence", "result"];
|
|
12
|
+
const FORMULA_LEADS = ["=", "+", "-", "@", '"'];
|
|
13
|
+
const TRAIL_PATH = /^scratch\/decisions-[^/]+\.tsv$/;
|
|
14
|
+
|
|
15
|
+
function clean(cell: string): string {
|
|
16
|
+
const flat = cell.replace(/[\t\r\n]/g, " ");
|
|
17
|
+
return FORMULA_LEADS.some((lead) => flat.startsWith(lead)) ? `'${flat}` : flat;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readRows(log: string): string[][] {
|
|
21
|
+
if (!existsSync(log)) return [];
|
|
22
|
+
return readFileSync(log, "utf-8")
|
|
23
|
+
.split("\n")
|
|
24
|
+
.slice(1)
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map((line) => line.split("\t"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function appendRow(log: string, cells: string[], now = new Date()): string {
|
|
30
|
+
mkdirSync(dirname(log), { recursive: true });
|
|
31
|
+
const ts = now.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
32
|
+
const row = `${[ts, ...cells.map(clean)].join("\t")}\n`;
|
|
33
|
+
const data = existsSync(log) && statSync(log).size > 0 ? row : `${HEADER.join("\t")}\n${row}`;
|
|
34
|
+
// One O_APPEND write per row, so parallel writers never interleave inside a row.
|
|
35
|
+
const fd = openSync(log, "a");
|
|
36
|
+
try {
|
|
37
|
+
const bytes = Buffer.from(data);
|
|
38
|
+
const written = writeSync(fd, bytes);
|
|
39
|
+
if (written !== bytes.length) throw new Error(`short write: ${written} of ${bytes.length} bytes`);
|
|
40
|
+
} finally {
|
|
41
|
+
closeSync(fd);
|
|
42
|
+
}
|
|
43
|
+
return row;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function startRow(log: string, runId: string): string {
|
|
47
|
+
const rows = readRows(log);
|
|
48
|
+
const span = rows.length > 0 ? `${rows[0][0]}..${rows.at(-1)?.[0]}` : "empty trail";
|
|
49
|
+
return appendRow(log, ["start", `picked up after ${rows.length} earlier rows (${span})`, runId, "open"]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Params = { log: string; start?: string; decision?: string; why?: string; evidence?: string; result?: string };
|
|
53
|
+
|
|
54
|
+
export default function (pi: ExtensionAPI) {
|
|
55
|
+
pi.registerTool({
|
|
56
|
+
name: "anchorloop_decision",
|
|
57
|
+
label: "Decision trail",
|
|
58
|
+
description:
|
|
59
|
+
"Append one row to a decision trail TSV (scratch/decisions-<task-slug>.tsv): a fork chosen, a unit completed, a pivot, or a blocker. " +
|
|
60
|
+
"Pass `start` with your run id instead of the row fields when picking up a trail another run wrote.",
|
|
61
|
+
parameters: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
log: { type: "string", description: "Trail path, relative to the working directory" },
|
|
65
|
+
start: { type: "string", description: "Run id; writes a start row and ignores the row fields" },
|
|
66
|
+
decision: { type: "string", description: "What was chosen or done, one line" },
|
|
67
|
+
why: { type: "string", description: "The reason in plain words" },
|
|
68
|
+
evidence: { type: "string", description: "A pointer: commit, file:line, or artifact path" },
|
|
69
|
+
result: { type: "string", description: "Outcome or predicate state" },
|
|
70
|
+
},
|
|
71
|
+
required: ["log"],
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
},
|
|
74
|
+
async execute(_toolCallId: string, params: Params, _signal: unknown, _onUpdate: unknown, ctx: { cwd: string }) {
|
|
75
|
+
const log = resolve(ctx.cwd, params.log);
|
|
76
|
+
if (!TRAIL_PATH.test(relative(ctx.cwd, log))) throw new Error("log must be scratch/decisions-<task-slug>.tsv");
|
|
77
|
+
let row: string;
|
|
78
|
+
if (params.start) {
|
|
79
|
+
row = startRow(log, params.start);
|
|
80
|
+
} else {
|
|
81
|
+
const cells = [params.decision, params.why, params.evidence, params.result];
|
|
82
|
+
if (cells.some((cell) => !cell)) throw new Error("decision, why, evidence, and result are all required");
|
|
83
|
+
row = appendRow(log, cells as string[]);
|
|
84
|
+
}
|
|
85
|
+
return { content: [{ type: "text", text: `appended to ${params.log}: ${row.trim()}` }], details: undefined };
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi extension: before a run that changed something settles, asks Jev whether the
|
|
3
|
+
* final reply shows the request met, what review it needs, and how good it is.
|
|
4
|
+
* Only what the user typed counts as the request: messages that extensions send
|
|
5
|
+
* (intercom, monitor wakes) do not replace it. A run they start is sent with
|
|
6
|
+
* `trigger: wake` and the message that woke it, and is judged on what that message asks.
|
|
7
|
+
* A run that arranged a wake (a monitor, a loop, a subagent) is waiting, not finished,
|
|
8
|
+
* and is not judged. Jev also says whether the reply is a
|
|
9
|
+
* checkpoint, a point that hands work back to the user; a reply that only acknowledges,
|
|
10
|
+
* reports progress, or waits gets no reminder. A wake run is asked a stricter checkpoint
|
|
11
|
+
* question, since with the general one Jev counts a reply to a routine liveness or health
|
|
12
|
+
* check as handing work back. A wrong review tier or low quality gets one
|
|
13
|
+
* advisory continuation per run; a low done score is only shown. The agent is never
|
|
14
|
+
* blocked. Only the request, the trigger, the wake message, and the final reply are sent.
|
|
15
|
+
* A subagent session registers nothing: the agent that spawned it judges its result.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { type ExtensionAPI, parseSkillBlock } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
export const ENTRY_TYPE = "anchorloop-second-thought";
|
|
21
|
+
const WORK_TOOLS = new Set(["write", "edit", "bash"]);
|
|
22
|
+
const WAIT_TOOLS = new Set(["MonitorCreate", "LoopCreate", "goal_wait", "subagent", "subagent_resume", "subagent_send"]);
|
|
23
|
+
const REQUEST_CHARS = 1500;
|
|
24
|
+
const WAKE_CHARS = 1500;
|
|
25
|
+
const REPLY_CHARS = 3000;
|
|
26
|
+
const TIMEOUT_MS = 15_000;
|
|
27
|
+
|
|
28
|
+
export const THRESHOLDS = { checkpointBelow: 0.5, doneBelow: 0.25, qualityBelow: 1.5, reviewTier: "full" };
|
|
29
|
+
|
|
30
|
+
export const QUESTIONS = {
|
|
31
|
+
checkpoint: {
|
|
32
|
+
type: "bool" as const,
|
|
33
|
+
instructions:
|
|
34
|
+
"Is `reply` a point where the agent hands work back to the user? `trigger` is `user` when the user's message started this run and `wake` when an automated message (a monitor, a loop, another agent) did.",
|
|
35
|
+
criteria: {
|
|
36
|
+
true: "the reply delivers a result, reports the request finished or blocked, or asks the user to decide",
|
|
37
|
+
false: "the reply only acknowledges a message, reports interim progress, or says the agent is waiting on something it started",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
done: {
|
|
41
|
+
type: "bool" as const,
|
|
42
|
+
instructions:
|
|
43
|
+
"Does `reply` show, with evidence, that the work this run took on is fully met? That work is `request`, or, when `trigger` is `wake`, what `wake` asks in service of `request`.",
|
|
44
|
+
criteria: {
|
|
45
|
+
true: "every part of the request has observed evidence in the reply: command output, a test result, or an artifact",
|
|
46
|
+
false: "some part failed, was skipped, is left for later, or rests only on a claim",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
review: {
|
|
50
|
+
type: "choice" as const,
|
|
51
|
+
instructions: "What review should the change described in `reply` get before it is merged or released?",
|
|
52
|
+
criteria: {
|
|
53
|
+
none: "trivial, fully covered by a passing check",
|
|
54
|
+
focused: "one independent reviewer on the changed lines",
|
|
55
|
+
full: "independent review plus an adversarial pass: security, data, permissions, migration, or a shared contract",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
quality: {
|
|
59
|
+
type: "score" as const,
|
|
60
|
+
instructions: "How acceptable is the delivered work against the work this run took on: `request`, or `wake` when `trigger` is `wake`?",
|
|
61
|
+
criteria: [
|
|
62
|
+
"unacceptable: wrong or broken",
|
|
63
|
+
"weak: works but misses requirements",
|
|
64
|
+
"acceptable: meets the request",
|
|
65
|
+
"strong: meets it with direct evidence and no excess",
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Replayed on the 26 wake-run advisories of one long session, this question let through 1 of
|
|
71
|
+
// the 21 status or liveness replies instead of all 21, and 3 of the 5 delivered results.
|
|
72
|
+
export const WAKE_CHECKPOINT = {
|
|
73
|
+
type: "bool" as const,
|
|
74
|
+
instructions:
|
|
75
|
+
"Is `reply` a point where the agent hands work back to the user? `trigger` is `user` when the user's message started this run and `wake` when an automated message (a monitor, a loop, another agent) did; `wake` is that message. A reply to a routine check that `wake` asked for is not a checkpoint.",
|
|
76
|
+
criteria: {
|
|
77
|
+
true: "the reply delivers a finished result or artifact, reports the user's request finished or blocked, or asks the user to decide",
|
|
78
|
+
false: "the reply acknowledges a message, reports the status, health, load, or progress of work that is still running, or says the agent is waiting on something",
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type Block = { type: string; text?: string };
|
|
83
|
+
type Answers = Record<string, { type: string; probability?: number; choice?: string; probabilities?: Record<string, number>; score?: number }>;
|
|
84
|
+
export type Verdict = { checkpoint: number; done: number; review: string; reviewProbability: number; quality: number };
|
|
85
|
+
|
|
86
|
+
export function readVerdict(answers: Answers): Verdict | undefined {
|
|
87
|
+
const { checkpoint, done, review, quality } = answers;
|
|
88
|
+
if (checkpoint?.type !== "bool" || done?.type !== "bool" || review?.type !== "choice" || quality?.type !== "score") return undefined;
|
|
89
|
+
const choice = review.choice ?? "";
|
|
90
|
+
return {
|
|
91
|
+
checkpoint: checkpoint.probability ?? 0,
|
|
92
|
+
done: done.probability ?? 0,
|
|
93
|
+
review: choice,
|
|
94
|
+
reviewProbability: review.probabilities?.[choice] ?? 0,
|
|
95
|
+
quality: quality.score ?? 0,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function concerns(verdict: Verdict): string[] {
|
|
100
|
+
const found: string[] = [];
|
|
101
|
+
if (verdict.checkpoint < THRESHOLDS.checkpointBelow) return found;
|
|
102
|
+
if (verdict.done < THRESHOLDS.doneBelow) found.push("request not shown met");
|
|
103
|
+
if (verdict.review === THRESHOLDS.reviewTier) found.push("needs full independent review");
|
|
104
|
+
if (verdict.quality < THRESHOLDS.qualityBelow) found.push("result below the request");
|
|
105
|
+
return found;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function needsRecheck(verdict: Verdict): boolean {
|
|
109
|
+
return verdict.review === THRESHOLDS.reviewTier || verdict.quality < THRESHOLDS.qualityBelow;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function message(verdict: Verdict, found: string[], recheck: boolean): string {
|
|
113
|
+
return (
|
|
114
|
+
`Second thought (Jev, advisory): done ${verdict.done.toFixed(2)}, review ${verdict.review}, ` +
|
|
115
|
+
`quality ${verdict.quality.toFixed(2)}/3; ${found.join("; ")}.` +
|
|
116
|
+
(recheck ? " Recheck your evidence; continue only if it fails." : "") +
|
|
117
|
+
" No reply needed. Clear finished or stale todos; continue any unfinished work."
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function text(content: unknown): string {
|
|
122
|
+
if (typeof content === "string") return content.trim();
|
|
123
|
+
if (!Array.isArray(content)) return "";
|
|
124
|
+
return (content as Block[])
|
|
125
|
+
.filter((block) => block.type === "text")
|
|
126
|
+
.map((block) => block.text ?? "")
|
|
127
|
+
.join("\n")
|
|
128
|
+
.trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// pi-herdr-agents marks its children with PI_SUBAGENT_ID, pi-subagents with PI_SUBAGENT_CHILD,
|
|
132
|
+
// and avtc's pi-subagent with PI_SUBAGENT_CHILD_AGENT plus PI_SUBAGENT_PARENT_PID.
|
|
133
|
+
export function isSubagent(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
134
|
+
return Boolean(env.PI_SUBAGENT_ID) || env.PI_SUBAGENT_CHILD === "1" || Boolean(env.PI_SUBAGENT_CHILD_AGENT && env.PI_SUBAGENT_PARENT_PID);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default function (pi: ExtensionAPI) {
|
|
138
|
+
if (isSubagent()) return;
|
|
139
|
+
let request = "";
|
|
140
|
+
let reply = "";
|
|
141
|
+
let worked = false;
|
|
142
|
+
let waiting = false;
|
|
143
|
+
let asked = false;
|
|
144
|
+
// A run is `user` once a message the user typed lands in it; otherwise a wake started it.
|
|
145
|
+
let trigger: "user" | "wake" = "wake";
|
|
146
|
+
// The automated message that started a wake run: the last one to land before the run's first reply.
|
|
147
|
+
let wake = "";
|
|
148
|
+
// Texts extensions sent as user messages that have not landed yet; matched by content,
|
|
149
|
+
// since another handler may swallow an input or a queue may be cleared before it lands.
|
|
150
|
+
const sent = new Map<string, number>();
|
|
151
|
+
|
|
152
|
+
pi.on("session_start", () => {
|
|
153
|
+
sent.clear();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
pi.on("input", (event) => {
|
|
157
|
+
if (event.source !== "extension") return;
|
|
158
|
+
const said = event.text.trim();
|
|
159
|
+
sent.set(said, (sent.get(said) ?? 0) + 1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Each run is judged once, on its own work. State resets once the session settles, so a
|
|
163
|
+
// continuation (this extension's recheck, or one after compaction) stays in the same run.
|
|
164
|
+
// An extension text still pending then was swallowed or dropped and will not land.
|
|
165
|
+
pi.on("agent_settled", () => {
|
|
166
|
+
sent.clear();
|
|
167
|
+
reply = "";
|
|
168
|
+
worked = false;
|
|
169
|
+
waiting = false;
|
|
170
|
+
asked = false;
|
|
171
|
+
trigger = "wake";
|
|
172
|
+
wake = "";
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
pi.on("tool_call", (event) => {
|
|
176
|
+
if (WORK_TOOLS.has(event.toolName)) worked = true;
|
|
177
|
+
if (WAIT_TOOLS.has(event.toolName)) waiting = true;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// A message the user typed, including one that steers a running loop, starts a new request.
|
|
181
|
+
pi.on("message_end", (event) => {
|
|
182
|
+
const msg = event.message as { role?: string; content?: unknown; customType?: string };
|
|
183
|
+
const said = text(msg.content);
|
|
184
|
+
if (msg.role === "custom") {
|
|
185
|
+
if (msg.customType !== ENTRY_TYPE && !reply && said) wake = said;
|
|
186
|
+
} else if (msg.role === "user") {
|
|
187
|
+
const pending = sent.get(said) ?? 0;
|
|
188
|
+
if (pending > 0) {
|
|
189
|
+
if (pending === 1) sent.delete(said);
|
|
190
|
+
else sent.set(said, pending - 1);
|
|
191
|
+
if (!reply) wake = said;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const skill = parseSkillBlock(said);
|
|
195
|
+
request = skill ? (skill.userMessage ?? `/skill:${skill.name}`) : said;
|
|
196
|
+
reply = "";
|
|
197
|
+
worked = false;
|
|
198
|
+
asked = false;
|
|
199
|
+
trigger = "user";
|
|
200
|
+
wake = "";
|
|
201
|
+
} else if (msg.role === "assistant" && said) {
|
|
202
|
+
reply = said;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
pi.on("agent_before_settle", async (event, ctx) => {
|
|
207
|
+
if (asked || !worked || waiting || !reply || !request || event.outcome !== "completed") return;
|
|
208
|
+
asked = true;
|
|
209
|
+
const record = (data: Record<string, unknown>) => ({ type: "custom" as const, customType: ENTRY_TYPE, data });
|
|
210
|
+
const jev = ctx.modelRegistry.findOfType("classifier", "typesafe", "jev-latest");
|
|
211
|
+
type AuthModel = Parameters<typeof ctx.modelRegistry.hasConfiguredAuth>[0];
|
|
212
|
+
// SAFETY: hasConfiguredAuth is typed for chat models but reads only `provider`, which a classifier has too.
|
|
213
|
+
if (!jev || !ctx.modelRegistry.hasConfiguredAuth(jev as unknown as AuthModel)) return;
|
|
214
|
+
let verdict: Verdict | undefined;
|
|
215
|
+
let reason = "";
|
|
216
|
+
try {
|
|
217
|
+
const result = await ctx.modelRegistry.classify(
|
|
218
|
+
jev,
|
|
219
|
+
{
|
|
220
|
+
state: { request: request.slice(0, REQUEST_CHARS), trigger, wake: wake.slice(0, WAKE_CHARS), reply: reply.slice(-REPLY_CHARS) },
|
|
221
|
+
questions: trigger === "wake" ? { ...QUESTIONS, checkpoint: WAKE_CHECKPOINT } : QUESTIONS,
|
|
222
|
+
},
|
|
223
|
+
{ signal: AbortSignal.any([AbortSignal.timeout(TIMEOUT_MS), ...(ctx.signal ? [ctx.signal] : [])]) },
|
|
224
|
+
);
|
|
225
|
+
verdict = result.stopReason === "stop" ? readVerdict(result.answers as Answers) : undefined;
|
|
226
|
+
reason = result.stopReason;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
reason = error instanceof Error ? error.message : String(error);
|
|
229
|
+
}
|
|
230
|
+
if (!verdict) return { entries: [record({ verdict: "unknown", reason })] };
|
|
231
|
+
const found = concerns(verdict);
|
|
232
|
+
if (found.length === 0) return { entries: [record({ ...verdict, concerns: [] })] };
|
|
233
|
+
const recheck = needsRecheck(verdict);
|
|
234
|
+
return {
|
|
235
|
+
entries: [
|
|
236
|
+
record({ ...verdict, concerns: found }),
|
|
237
|
+
{ type: "custom_message" as const, customType: ENTRY_TYPE, content: message(verdict, found, recheck), display: true },
|
|
238
|
+
],
|
|
239
|
+
...(recheck ? { continue: true } : {}),
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
}
|