@llblab/pi-kit 0.3.2 → 0.5.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/AGENTS.md +3 -3
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +10 -0
- package/README.md +19 -10
- package/node_modules/@llblab/pi-actors/AGENTS.md +1 -0
- package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +16 -6
- package/node_modules/@llblab/pi-actors/dist/skills/music-player/SKILL.md +3 -3
- package/node_modules/@llblab/pi-actors/dist/skills/music-player/genapps/music-player.mjs +6 -4
- package/node_modules/@llblab/pi-actors/dist/skills/music-player/scripts/playback.mjs +85 -18
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/SKILL.md +2 -6
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/references/development-swarm.md +2 -31
- package/node_modules/@llblab/pi-actors/docs/recipe-library.md +1 -1
- package/node_modules/@llblab/pi-actors/package.json +1 -1
- package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +16 -6
- package/node_modules/@llblab/pi-actors/skills/music-player/SKILL.md +3 -3
- package/node_modules/@llblab/pi-actors/skills/music-player/genapps/music-player.mjs +6 -4
- package/node_modules/@llblab/pi-actors/skills/music-player/scripts/playback.mjs +85 -18
- package/node_modules/@llblab/pi-actors/skills/swarm/SKILL.md +2 -6
- package/node_modules/@llblab/pi-actors/skills/swarm/references/development-swarm.md +2 -31
- package/node_modules/@llblab/pi-clean-room/AGENTS.md +5 -0
- package/node_modules/@llblab/pi-clean-room/BACKLOG.md +3 -0
- package/node_modules/@llblab/pi-clean-room/CHANGELOG.md +15 -0
- package/node_modules/@llblab/pi-clean-room/README.md +61 -0
- package/node_modules/@llblab/pi-clean-room/banner.jpg +0 -0
- package/node_modules/@llblab/pi-clean-room/index.ts +178 -0
- package/node_modules/@llblab/pi-clean-room/package.json +53 -0
- package/node_modules/@llblab/pi-grow-loop/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -0
- package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
- package/node_modules/@llblab/pi-grow-loop/skills/grow-loop/SKILL.md +3 -17
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +28 -0
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -0
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +79 -0
- package/node_modules/@llblab/pi-state-flow/README.md +197 -0
- package/node_modules/@llblab/pi-state-flow/index.ts +4 -0
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +88 -0
- package/node_modules/@llblab/pi-state-flow/lib/episode.ts +29 -0
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +238 -0
- package/node_modules/@llblab/pi-state-flow/lib/json.ts +78 -0
- package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +38 -0
- package/node_modules/@llblab/pi-state-flow/lib/session.ts +49 -0
- package/node_modules/@llblab/pi-state-flow/lib/skills.ts +58 -0
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +103 -0
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +20 -0
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +16 -0
- package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +148 -0
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +47 -0
- package/node_modules/@llblab/pi-state-flow/lib/validation.ts +27 -0
- package/node_modules/@llblab/pi-state-flow/package.json +55 -0
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +3 -0
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
- package/node_modules/@llblab/pi-telegram/README.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -3
- package/node_modules/@llblab/pi-telegram/docs/compact-matrix-literal.md +39 -11
- package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +17 -5
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -4
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
- package/node_modules/@llblab/pi-telegram/index.ts +13 -1
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +12 -3
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +29 -18
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -6
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +11 -4
- package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +5 -3
- package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +72 -15
- package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +81 -9
- package/node_modules/@llblab/pi-telegram/lib/outbound.ts +2 -0
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +89 -32
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +74 -15
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +32 -1
- package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +17 -0
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +123 -15
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +4 -2
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/references/layout-and-state.md +4 -2
- package/node_modules/@llblab/pi-telegram/skills/generative-apps/SKILL.md +4 -3
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +19 -8
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +4 -2
- package/node_modules/@llblab/skills/abcd-context/AGENTS.md +40 -0
- package/node_modules/@llblab/skills/abcd-context/BACKLOG.md +3 -0
- package/node_modules/@llblab/skills/abcd-context/CHANGELOG.md +19 -0
- package/node_modules/@llblab/skills/abcd-context/README.md +30 -0
- package/node_modules/@llblab/skills/abcd-context/SKILL.md +122 -0
- package/node_modules/@llblab/skills/abcd-context/docs/README.md +9 -0
- package/node_modules/@llblab/skills/abcd-context/docs/protocols.md +199 -0
- package/node_modules/@llblab/skills/abcd-context/docs/templates.md +194 -0
- package/node_modules/@llblab/skills/abcd-context/docs/validation-design.md +117 -0
- package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/AGENTS.md +10 -0
- package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/BACKLOG.md +5 -0
- package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/CHANGELOG.md +5 -0
- package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/README.md +10 -0
- package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/docs/README.md +7 -0
- package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/docs/overview.md +3 -0
- package/node_modules/@llblab/skills/abcd-context/recipes/validate-context.json +7 -0
- package/node_modules/@llblab/skills/abcd-context/scripts/_self-test.mjs +244 -0
- package/node_modules/@llblab/skills/abcd-context/scripts/validate-context.mjs +497 -0
- package/node_modules/@llblab/skills/brain-storm/AGENTS.md +5 -0
- package/node_modules/@llblab/skills/brain-storm/SKILL.md +321 -0
- package/node_modules/@llblab/skills/coding-contract/SKILL.md +228 -0
- package/node_modules/@llblab/skills/domain-dag/AGENTS.md +6 -0
- package/node_modules/@llblab/skills/domain-dag/SKILL.md +257 -0
- package/node_modules/@llblab/skills/domain-dag/domain-dag.json +9 -0
- package/node_modules/@llblab/skills/domain-dag/recipes/validate-domain-dag.json +7 -0
- package/node_modules/@llblab/skills/domain-dag/scripts/validate-domain-dag.mjs +771 -0
- package/node_modules/@llblab/skills/domain-dag/scripts/validate-domain-dag.sh +4 -0
- package/node_modules/@llblab/skills/edge-tts/AGENTS.md +19 -0
- package/node_modules/@llblab/skills/edge-tts/SKILL.md +56 -0
- package/node_modules/@llblab/skills/edge-tts/recipes/say-edge.json +9 -0
- package/node_modules/@llblab/skills/edge-tts/scripts/say.mjs +1548 -0
- package/node_modules/@llblab/skills/edge-tts/scripts/say.sh +4 -0
- package/node_modules/@llblab/skills/extra-self/SKILL.md +229 -0
- package/node_modules/@llblab/skills/frontend-design/SKILL.md +78 -0
- package/node_modules/@llblab/skills/groq-stt/AGENTS.md +15 -0
- package/node_modules/@llblab/skills/groq-stt/SKILL.md +35 -0
- package/node_modules/@llblab/skills/groq-stt/recipes/transcribe-groq.json +10 -0
- package/node_modules/@llblab/skills/groq-stt/scripts/transcribe.mjs +190 -0
- package/node_modules/@llblab/skills/groq-stt/scripts/transcribe.sh +4 -0
- package/node_modules/@llblab/skills/mistral-stt/AGENTS.md +15 -0
- package/node_modules/@llblab/skills/mistral-stt/SKILL.md +35 -0
- package/node_modules/@llblab/skills/mistral-stt/recipes/transcribe-mistral.json +10 -0
- package/node_modules/@llblab/skills/mistral-stt/scripts/transcribe.mjs +191 -0
- package/node_modules/@llblab/skills/mistral-stt/scripts/transcribe.sh +4 -0
- package/node_modules/@llblab/skills/package.json +46 -0
- package/node_modules/@llblab/skills/re-review/SKILL.md +318 -0
- package/node_modules/@llblab/skills/release-flow/SKILL.md +456 -0
- package/node_modules/@llblab/skills/show-me/SKILL.md +144 -0
- package/package.json +20 -8
- package/node_modules/@llblab/pi-actors/dist/skills/music-player/scripts/playback-client.mjs +0 -143
- package/node_modules/@llblab/pi-actors/skills/music-player/scripts/playback-client.mjs +0 -143
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { assistantToolCallCount, finalizedAssistantResponse, parseTerminalPatch, stateFlowProtocol, stripStateComments } from "./terminal.ts";
|
|
4
|
+
import { currentRunTrajectory, runtimeContextMessage, VALIDATION_MESSAGE_TYPE, withoutPrivateValidation } from "./context.ts";
|
|
5
|
+
import { SkillReadTracker } from "./skills.ts";
|
|
6
|
+
import { migrationFailure, type Snapshot } from "./snapshot.ts";
|
|
7
|
+
import { emptyState } from "./state.ts";
|
|
8
|
+
import { commitTransition, stageTransition, type StagedTransition } from "./transition.ts";
|
|
9
|
+
import { MAX_VALIDATION_RETRIES, nextValidation } from "./validation.ts";
|
|
10
|
+
import { discoverSnapshotData, hasPriorConversation, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
|
|
11
|
+
import { compactStatus, detailedStatus, STATUS_KEY } from "./status.ts";
|
|
12
|
+
import { abandonValidation, prepareRun, startEpisode, stopEpisode } from "./episode.ts";
|
|
13
|
+
import { recoverSnapshot } from "./recovery.ts";
|
|
14
|
+
|
|
15
|
+
export default function stateFlowExtension(pi: ExtensionAPI): void {
|
|
16
|
+
let snapshot: Snapshot = { enabled: false, state: emptyState(), step: 0 };
|
|
17
|
+
let stagedFinal: StagedTransition | undefined;
|
|
18
|
+
let retryQueued = false;
|
|
19
|
+
let runAnchorTimestamp: number | undefined;
|
|
20
|
+
const skillReads = new SkillReadTracker();
|
|
21
|
+
|
|
22
|
+
function persist(): void {
|
|
23
|
+
pi.appendEntry(SNAPSHOT_ENTRY_TYPE, structuredClone(snapshot));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function updateUi(ctx: ExtensionContext): void {
|
|
27
|
+
ctx.ui.setStatus(STATUS_KEY, compactStatus(snapshot, (color, text) => ctx.ui.theme.fg(color, text)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function clearRunTransient(): void {
|
|
31
|
+
stagedFinal = undefined;
|
|
32
|
+
retryQueued = false;
|
|
33
|
+
runAnchorTimestamp = undefined;
|
|
34
|
+
skillReads.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function restoreActiveBranch(ctx: ExtensionContext): void {
|
|
38
|
+
clearRunTransient();
|
|
39
|
+
try {
|
|
40
|
+
const discovery = discoverSnapshotData(ctx.sessionManager.getBranch());
|
|
41
|
+
const recovery = recoverSnapshot(discovery.candidates);
|
|
42
|
+
const skipped = discovery.errors.length + recovery.skipped.length;
|
|
43
|
+
snapshot = discovery.candidates.length === 0 && discovery.errors.length > 0
|
|
44
|
+
? migrationFailure({}, `Snapshot restoration failed: ${discovery.errors[0]}`)
|
|
45
|
+
: recovery.snapshot;
|
|
46
|
+
if (snapshot.enabled && skipped > 0) {
|
|
47
|
+
ctx.ui.notify(`State Flow recovered the previous valid snapshot after skipping ${skipped} malformed newer snapshot(s).`, "warning");
|
|
48
|
+
}
|
|
49
|
+
} catch (error) {
|
|
50
|
+
snapshot = migrationFailure(
|
|
51
|
+
{},
|
|
52
|
+
`Snapshot restoration failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (!snapshot.enabled && snapshot.validation?.attempt === 0) {
|
|
56
|
+
ctx.ui.notify(`State Flow restored disabled: ${snapshot.validation.error}`, "error");
|
|
57
|
+
}
|
|
58
|
+
updateUi(ctx);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function abandonRun(ctx: ExtensionContext): void {
|
|
62
|
+
const changed = abandonValidation(snapshot);
|
|
63
|
+
clearRunTransient();
|
|
64
|
+
if (changed) persist();
|
|
65
|
+
updateUi(ctx);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function queueTerminalRegeneration(error: string, ctx: ExtensionContext): void {
|
|
69
|
+
const decision = nextValidation(snapshot.validation, error);
|
|
70
|
+
if (decision.kind === "retry") {
|
|
71
|
+
snapshot.validation = decision.feedback;
|
|
72
|
+
persist();
|
|
73
|
+
retryQueued = true;
|
|
74
|
+
pi.sendMessage({
|
|
75
|
+
customType: VALIDATION_MESSAGE_TYPE,
|
|
76
|
+
content: `State Flow rejected the terminal response (attempt ${decision.feedback.attempt}/${MAX_VALIDATION_RETRIES}). ${decision.feedback.instruction}`,
|
|
77
|
+
display: false,
|
|
78
|
+
}, { deliverAs: "steer", triggerTurn: true });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
retryQueued = false;
|
|
82
|
+
snapshot.validation = undefined;
|
|
83
|
+
skillReads.clear();
|
|
84
|
+
persist();
|
|
85
|
+
ctx.ui.notify(`State Flow remains enabled after ${MAX_VALIDATION_RETRIES} automatic regeneration attempts; the last committed state was preserved: ${decision.error}`, "error");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function rejectTerminal(message: { role: "assistant"; content?: unknown }, error: string, ctx: ExtensionContext) {
|
|
89
|
+
queueTerminalRegeneration(error, ctx);
|
|
90
|
+
return {
|
|
91
|
+
message: {
|
|
92
|
+
...message,
|
|
93
|
+
role: "assistant" as const,
|
|
94
|
+
content: [],
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
pi.registerCommand("state-flow-start", {
|
|
100
|
+
description: "Start State Flow mode",
|
|
101
|
+
handler: async (_args, ctx) => {
|
|
102
|
+
snapshot = startEpisode(hasPriorConversation(ctx.sessionManager.getBranch()));
|
|
103
|
+
stagedFinal = undefined;
|
|
104
|
+
retryQueued = false;
|
|
105
|
+
runAnchorTimestamp = undefined;
|
|
106
|
+
skillReads.clear();
|
|
107
|
+
persist();
|
|
108
|
+
updateUi(ctx);
|
|
109
|
+
ctx.ui.notify(
|
|
110
|
+
snapshot.bootstrap
|
|
111
|
+
? "State Flow enabled. The next complete agent run will migrate active context into state."
|
|
112
|
+
: "State Flow enabled. The next prompt starts a fresh stateful agent run.",
|
|
113
|
+
"info",
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.registerCommand("state-flow-status", {
|
|
119
|
+
description: "Show State Flow runtime status",
|
|
120
|
+
handler: async (_args, ctx) => {
|
|
121
|
+
ctx.ui.notify(detailedStatus(snapshot), "info");
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
pi.registerCommand("state-flow-stop", {
|
|
126
|
+
description: "Stop State Flow and clear its complete episode",
|
|
127
|
+
handler: async (_args, ctx) => {
|
|
128
|
+
snapshot = stopEpisode();
|
|
129
|
+
stagedFinal = undefined;
|
|
130
|
+
retryQueued = false;
|
|
131
|
+
runAnchorTimestamp = undefined;
|
|
132
|
+
skillReads.clear();
|
|
133
|
+
persist();
|
|
134
|
+
updateUi(ctx);
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
pi.on("before_agent_start", (event) => {
|
|
139
|
+
if (!snapshot.enabled) return;
|
|
140
|
+
if (!retryQueued) {
|
|
141
|
+
skillReads.clear();
|
|
142
|
+
}
|
|
143
|
+
const rotatesRun = snapshot.specification !== undefined && !retryQueued;
|
|
144
|
+
if (prepareRun(snapshot, event.prompt, retryQueued)) {
|
|
145
|
+
if (rotatesRun) runAnchorTimestamp = undefined;
|
|
146
|
+
persist();
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
systemPrompt: `${event.systemPrompt}\n\n${stateFlowProtocol(snapshot.bootstrap === true)}`,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
pi.on("context", (event) => {
|
|
154
|
+
if (!snapshot.enabled || snapshot.specification === undefined) return;
|
|
155
|
+
if (snapshot.bootstrap) {
|
|
156
|
+
if (retryQueued) return;
|
|
157
|
+
const messages = withoutPrivateValidation(event.messages as AgentMessage[]);
|
|
158
|
+
return messages.length === event.messages.length ? undefined : { messages };
|
|
159
|
+
}
|
|
160
|
+
const trajectory = currentRunTrajectory(
|
|
161
|
+
event.messages as AgentMessage[],
|
|
162
|
+
snapshot.specification,
|
|
163
|
+
runAnchorTimestamp,
|
|
164
|
+
);
|
|
165
|
+
runAnchorTimestamp = trajectory.anchorTimestamp;
|
|
166
|
+
return { messages: [runtimeContextMessage(snapshot), ...trajectory.messages] };
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
pi.on("tool_execution_start", (event) => {
|
|
170
|
+
if (!snapshot.enabled) return;
|
|
171
|
+
// Pi emits this before tool_call. Keep the argument object as a fallback;
|
|
172
|
+
// tool_call replaces it with the mutable, post-preflight input reference.
|
|
173
|
+
skillReads.recordStart(event.toolCallId, event.toolName, event.args);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
pi.on("tool_call", (event) => {
|
|
177
|
+
if (!snapshot.enabled) return;
|
|
178
|
+
skillReads.recordCall(event.toolCallId, event.toolName, event.input);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
pi.on("tool_execution_end", (event) => {
|
|
182
|
+
if (!snapshot.enabled) return;
|
|
183
|
+
skillReads.recordEnd(event.toolCallId, event.toolName, event.isError);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
pi.on("message_end", (event, ctx): any => {
|
|
187
|
+
if (!snapshot.enabled || event.message.role !== "assistant") return;
|
|
188
|
+
stagedFinal = undefined;
|
|
189
|
+
const message = event.message as unknown as { role: "assistant"; stopReason?: string; content?: unknown };
|
|
190
|
+
if (message.stopReason === "aborted") {
|
|
191
|
+
abandonRun(ctx);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (message.stopReason === "length" || message.stopReason === "error") {
|
|
195
|
+
return rejectTerminal(message, `Assistant response ended with ${message.stopReason}`, ctx);
|
|
196
|
+
}
|
|
197
|
+
if (assistantToolCallCount(message.content) > 0 || message.stopReason === "toolUse") {
|
|
198
|
+
retryQueued = snapshot.validation !== undefined;
|
|
199
|
+
const cleaned = stripStateComments(message.content);
|
|
200
|
+
return cleaned.changed
|
|
201
|
+
? { message: { ...message, role: "assistant" as const, content: cleaned.content } }
|
|
202
|
+
: undefined;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const parsed = parseTerminalPatch(message.content);
|
|
206
|
+
stagedFinal = stageTransition(snapshot.state, parsed.patch, skillReads.successful);
|
|
207
|
+
retryQueued = false;
|
|
208
|
+
return { message: { ...message, role: "assistant" as const, content: parsed.responseContent } };
|
|
209
|
+
} catch (error) {
|
|
210
|
+
return rejectTerminal(message, error instanceof Error ? error.message : String(error), ctx);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
pi.on("turn_end", (event, ctx) => {
|
|
215
|
+
if (!snapshot.enabled || !stagedFinal) {
|
|
216
|
+
updateUi(ctx);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
stagedFinal.nextState.response = finalizedAssistantResponse(event.message);
|
|
221
|
+
if (commitTransition(snapshot, stagedFinal)) {
|
|
222
|
+
skillReads.clear();
|
|
223
|
+
persist();
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
queueTerminalRegeneration(error instanceof Error ? error.message : String(error), ctx);
|
|
227
|
+
}
|
|
228
|
+
stagedFinal = undefined;
|
|
229
|
+
updateUi(ctx);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
233
|
+
if (snapshot.enabled && retryQueued) abandonRun(ctx);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
pi.on("session_start", (_event, ctx) => restoreActiveBranch(ctx));
|
|
237
|
+
pi.on("session_tree", (_event, ctx) => restoreActiveBranch(ctx));
|
|
238
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject;
|
|
4
|
+
export interface JsonObject { [key: string]: JsonValue }
|
|
5
|
+
|
|
6
|
+
export function applyPatch(state: JsonObject, patch: JsonObject): JsonObject {
|
|
7
|
+
const next: JsonObject = structuredClone(state);
|
|
8
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
9
|
+
if (value === null) {
|
|
10
|
+
delete next[key];
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const current = next[key];
|
|
14
|
+
const materialized = isObject(current) && isObject(value)
|
|
15
|
+
? applyPatch(current, value)
|
|
16
|
+
: structuredClone(value);
|
|
17
|
+
Object.defineProperty(next, key, {
|
|
18
|
+
value: materialized,
|
|
19
|
+
enumerable: true,
|
|
20
|
+
configurable: true,
|
|
21
|
+
writable: true,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return next;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isObject(value: JsonValue | unknown): value is JsonObject {
|
|
28
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function validatePatch(value: unknown): asserts value is JsonObject {
|
|
32
|
+
if (!isObject(value)) throw new Error("State patch must be a JSON object");
|
|
33
|
+
if (!isJsonValue(value)) throw new Error("State patch must contain finite, acyclic JSON data");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function canonicalJson(value: JsonValue | unknown): string {
|
|
37
|
+
if (!isJsonValue(value)) throw new Error("Value must be finite, acyclic JSON data");
|
|
38
|
+
return JSON.stringify(orderValue(value));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function hashJson(value: JsonValue | unknown): string {
|
|
42
|
+
return createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function orderValue(value: unknown): unknown {
|
|
46
|
+
if (Array.isArray(value)) return value.map((item) => orderValue(item));
|
|
47
|
+
if (!isObject(value)) return value;
|
|
48
|
+
return Object.fromEntries(
|
|
49
|
+
Object.keys(value).sort().map((key) => [key, orderValue(value[key])]),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function containsNull(value: unknown): boolean {
|
|
54
|
+
if (value === null) return true;
|
|
55
|
+
if (Array.isArray(value)) return value.some((item) => containsNull(item));
|
|
56
|
+
if (!isObject(value)) return false;
|
|
57
|
+
return Object.values(value).some((item) => containsNull(item));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isJsonValue(value: unknown, ancestors = new WeakSet<object>()): value is JsonValue {
|
|
61
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
62
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
63
|
+
if (typeof value !== "object") return false;
|
|
64
|
+
if (ancestors.has(value)) return false;
|
|
65
|
+
ancestors.add(value);
|
|
66
|
+
try {
|
|
67
|
+
if (Array.isArray(value)) return value.every((item) => isJsonValue(item, ancestors));
|
|
68
|
+
if (!isObject(value)) return false;
|
|
69
|
+
const prototype = Object.getPrototypeOf(value);
|
|
70
|
+
if (prototype !== Object.prototype && prototype !== null) return false;
|
|
71
|
+
if (Reflect.ownKeys(value).some((key) => typeof key === "symbol")) return false;
|
|
72
|
+
return Object.values(value).every((item) => isJsonValue(item, ancestors));
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
} finally {
|
|
76
|
+
ancestors.delete(value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { emptyState } from "./state.ts";
|
|
2
|
+
import { migrateSnapshot, migrationFailure, type Snapshot } from "./snapshot.ts";
|
|
3
|
+
|
|
4
|
+
export interface SnapshotRecovery {
|
|
5
|
+
snapshot: Snapshot;
|
|
6
|
+
skipped: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function failureMessage(snapshot: Snapshot): string | undefined {
|
|
10
|
+
return !snapshot.enabled && snapshot.validation?.attempt === 0
|
|
11
|
+
? snapshot.validation.error
|
|
12
|
+
: undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Recover the newest valid snapshot, falling back through the active branch. */
|
|
16
|
+
export function recoverSnapshot(candidates: readonly unknown[]): SnapshotRecovery {
|
|
17
|
+
const skipped: string[] = [];
|
|
18
|
+
let newestFailure: Snapshot | undefined;
|
|
19
|
+
for (const candidate of candidates) {
|
|
20
|
+
let migrated: Snapshot;
|
|
21
|
+
try {
|
|
22
|
+
migrated = migrateSnapshot(candidate);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
migrated = migrationFailure(
|
|
25
|
+
{},
|
|
26
|
+
`Snapshot restoration failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const failure = failureMessage(migrated);
|
|
30
|
+
if (failure === undefined) return { snapshot: migrated, skipped };
|
|
31
|
+
newestFailure ??= migrated;
|
|
32
|
+
skipped.push(failure);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
snapshot: newestFailure ?? { enabled: false, state: emptyState(), step: 0 },
|
|
36
|
+
skipped,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const SNAPSHOT_ENTRY_TYPE = "state-flow-snapshot";
|
|
2
|
+
|
|
3
|
+
interface BranchEntry {
|
|
4
|
+
type?: unknown;
|
|
5
|
+
customType?: unknown;
|
|
6
|
+
data?: unknown;
|
|
7
|
+
message?: { role?: unknown };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SnapshotDiscovery {
|
|
11
|
+
candidates: unknown[];
|
|
12
|
+
errors: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Enumerate active-branch snapshots newest-first while containing hostile entries. */
|
|
16
|
+
export function discoverSnapshotData(branch: readonly BranchEntry[]): SnapshotDiscovery {
|
|
17
|
+
const candidates: unknown[] = [];
|
|
18
|
+
const errors: string[] = [];
|
|
19
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
20
|
+
try {
|
|
21
|
+
const entry = branch[index];
|
|
22
|
+
if (entry?.type === "custom" && entry.customType === SNAPSHOT_ENTRY_TYPE) candidates.push(entry.data);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { candidates, errors };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function snapshotDataNewestFirst(branch: readonly BranchEntry[]): unknown[] {
|
|
31
|
+
return discoverSnapshotData(branch).candidates;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function latestSnapshotData(branch: readonly BranchEntry[]): unknown {
|
|
35
|
+
return snapshotDataNewestFirst(branch)[0];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function hasPriorConversation(branch: readonly BranchEntry[]): boolean {
|
|
39
|
+
for (const entry of branch) {
|
|
40
|
+
try {
|
|
41
|
+
if (entry.type !== "message") continue;
|
|
42
|
+
const role = entry.message?.role;
|
|
43
|
+
if (role === "user" || role === "assistant" || role === "toolResult") return true;
|
|
44
|
+
} catch {
|
|
45
|
+
// A hostile unrelated entry must not prevent explicit episode startup.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isObject, type JsonObject } from "./json.ts";
|
|
2
|
+
|
|
3
|
+
function hasContent(value: unknown): boolean {
|
|
4
|
+
if (typeof value === "string") return value.trim().length > 0;
|
|
5
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
6
|
+
if (isObject(value)) return Object.keys(value).length > 0;
|
|
7
|
+
return value !== undefined && value !== null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function hasCompiledSkill(contract: JsonObject, source: string): boolean {
|
|
11
|
+
if (!isObject(contract.compiled_skills)) return false;
|
|
12
|
+
return hasContent(contract.compiled_skills[source]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function skillPathFromRead(toolName: unknown, args: unknown): string | undefined {
|
|
16
|
+
if (toolName !== "read" || !isObject(args) || typeof args.path !== "string") return undefined;
|
|
17
|
+
return /(^|[\\/])SKILL\.md$/.test(args.path) ? args.path : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PendingRead {
|
|
21
|
+
toolName: string;
|
|
22
|
+
args: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Correlates Pi's mutable tool lifecycle without exposing runtime event objects. */
|
|
26
|
+
export class SkillReadTracker {
|
|
27
|
+
readonly successful = new Set<string>();
|
|
28
|
+
readonly #pending = new Map<string, PendingRead>();
|
|
29
|
+
|
|
30
|
+
clear(): void {
|
|
31
|
+
this.successful.clear();
|
|
32
|
+
this.#pending.clear();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
recordStart(toolCallId: string, toolName: string, args: unknown): void {
|
|
36
|
+
this.#record(toolCallId, toolName, args);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
recordCall(toolCallId: string, toolName: string, input: unknown): void {
|
|
40
|
+
this.#record(toolCallId, toolName, input);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
recordEnd(toolCallId: string, toolName: string, isError: boolean): void {
|
|
44
|
+
const pending = this.#pending.get(toolCallId);
|
|
45
|
+
this.#pending.delete(toolCallId);
|
|
46
|
+
if (isError || !pending || toolName !== pending.toolName) return;
|
|
47
|
+
const source = skillPathFromRead(pending.toolName, pending.args);
|
|
48
|
+
if (source) this.successful.add(source);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#record(toolCallId: string, toolName: string, args: unknown): void {
|
|
52
|
+
if (toolName !== "read") {
|
|
53
|
+
this.#pending.delete(toolCallId);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this.#pending.set(toolCallId, { toolName, args });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { applyPatch, containsNull, isJsonValue, isObject, type JsonObject } from "./json.ts";
|
|
2
|
+
import { emptyState, isStateDocument, type StateDocument } from "./state.ts";
|
|
3
|
+
import { MAX_VALIDATION_RETRIES, type ValidationFeedback } from "./validation.ts";
|
|
4
|
+
|
|
5
|
+
const MAX_RESTORED_STEP = Number.MAX_SAFE_INTEGER - 1;
|
|
6
|
+
|
|
7
|
+
export interface Snapshot {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
specification?: string;
|
|
10
|
+
state: StateDocument;
|
|
11
|
+
step: number;
|
|
12
|
+
validation?: ValidationFeedback;
|
|
13
|
+
bootstrap?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isLegacyTwoPartState(value: unknown): value is { contract: JsonObject; working: JsonObject } {
|
|
17
|
+
return isObject(value)
|
|
18
|
+
&& isObject(value.contract)
|
|
19
|
+
&& isObject(value.working)
|
|
20
|
+
&& Object.keys(value).every((key) => key === "contract" || key === "working");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function restoredStep(value: unknown): number {
|
|
24
|
+
return typeof value === "number"
|
|
25
|
+
&& Number.isSafeInteger(value)
|
|
26
|
+
&& value >= 0
|
|
27
|
+
&& value <= MAX_RESTORED_STEP
|
|
28
|
+
? value
|
|
29
|
+
: 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function restoredValidation(value: unknown): ValidationFeedback | undefined {
|
|
33
|
+
if (!isObject(value)
|
|
34
|
+
|| !Number.isSafeInteger(value.attempt as number)
|
|
35
|
+
|| (value.attempt as number) < 0
|
|
36
|
+
|| (value.attempt as number) > MAX_VALIDATION_RETRIES
|
|
37
|
+
|| typeof value.error !== "string"
|
|
38
|
+
|| typeof value.instruction !== "string") return undefined;
|
|
39
|
+
return {
|
|
40
|
+
attempt: value.attempt as number,
|
|
41
|
+
error: value.error,
|
|
42
|
+
instruction: value.instruction,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function migrationFailure(data: JsonObject, error: string): Snapshot {
|
|
47
|
+
return {
|
|
48
|
+
enabled: false,
|
|
49
|
+
specification: typeof data.specification === "string" ? data.specification : undefined,
|
|
50
|
+
state: emptyState(),
|
|
51
|
+
step: restoredStep(data.step),
|
|
52
|
+
validation: {
|
|
53
|
+
attempt: 0,
|
|
54
|
+
error,
|
|
55
|
+
instruction: "Start a fresh State Flow episode; null is reserved for patch deletion.",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function migrateSnapshot(value: unknown): Snapshot {
|
|
61
|
+
if (!isObject(value)) return { enabled: false, state: emptyState(), step: 0 };
|
|
62
|
+
const base = {
|
|
63
|
+
enabled: value.enabled === true,
|
|
64
|
+
specification: typeof value.specification === "string" ? value.specification : undefined,
|
|
65
|
+
step: restoredStep(value.step),
|
|
66
|
+
validation: restoredValidation(value.validation),
|
|
67
|
+
bootstrap: value.bootstrap === true,
|
|
68
|
+
};
|
|
69
|
+
if (isStateDocument(value.state)) {
|
|
70
|
+
if (!isJsonValue(value.state)) return migrationFailure(value, "Restored state contains non-JSON data");
|
|
71
|
+
if (containsNull(value.state)) return migrationFailure(value, "Restored state contains null data");
|
|
72
|
+
return { ...base, state: structuredClone(value.state) };
|
|
73
|
+
}
|
|
74
|
+
if (isLegacyTwoPartState(value.state)) {
|
|
75
|
+
if (!isJsonValue(value.state)) return migrationFailure(value, "Restored state contains non-JSON data");
|
|
76
|
+
if (containsNull(value.state)) return migrationFailure(value, "Restored state contains null data");
|
|
77
|
+
return {
|
|
78
|
+
...base,
|
|
79
|
+
state: {
|
|
80
|
+
contract: structuredClone(value.state.contract),
|
|
81
|
+
working: structuredClone(value.state.working),
|
|
82
|
+
response: "",
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (isObject(value.state)
|
|
87
|
+
&& (Object.hasOwn(value.state, "response")
|
|
88
|
+
|| (Object.hasOwn(value.state, "contract") && Object.hasOwn(value.state, "working")))) {
|
|
89
|
+
return migrationFailure(value, "Restored state has an invalid materialized-state schema");
|
|
90
|
+
}
|
|
91
|
+
const legacyBasis = isObject(value.stateBasis)
|
|
92
|
+
? value.stateBasis
|
|
93
|
+
: isObject(value.state)
|
|
94
|
+
? value.state
|
|
95
|
+
: {};
|
|
96
|
+
const legacyPatch = isObject(value.previousStatePatch) ? value.previousStatePatch : undefined;
|
|
97
|
+
if (!isJsonValue(legacyBasis) || (legacyPatch !== undefined && !isJsonValue(legacyPatch))) {
|
|
98
|
+
return migrationFailure(value, "Legacy state contains non-JSON data");
|
|
99
|
+
}
|
|
100
|
+
const legacyState = legacyPatch === undefined ? legacyBasis : applyPatch(legacyBasis, legacyPatch);
|
|
101
|
+
if (containsNull(legacyState)) return migrationFailure(value, "Legacy state contains null data");
|
|
102
|
+
return { ...base, state: { contract: {}, working: structuredClone(legacyState), response: "" } };
|
|
103
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { isObject, type JsonObject } from "./json.ts";
|
|
2
|
+
|
|
3
|
+
/** The only materialized memory shape persisted between State Flow runs. */
|
|
4
|
+
export interface StateDocument extends JsonObject {
|
|
5
|
+
contract: JsonObject;
|
|
6
|
+
working: JsonObject;
|
|
7
|
+
response: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function emptyState(): StateDocument {
|
|
11
|
+
return { contract: {}, working: {}, response: "" };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isStateDocument(value: unknown): value is StateDocument {
|
|
15
|
+
return isObject(value)
|
|
16
|
+
&& isObject(value.contract)
|
|
17
|
+
&& isObject(value.working)
|
|
18
|
+
&& typeof value.response === "string"
|
|
19
|
+
&& Object.keys(value).every((key) => key === "contract" || key === "working" || key === "response");
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Snapshot } from "./snapshot.ts";
|
|
2
|
+
|
|
3
|
+
export const STATUS_KEY = "state-flow";
|
|
4
|
+
|
|
5
|
+
export type Colorize = (color: "accent" | "dim", text: string) => string;
|
|
6
|
+
|
|
7
|
+
export function compactStatus(snapshot: Snapshot, colorize: Colorize): string | undefined {
|
|
8
|
+
if (!snapshot.enabled) return undefined;
|
|
9
|
+
return `${colorize("accent", "state-flow")} ${colorize("dim", `#${snapshot.step}`)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function detailedStatus(snapshot: Snapshot): string {
|
|
13
|
+
const stateJson = JSON.stringify(snapshot.state, null, 2);
|
|
14
|
+
const stateBytes = Buffer.byteLength(stateJson, "utf8");
|
|
15
|
+
return `State Flow ${snapshot.enabled ? "enabled" : "disabled"}; iteration #${snapshot.step}; state ${stateBytes} bytes; validation attempts ${snapshot.validation?.attempt ?? 0}.\n\n${stateJson}`;
|
|
16
|
+
}
|