@mccune1224/pi-pstack 0.1.0 → 0.2.0
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/README.md +52 -100
- package/agents/comment-sicko.md +1 -1
- package/agents/poteto-agent.md +2 -2
- package/docs/compare.md +122 -0
- package/docs/guide/01-setup.md +37 -12
- package/docs/guide/02-poteto-mode.md +1 -1
- package/docs/guide/03-understand.md +0 -8
- package/docs/guide/05-build-and-clean.md +1 -1
- package/docs/guide/06-verify-and-ship.md +1 -1
- package/docs/guide/07-overnight.md +2 -2
- package/docs/guide/08-principles.md +4 -2
- package/docs/guide/10-recipes-and-pitfalls.md +2 -2
- package/docs/guide/README.md +2 -2
- package/extensions/compaction.test.ts +132 -0
- package/extensions/compaction.ts +183 -0
- package/extensions/context-guard.test.ts +53 -0
- package/extensions/context-guard.ts +65 -0
- package/extensions/doctor.test.ts +85 -0
- package/extensions/doctor.ts +100 -0
- package/extensions/index.ts +289 -26
- package/extensions/settings.test.ts +71 -16
- package/extensions/settings.ts +57 -9
- package/extensions/skill-budget.test.ts +110 -0
- package/extensions/skill-budget.ts +101 -0
- package/package.json +9 -4
- package/scripts/check-pi-isms.mjs +106 -0
- package/scripts/lib/pi-delta-rules.mjs +110 -0
- package/scripts/lib/pi-delta-rules.test.mjs +100 -0
- package/scripts/lib/upstream-vendor.mjs +32 -0
- package/scripts/pi-deltas.json +754 -0
- package/scripts/sync-upstream.mjs +117 -0
- package/scripts/verify-guards.mjs +132 -0
- package/scripts/verify-sync.mjs +206 -0
- package/skills/architect/SKILL.md +10 -10
- package/skills/architect/references/rationale-template.md +2 -2
- package/skills/architect/references/runner-prompt.md +7 -7
- package/skills/arena/SKILL.md +11 -11
- package/skills/automate-me/SKILL.md +21 -26
- package/skills/blast-radius/SKILL.md +5 -5
- package/skills/create-verification-skill/SKILL.md +5 -5
- package/skills/figure-it-out/SKILL.md +11 -13
- package/skills/how/SKILL.md +30 -104
- package/skills/how/references/explainer-prompt.md +6 -6
- package/skills/how/references/explorer-prompt.md +1 -1
- package/skills/interrogate/SKILL.md +13 -14
- package/skills/interrogate/references/code-quality-review.md +1 -1
- package/skills/interrogate/references/lead-judgment.md +1 -1
- package/skills/interrogate/references/rubric.md +2 -2
- package/skills/maintain-verification-skill/SKILL.md +5 -5
- package/skills/no-comments/SKILL.md +3 -3
- package/skills/poteto-mode/SKILL.md +9 -8
- package/skills/poteto-mode/playbooks/authoring-a-skill.md +4 -4
- package/skills/poteto-mode/playbooks/autonomous-run.md +4 -4
- package/skills/poteto-mode/playbooks/autopilot-full.md +9 -9
- package/skills/poteto-mode/playbooks/autopilot-stack.md +8 -8
- package/skills/poteto-mode/playbooks/babysit.md +17 -17
- package/skills/poteto-mode/playbooks/bug-fix.md +6 -6
- package/skills/poteto-mode/playbooks/eval.md +6 -8
- package/skills/poteto-mode/playbooks/feature.md +7 -7
- package/skills/poteto-mode/playbooks/hillclimb.md +10 -10
- package/skills/poteto-mode/playbooks/investigation.md +3 -3
- package/skills/poteto-mode/playbooks/multi-phase-plan.md +26 -25
- package/skills/poteto-mode/playbooks/opening-a-pr.md +17 -13
- package/skills/poteto-mode/playbooks/orchestrate.md +37 -39
- package/skills/poteto-mode/playbooks/pause-safely.md +4 -4
- package/skills/poteto-mode/playbooks/perf-issue.md +9 -9
- package/skills/poteto-mode/playbooks/prototype.md +5 -5
- package/skills/poteto-mode/playbooks/refactoring.md +9 -9
- package/skills/poteto-mode/playbooks/runtime-forensics.md +3 -3
- package/skills/poteto-mode/playbooks/session-pickup.md +4 -6
- package/skills/poteto-mode/playbooks/shipping.md +11 -14
- package/skills/poteto-mode/playbooks/trace-forensics.md +4 -4
- package/skills/poteto-mode/playbooks/visual-parity.md +3 -3
- package/skills/poteto-mode/playbooks/worktree-cleanup.md +4 -4
- package/skills/poteto-mode/references/bugbot-triage.md +3 -3
- package/skills/poteto-mode/scripts/worktree-audit.mjs +0 -7
- package/skills/principle-attack-the-premise/SKILL.md +23 -0
- package/skills/principle-boundary-discipline/SKILL.md +2 -2
- package/skills/principle-build-the-lever/SKILL.md +5 -5
- package/skills/principle-encode-lessons-in-structure/SKILL.md +3 -3
- package/skills/principle-experience-first/SKILL.md +5 -5
- package/skills/principle-fix-root-causes/SKILL.md +4 -4
- package/skills/principle-foundational-thinking/SKILL.md +3 -3
- package/skills/principle-guard-the-context-window/SKILL.md +2 -2
- package/skills/principle-laziness-protocol/SKILL.md +2 -2
- package/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +1 -1
- package/skills/principle-minimize-reader-load/SKILL.md +2 -2
- package/skills/principle-model-the-domain/SKILL.md +3 -3
- package/skills/principle-never-block-on-the-human/SKILL.md +4 -5
- package/skills/principle-prove-it-works/SKILL.md +3 -3
- package/skills/principle-redesign-from-first-principles/SKILL.md +3 -3
- package/skills/principle-separate-before-serializing-shared-state/SKILL.md +3 -3
- package/skills/principle-sequence-verifiable-units/SKILL.md +3 -3
- package/skills/principle-subtract-before-you-add/SKILL.md +3 -4
- package/skills/principle-test-behavior-not-implementation/SKILL.md +25 -0
- package/skills/principle-type-system-discipline/SKILL.md +5 -5
- package/skills/recall/SKILL.md +7 -7
- package/skills/reflect/SKILL.md +14 -20
- package/skills/reflect/references/divergent-reviewer.md +3 -3
- package/skills/reflect/references/judgment-reviewer.md +3 -3
- package/skills/reflect/references/synthesizer.md +2 -2
- package/skills/reflect/references/tooling-reviewer.md +4 -6
- package/skills/setup-pstack/SKILL.md +61 -49
- package/skills/show-me-your-work/SKILL.md +17 -17
- package/skills/show-me-your-work/scripts/log.mjs +2 -8
- package/skills/swarm/SKILL.md +6 -6
- package/skills/tdd/SKILL.md +2 -2
- package/skills/teach/SKILL.md +7 -7
- package/skills/technical-writing/SKILL.md +5 -8
- package/skills/typescript-best-practices/SKILL.md +6 -3
- package/skills/typescript-best-practices/references/patterns.md +35 -14
- package/skills/unslop/SKILL.md +8 -20
- package/skills/why/SKILL.md +27 -100
- package/skills/why/references/epistemics.md +6 -6
- package/skills/why/references/investigator-prompt.md +5 -5
- package/skills/why/references/source-playbook.md +1 -1
- package/skills/why/references/sources/databricks.md +7 -7
- package/skills/why/references/sources/datadog.md +5 -5
- package/skills/why/references/sources/incident-postmortem.md +2 -2
- package/skills/why/references/sources/linear.md +2 -2
- package/skills/why/references/sources/notion.md +3 -3
- package/skills/why/references/sources/sentry.md +4 -4
- package/skills/why/references/sources/slack.md +2 -2
- package/skills/why/references/synthesizer-prompt.md +3 -3
- package/skills/how/references/critic-prompt.md +0 -59
- package/skills/how/references/critique-rubric.md +0 -58
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
POTETO_COMPACT_INSTRUCTIONS,
|
|
5
|
+
SUMMARY_SYSTEM_FRAME,
|
|
6
|
+
buildSummaryPrompt,
|
|
7
|
+
loadConversationHelpers,
|
|
8
|
+
runPotetoCompaction,
|
|
9
|
+
} from "./compaction.ts";
|
|
10
|
+
|
|
11
|
+
const PREPARATION = {
|
|
12
|
+
firstKeptEntryId: "entry-9",
|
|
13
|
+
messagesToSummarize: [{ role: "user", content: "fix the webhook" }],
|
|
14
|
+
turnPrefixMessages: [],
|
|
15
|
+
tokensBefore: 148_000,
|
|
16
|
+
previousSummary: undefined,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
test("buildSummaryPrompt demands the poteto sections and keeps the conversation inline", () => {
|
|
20
|
+
const prompt = buildSummaryPrompt("<raw transcript>");
|
|
21
|
+
for (const section of [
|
|
22
|
+
"## Playbook and position",
|
|
23
|
+
"## Decision ledger",
|
|
24
|
+
"## Verified and unverified",
|
|
25
|
+
"## Files and artifacts",
|
|
26
|
+
"## Next actions",
|
|
27
|
+
"## Open questions",
|
|
28
|
+
]) {
|
|
29
|
+
assert.ok(prompt.includes(section), section);
|
|
30
|
+
}
|
|
31
|
+
assert.ok(prompt.includes("<conversation><raw transcript></conversation>"));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("buildSummaryPrompt folds in custom instructions and the previous summary", () => {
|
|
35
|
+
const prompt = buildSummaryPrompt("t", {
|
|
36
|
+
customInstructions: "focus on the retry path",
|
|
37
|
+
previousSummary: "earlier run fixed the parser",
|
|
38
|
+
});
|
|
39
|
+
assert.ok(prompt.includes("focus on the retry path"));
|
|
40
|
+
assert.ok(prompt.includes("<previous_summary>earlier run fixed the parser</previous_summary>"));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("runPotetoCompaction returns the compaction Pi expects", async () => {
|
|
44
|
+
const seen: { model: unknown; context: unknown; options: unknown }[] = [];
|
|
45
|
+
const result = await runPotetoCompaction(PREPARATION, undefined, new AbortController().signal, {
|
|
46
|
+
model: { provider: "test", id: "parent-model" },
|
|
47
|
+
complete: async (model, context, options) => {
|
|
48
|
+
seen.push({ model, context, options });
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text", text: "## Playbook and position\nbug-fix, step 4" }],
|
|
51
|
+
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0 },
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
serialize: (messages) => `serialized:${messages.length}`,
|
|
55
|
+
});
|
|
56
|
+
assert.ok(result);
|
|
57
|
+
assert.equal(result.summary, "## Playbook and position\nbug-fix, step 4");
|
|
58
|
+
assert.equal(result.firstKeptEntryId, "entry-9");
|
|
59
|
+
assert.equal(result.tokensBefore, 148_000);
|
|
60
|
+
const call = seen[0];
|
|
61
|
+
assert.equal((call.context as { systemPrompt: string }).systemPrompt, SUMMARY_SYSTEM_FRAME);
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
(call.options as { maxTokens: number; cacheRetention: string }).maxTokens !== undefined,
|
|
64
|
+
true,
|
|
65
|
+
);
|
|
66
|
+
const text = (call.context as { messages: Array<{ content: Array<{ text: string }> }> }).messages[0]
|
|
67
|
+
.content[0].text;
|
|
68
|
+
assert.ok(text.includes("serialized:1"));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("runPotetoCompaction falls back to default on empty summary, error, and missing model", async () => {
|
|
72
|
+
const notes: string[] = [];
|
|
73
|
+
const notify = (message: string) => notes.push(message);
|
|
74
|
+
const empty = await runPotetoCompaction(PREPARATION, undefined, new AbortController().signal, {
|
|
75
|
+
model: { provider: "test", id: "m" },
|
|
76
|
+
complete: async () => ({ content: [{ type: "text", text: " " }] }),
|
|
77
|
+
serialize: () => "s",
|
|
78
|
+
notify,
|
|
79
|
+
});
|
|
80
|
+
assert.equal(empty, undefined);
|
|
81
|
+
assert.equal(notes.length, 1);
|
|
82
|
+
|
|
83
|
+
const failed = await runPotetoCompaction(PREPARATION, undefined, new AbortController().signal, {
|
|
84
|
+
model: { provider: "test", id: "m" },
|
|
85
|
+
complete: async () => {
|
|
86
|
+
throw new Error("provider down");
|
|
87
|
+
},
|
|
88
|
+
serialize: () => "s",
|
|
89
|
+
notify,
|
|
90
|
+
});
|
|
91
|
+
assert.equal(failed, undefined);
|
|
92
|
+
assert.ok(notes[1].includes("provider down"), notes[1]);
|
|
93
|
+
|
|
94
|
+
const noModel = await runPotetoCompaction(PREPARATION, undefined, new AbortController().signal, {
|
|
95
|
+
model: undefined,
|
|
96
|
+
complete: async () => {
|
|
97
|
+
throw new Error("must not be called");
|
|
98
|
+
},
|
|
99
|
+
serialize: () => "s",
|
|
100
|
+
});
|
|
101
|
+
assert.equal(noModel, undefined);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("runPotetoCompaction honors an aborted signal without a fallback notify", async () => {
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
controller.abort();
|
|
107
|
+
const notes: string[] = [];
|
|
108
|
+
const result = await runPotetoCompaction(PREPARATION, undefined, controller.signal, {
|
|
109
|
+
model: { provider: "test", id: "m" },
|
|
110
|
+
complete: async () => {
|
|
111
|
+
throw new Error("aborted");
|
|
112
|
+
},
|
|
113
|
+
serialize: () => "s",
|
|
114
|
+
notify: (message) => notes.push(message),
|
|
115
|
+
});
|
|
116
|
+
assert.equal(result, undefined);
|
|
117
|
+
assert.equal(notes.length, 0, "a user abort is not a failure worth reporting");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("POTETO_COMPACT_INSTRUCTIONS covers the run state a compaction must keep", () => {
|
|
121
|
+
for (const needle of ["playbook", "decision ledger", "verified", "next actions"]) {
|
|
122
|
+
assert.ok(POTETO_COMPACT_INSTRUCTIONS.includes(needle), needle);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("loadConversationHelpers resolves in a dev checkout and degrades gracefully", async () => {
|
|
127
|
+
const helpers = await loadConversationHelpers();
|
|
128
|
+
if (helpers) {
|
|
129
|
+
const text = helpers.serialize([{ role: "user", content: "hello" }]);
|
|
130
|
+
assert.ok(typeof text === "string" && text.length > 0);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poteto-flavored compaction for Poteto Mode.
|
|
3
|
+
*
|
|
4
|
+
* Pi's default compaction summarizes the conversation generically. When Poteto
|
|
5
|
+
* Mode is on, the run's value lives in specific places: the matched playbook
|
|
6
|
+
* and its step position, the decision ledger with the principles cited, the
|
|
7
|
+
* evidence of what has been verified, and the next actions. This module asks
|
|
8
|
+
* the session model for a summary shaped around those, and falls back to Pi's
|
|
9
|
+
* default compaction for any failure.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import type { CompactionResult } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
/** Passed to ctx.compact() when the context guard offers a one-key compaction. */
|
|
16
|
+
export const POTETO_COMPACT_INSTRUCTIONS =
|
|
17
|
+
"Preserve the pstack run state: the active playbook and its current step, " +
|
|
18
|
+
"the decision ledger with the principle each decision cited, what has been " +
|
|
19
|
+
"verified and how, file paths touched, and the next actions. Drop narration, " +
|
|
20
|
+
"raw tool output, and abandoned branches.";
|
|
21
|
+
|
|
22
|
+
export const SUMMARY_SYSTEM_FRAME =
|
|
23
|
+
"You are summarizing a coding-agent session so work can continue after the " +
|
|
24
|
+
"oldest messages are dropped. The session ran under Pstack Poteto Mode. " +
|
|
25
|
+
"Be dense and concrete. No narration about the summarization itself.";
|
|
26
|
+
|
|
27
|
+
export function buildSummaryPrompt(conversationText: string, extra?: {
|
|
28
|
+
previousSummary?: string;
|
|
29
|
+
customInstructions?: string;
|
|
30
|
+
}): string {
|
|
31
|
+
const sections = [
|
|
32
|
+
"Summarize the conversation between the <conversation> tags. Structure the summary as markdown with exactly these sections:",
|
|
33
|
+
"",
|
|
34
|
+
"## Playbook and position",
|
|
35
|
+
"The matched playbook, the step the run is on, and the steps completed with their verdicts.",
|
|
36
|
+
"",
|
|
37
|
+
"## Decision ledger",
|
|
38
|
+
"One row per decision: what was decided, the principle cited for it, the evidence behind it.",
|
|
39
|
+
"",
|
|
40
|
+
"## Verified and unverified",
|
|
41
|
+
"What has been proven against the real artifact, with the proof. What is still claimed but unverified.",
|
|
42
|
+
"",
|
|
43
|
+
"## Files and artifacts",
|
|
44
|
+
"Every file touched or produced, with its path and what changed in it.",
|
|
45
|
+
"",
|
|
46
|
+
"## Next actions",
|
|
47
|
+
"The exact next steps, in order, with enough detail to execute them without re-reading the dropped messages.",
|
|
48
|
+
"",
|
|
49
|
+
"## Open questions",
|
|
50
|
+
"Blockers, unanswered questions, and promises made to the operator.",
|
|
51
|
+
"",
|
|
52
|
+
"Keep every file path, command, and measurement exact. Drop small talk, retries that ended in revert, and tool output that a file on disk already holds.",
|
|
53
|
+
];
|
|
54
|
+
if (extra?.customInstructions) {
|
|
55
|
+
sections.push("", `The operator added this instruction for the summary: ${extra.customInstructions}`);
|
|
56
|
+
}
|
|
57
|
+
if (extra?.previousSummary) {
|
|
58
|
+
sections.push(
|
|
59
|
+
"",
|
|
60
|
+
"An earlier compaction produced this summary. Fold it in: keep what it holds that still matters, and update anything the newer conversation changed.",
|
|
61
|
+
"",
|
|
62
|
+
`<previous_summary>${extra.previousSummary}</previous_summary>`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
sections.push("", `<conversation>${conversationText}</conversation>`);
|
|
66
|
+
return sections.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CompactionPreparationLike {
|
|
70
|
+
firstKeptEntryId: string;
|
|
71
|
+
messagesToSummarize: unknown[];
|
|
72
|
+
turnPrefixMessages: unknown[];
|
|
73
|
+
tokensBefore: number;
|
|
74
|
+
previousSummary?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface TextBlockLike {
|
|
78
|
+
type: string;
|
|
79
|
+
text?: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractText(content: unknown): string {
|
|
83
|
+
if (typeof content === "string") return content;
|
|
84
|
+
if (!Array.isArray(content)) return "";
|
|
85
|
+
return (content as TextBlockLike[])
|
|
86
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
87
|
+
.map((block) => block.text as string)
|
|
88
|
+
.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Runs the poteto compaction. Returns the compaction result Pi expects, or
|
|
93
|
+
* undefined to let Pi run its default compaction. Never throws: every failure
|
|
94
|
+
* path degrades to the default.
|
|
95
|
+
*/
|
|
96
|
+
export type UsageLike = NonNullable<CompactionResult["usage"]>;
|
|
97
|
+
|
|
98
|
+
export async function runPotetoCompaction(
|
|
99
|
+
preparation: CompactionPreparationLike,
|
|
100
|
+
customInstructions: string | undefined,
|
|
101
|
+
signal: AbortSignal,
|
|
102
|
+
deps: {
|
|
103
|
+
model: unknown;
|
|
104
|
+
complete: (model: unknown, context: unknown, options: unknown) => Promise<unknown>;
|
|
105
|
+
serialize: (messages: unknown[]) => string;
|
|
106
|
+
notify?: (message: string, level?: "info" | "warning" | "error") => void;
|
|
107
|
+
},
|
|
108
|
+
): Promise<{
|
|
109
|
+
summary: string;
|
|
110
|
+
firstKeptEntryId: string;
|
|
111
|
+
tokensBefore: number;
|
|
112
|
+
usage?: UsageLike;
|
|
113
|
+
} | undefined> {
|
|
114
|
+
if (!deps.model) return undefined;
|
|
115
|
+
try {
|
|
116
|
+
const messages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
|
|
117
|
+
const conversationText = deps.serialize(messages);
|
|
118
|
+
const prompt = buildSummaryPrompt(conversationText, {
|
|
119
|
+
previousSummary: preparation.previousSummary,
|
|
120
|
+
customInstructions,
|
|
121
|
+
});
|
|
122
|
+
const response = (await deps.complete(
|
|
123
|
+
deps.model,
|
|
124
|
+
{
|
|
125
|
+
messages: [
|
|
126
|
+
{
|
|
127
|
+
role: "user",
|
|
128
|
+
content: [{ type: "text", text: prompt }],
|
|
129
|
+
timestamp: Date.now(),
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
systemPrompt: SUMMARY_SYSTEM_FRAME,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
maxTokens: 8192,
|
|
136
|
+
signal,
|
|
137
|
+
cacheRetention: "none",
|
|
138
|
+
sessionId: randomUUID(),
|
|
139
|
+
},
|
|
140
|
+
)) as { content?: unknown; usage?: UsageLike } | undefined;
|
|
141
|
+
|
|
142
|
+
const summary = extractText(response?.content).trim();
|
|
143
|
+
if (!summary) {
|
|
144
|
+
deps.notify?.("pstack compaction: empty summary, using Pi's default", "warning");
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
summary,
|
|
149
|
+
firstKeptEntryId: preparation.firstKeptEntryId,
|
|
150
|
+
tokensBefore: preparation.tokensBefore,
|
|
151
|
+
usage: response?.usage,
|
|
152
|
+
};
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (signal.aborted) return undefined;
|
|
155
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
156
|
+
deps.notify?.(`pstack compaction failed (${message}); using Pi's default`, "warning");
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Loads the runtime conversation helpers from the pi-coding-agent install.
|
|
163
|
+
* Returns undefined when they cannot be resolved, which is the signal to fall
|
|
164
|
+
* back to default compaction. Static imports would make the whole extension
|
|
165
|
+
* fail to load on installs where the host package is not resolvable from the
|
|
166
|
+
* package directory.
|
|
167
|
+
*/
|
|
168
|
+
export async function loadConversationHelpers(): Promise<{
|
|
169
|
+
serialize: (messages: unknown[]) => string;
|
|
170
|
+
} | undefined> {
|
|
171
|
+
try {
|
|
172
|
+
const core = (await import("@earendil-works/pi-coding-agent")) as {
|
|
173
|
+
convertToLlm?: (messages: unknown[]) => unknown[];
|
|
174
|
+
serializeConversation?: (messages: unknown[]) => string;
|
|
175
|
+
};
|
|
176
|
+
if (!core.convertToLlm || !core.serializeConversation) return undefined;
|
|
177
|
+
return {
|
|
178
|
+
serialize: (messages: unknown[]) => core.serializeConversation!(core.convertToLlm!(messages)),
|
|
179
|
+
};
|
|
180
|
+
} catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
GUARD_THRESHOLDS,
|
|
5
|
+
formatPercent,
|
|
6
|
+
guardAdvice,
|
|
7
|
+
guardLevel,
|
|
8
|
+
isStronger,
|
|
9
|
+
meterLine,
|
|
10
|
+
} from "./context-guard.ts";
|
|
11
|
+
|
|
12
|
+
test("guardLevel maps usage percentages onto the ladder", () => {
|
|
13
|
+
assert.equal(guardLevel(null), "ok");
|
|
14
|
+
assert.equal(guardLevel(undefined), "ok");
|
|
15
|
+
assert.equal(guardLevel(Number.NaN), "ok");
|
|
16
|
+
assert.equal(guardLevel(0), "ok");
|
|
17
|
+
assert.equal(guardLevel(GUARD_THRESHOLDS.watch - 1), "ok");
|
|
18
|
+
assert.equal(guardLevel(GUARD_THRESHOLDS.watch), "watch");
|
|
19
|
+
assert.equal(guardLevel(GUARD_THRESHOLDS.warn - 1), "watch");
|
|
20
|
+
assert.equal(guardLevel(GUARD_THRESHOLDS.warn), "warn");
|
|
21
|
+
assert.equal(guardLevel(GUARD_THRESHOLDS.compact - 1), "warn");
|
|
22
|
+
assert.equal(guardLevel(GUARD_THRESHOLDS.compact), "compact");
|
|
23
|
+
assert.equal(guardLevel(100), "compact");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("isStronger orders the ladder", () => {
|
|
27
|
+
assert.ok(isStronger("warn", "watch"));
|
|
28
|
+
assert.ok(isStronger("compact", "warn"));
|
|
29
|
+
assert.ok(!isStronger("watch", "warn"));
|
|
30
|
+
assert.ok(!isStronger("ok", "ok"));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("formatPercent rounds and tolerates unknown usage", () => {
|
|
34
|
+
assert.equal(formatPercent(42.4), "42%");
|
|
35
|
+
assert.equal(formatPercent(42.5), "43%");
|
|
36
|
+
assert.equal(formatPercent(null), "?");
|
|
37
|
+
assert.equal(formatPercent(undefined), "?");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("guardAdvice names the percentage and an action at every level", () => {
|
|
41
|
+
for (const level of ["watch", "warn", "compact"] as const) {
|
|
42
|
+
const advice = guardAdvice(level, 91);
|
|
43
|
+
assert.ok(advice.startsWith("pstack context guard: 91%"), advice);
|
|
44
|
+
assert.ok(advice.length > 40, `level ${level} carries real advice`);
|
|
45
|
+
}
|
|
46
|
+
assert.equal(guardAdvice("ok", 10), "");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("meterLine renders the HUD line", () => {
|
|
50
|
+
const line = meterLine({ tokens: 42000, contextWindow: 200000, percent: 21 }, "medium");
|
|
51
|
+
assert.equal(line, "context: 21% of 200k | budget: medium");
|
|
52
|
+
assert.equal(meterLine(undefined, "unlimited"), "context: ? of 0k | budget: unlimited");
|
|
53
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context guard for Poteto Mode.
|
|
3
|
+
*
|
|
4
|
+
* Pi meters context usage; the harness can act on it in a way no skill file
|
|
5
|
+
* can. This module holds the pure decision logic: map a usage percentage to a
|
|
6
|
+
* guard level, and say what to do about it. The wiring lives in index.ts.
|
|
7
|
+
*
|
|
8
|
+
* The thresholds back the guard-the-context-window principle: finish the
|
|
9
|
+
* current step, capture the decision state, then free context deliberately
|
|
10
|
+
* instead of letting the automatic cut take the tail of the run.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ContextUsageLike {
|
|
14
|
+
tokens: number | null;
|
|
15
|
+
contextWindow: number;
|
|
16
|
+
percent: number | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type GuardLevel = "ok" | "watch" | "warn" | "compact";
|
|
20
|
+
|
|
21
|
+
/** Percent-of-context-window thresholds, ascending. */
|
|
22
|
+
export const GUARD_THRESHOLDS = {
|
|
23
|
+
watch: 70,
|
|
24
|
+
warn: 85,
|
|
25
|
+
compact: 92,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
const LEVEL_ORDER: Record<GuardLevel, number> = { ok: 0, watch: 1, warn: 2, compact: 3 };
|
|
29
|
+
|
|
30
|
+
export function guardLevel(percent: number | null | undefined): GuardLevel {
|
|
31
|
+
if (percent === null || percent === undefined || Number.isNaN(percent)) return "ok";
|
|
32
|
+
if (percent >= GUARD_THRESHOLDS.compact) return "compact";
|
|
33
|
+
if (percent >= GUARD_THRESHOLDS.warn) return "warn";
|
|
34
|
+
if (percent >= GUARD_THRESHOLDS.watch) return "watch";
|
|
35
|
+
return "ok";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isStronger(a: GuardLevel, b: GuardLevel): boolean {
|
|
39
|
+
return LEVEL_ORDER[a] > LEVEL_ORDER[b];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatPercent(percent: number | null | undefined): string {
|
|
43
|
+
if (percent === null || percent === undefined || Number.isNaN(percent)) return "?";
|
|
44
|
+
return `${Math.round(percent)}%`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ADVICE: Record<Exclude<GuardLevel, "ok">, string> = {
|
|
48
|
+
watch:
|
|
49
|
+
"Context is filling up. Finish the current step, then hand long output to a file and point at it instead of pasting it back.",
|
|
50
|
+
warn:
|
|
51
|
+
"Context is high. Capture the decision state now: the active playbook, the step it is on, and what has been verified. Then run /compact, or accept the compaction offer.",
|
|
52
|
+
compact:
|
|
53
|
+
"Context is nearly full. Pi is about to compact on its own and the cut point is not yours to choose. Run /compact now to keep the playbook and evidence state intact.",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function guardAdvice(level: GuardLevel, percent: number | null | undefined): string {
|
|
57
|
+
if (level === "ok") return "";
|
|
58
|
+
return `pstack context guard: ${formatPercent(percent)} of the context window. ${ADVICE[level]}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One HUD line for the poteto-mode widget. */
|
|
62
|
+
export function meterLine(usage: ContextUsageLike | undefined, budget: string): string {
|
|
63
|
+
const percent = usage?.percent ?? null;
|
|
64
|
+
return `context: ${formatPercent(percent)} of ${Math.round((usage?.contextWindow ?? 0) / 1000)}k | budget: ${budget}`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
checkAgentFrontmatter,
|
|
5
|
+
checkModelMap,
|
|
6
|
+
checkModelValue,
|
|
7
|
+
checkThinkingValue,
|
|
8
|
+
renderReport,
|
|
9
|
+
} from "./doctor.ts";
|
|
10
|
+
|
|
11
|
+
test("checkModelValue accepts inherit and provider/id shapes only", () => {
|
|
12
|
+
assert.equal(checkModelValue("inherit"), null);
|
|
13
|
+
assert.equal(checkModelValue("opencode-go/hy3"), null);
|
|
14
|
+
assert.ok(checkModelValue("just-a-slug")?.includes("provider/id"));
|
|
15
|
+
assert.ok(checkModelValue("openrouter anthropic/claude")?.includes("provider/id"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("checkThinkingValue rejects anything off Pi's ladder", () => {
|
|
19
|
+
assert.equal(checkThinkingValue("high"), null);
|
|
20
|
+
assert.equal(checkThinkingValue("xhigh"), null);
|
|
21
|
+
assert.ok(checkThinkingValue("inherit")?.includes("not a Pi level"));
|
|
22
|
+
assert.ok(checkThinkingValue("maximum")?.includes("not a Pi level"));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("checkModelMap flags typos, bad values, and bad fallbacks", () => {
|
|
26
|
+
const problems = checkModelMap(
|
|
27
|
+
{
|
|
28
|
+
defaultModel: "inherit",
|
|
29
|
+
defaultThinking: "medium",
|
|
30
|
+
agentOverrides: {
|
|
31
|
+
scout: { thinking: "low" },
|
|
32
|
+
wroker: { model: "nope" },
|
|
33
|
+
oracle: { model: "a/b", thinking: "max", fallbackModels: ["inherit", "bad fallback"] },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
["scout", "worker", "reviewer", "oracle"],
|
|
37
|
+
);
|
|
38
|
+
assert.equal(problems.length, 2, problems.join("; "));
|
|
39
|
+
assert.ok(problems.some((line) => line.includes("wroker") && line.includes("unknown role")));
|
|
40
|
+
assert.ok(problems.some((line) => line.includes("fallbackModels") && line.includes("provider/id")));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("checkModelMap passes a healthy map", () => {
|
|
44
|
+
const problems = checkModelMap(
|
|
45
|
+
{
|
|
46
|
+
defaultModel: "inherit",
|
|
47
|
+
defaultThinking: "xhigh",
|
|
48
|
+
agentOverrides: {
|
|
49
|
+
scout: { model: "inherit", thinking: "low" },
|
|
50
|
+
oracle: { model: "a/b", fallbackModels: ["c/d"] },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
["scout", "oracle"],
|
|
54
|
+
);
|
|
55
|
+
assert.deepEqual(problems, []);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("checkAgentFrontmatter requires a frontmatter block with name and description", () => {
|
|
59
|
+
assert.deepEqual(
|
|
60
|
+
checkAgentFrontmatter("poteto-agent.md", "---\nname: poteto-agent\ndescription: runs the mode\n---\nbody"),
|
|
61
|
+
[],
|
|
62
|
+
);
|
|
63
|
+
assert.equal(
|
|
64
|
+
checkAgentFrontmatter("broken.md", "no frontmatter here").length,
|
|
65
|
+
1,
|
|
66
|
+
);
|
|
67
|
+
const missing = checkAgentFrontmatter("thin.md", "---\nname: thin\n---\nbody");
|
|
68
|
+
assert.equal(missing.length, 1);
|
|
69
|
+
assert.ok(missing[0].includes("description"));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("renderReport summarizes and counts findings", () => {
|
|
73
|
+
const clean = renderReport([
|
|
74
|
+
{ name: "settings", status: "ok", detail: "parsed" },
|
|
75
|
+
{ name: "agents", status: "ok", detail: "2 registered" },
|
|
76
|
+
]);
|
|
77
|
+
assert.ok(clean.endsWith("pstack doctor: clean. Nothing to fix."));
|
|
78
|
+
const noisy = renderReport([
|
|
79
|
+
{ name: "settings", status: "warn", detail: "unknown role" },
|
|
80
|
+
{ name: "pin", status: "fail", detail: "stale" },
|
|
81
|
+
]);
|
|
82
|
+
assert.ok(noisy.includes("warn settings"));
|
|
83
|
+
assert.ok(noisy.includes("FAIL pin"));
|
|
84
|
+
assert.ok(noisy.endsWith("pstack doctor: 2 findings."));
|
|
85
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /pstack-doctor: one command that checks the install the way a colleague
|
|
3
|
+
* would, offline. Pure validators live here; index.ts gathers the inputs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ModelMap, ThinkingLevel } from "./settings.ts";
|
|
7
|
+
|
|
8
|
+
export const PI_THINKING_LEVELS: readonly string[] = [
|
|
9
|
+
"off",
|
|
10
|
+
"minimal",
|
|
11
|
+
"low",
|
|
12
|
+
"medium",
|
|
13
|
+
"high",
|
|
14
|
+
"xhigh",
|
|
15
|
+
"max",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export interface DoctorCheck {
|
|
19
|
+
name: string;
|
|
20
|
+
status: "ok" | "warn" | "fail";
|
|
21
|
+
detail: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** "inherit" is always valid. Anything else must look like provider/id. */
|
|
25
|
+
export function checkModelValue(value: string): string | null {
|
|
26
|
+
if (value === "inherit") return null;
|
|
27
|
+
if (!value.includes("/") || /\s/.test(value)) {
|
|
28
|
+
return `model "${value}" is neither inherit nor provider/id`;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function checkThinkingValue(value: string): string | null {
|
|
34
|
+
return PI_THINKING_LEVELS.includes(value) ? null : `thinking "${value}" is not a Pi level (${PI_THINKING_LEVELS.join(", ")})`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Returns one human line per problem found in the map. */
|
|
38
|
+
export function checkModelMap(map: ModelMap, knownRoles: readonly string[]): string[] {
|
|
39
|
+
const problems: string[] = [];
|
|
40
|
+
if (map.defaultModel) {
|
|
41
|
+
const problem = checkModelValue(map.defaultModel);
|
|
42
|
+
if (problem) problems.push(`defaultModel: ${problem}`);
|
|
43
|
+
}
|
|
44
|
+
if (map.defaultThinking) {
|
|
45
|
+
const problem = checkThinkingValue(map.defaultThinking);
|
|
46
|
+
if (problem) problems.push(`defaultThinking: ${problem}`);
|
|
47
|
+
}
|
|
48
|
+
for (const [role, spec] of Object.entries(map.agentOverrides ?? {})) {
|
|
49
|
+
if (!knownRoles.includes(role)) {
|
|
50
|
+
problems.push(
|
|
51
|
+
`agentOverrides.${role}: unknown role (known: ${knownRoles.join(", ")}) - a typo here silently does nothing`,
|
|
52
|
+
);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (spec?.model) {
|
|
56
|
+
const problem = checkModelValue(spec.model);
|
|
57
|
+
if (problem) problems.push(`agentOverrides.${role}.model: ${problem}`);
|
|
58
|
+
}
|
|
59
|
+
if (spec?.thinking) {
|
|
60
|
+
const problem = checkThinkingValue(spec.thinking);
|
|
61
|
+
if (problem) problems.push(`agentOverrides.${role}.thinking: ${problem}`);
|
|
62
|
+
}
|
|
63
|
+
for (const fallback of spec?.fallbackModels ?? []) {
|
|
64
|
+
const problem = checkModelValue(fallback);
|
|
65
|
+
if (problem) problems.push(`agentOverrides.${role}.fallbackModels: ${problem}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return problems;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Minimal frontmatter check for a package agent file. Returns one line per
|
|
73
|
+
* problem. pi-subagents reads name and description from this block.
|
|
74
|
+
*/
|
|
75
|
+
export function checkAgentFrontmatter(fileName: string, content: string): string[] {
|
|
76
|
+
const problems: string[] = [];
|
|
77
|
+
const frontmatter = /^---\n([\s\S]*?)\n---/.exec(content)?.[1];
|
|
78
|
+
if (!frontmatter) {
|
|
79
|
+
problems.push(`${fileName}: no frontmatter block; pi-subagents cannot register it`);
|
|
80
|
+
return problems;
|
|
81
|
+
}
|
|
82
|
+
for (const field of ["name", "description"]) {
|
|
83
|
+
if (!new RegExp(`^${field}:`, "m").test(frontmatter)) {
|
|
84
|
+
problems.push(`${fileName}: frontmatter is missing "${field}"`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return problems;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function renderReport(checks: readonly DoctorCheck[]): string {
|
|
91
|
+
const mark = { ok: "ok ", warn: "warn", fail: "FAIL" } as const;
|
|
92
|
+
const lines = checks.map((check) => `${mark[check.status]} ${check.name}: ${check.detail}`);
|
|
93
|
+
const warnings = checks.filter((check) => check.status !== "ok").length;
|
|
94
|
+
lines.push(
|
|
95
|
+
warnings === 0
|
|
96
|
+
? "pstack doctor: clean. Nothing to fix."
|
|
97
|
+
: `pstack doctor: ${warnings} finding${warnings === 1 ? "" : "s"}.`,
|
|
98
|
+
);
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
}
|