@jwilger/pi-development-system 0.79.0 → 0.81.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/extensions/development-system.ts +26 -22
- package/package.json +1 -1
- package/src/context/cadence.ts +13 -4
- package/src/context/devsys-tool.ts +9 -9
- package/src/context/nudge.ts +30 -0
- package/src/context/system-prompt.ts +48 -18
- package/src/jev/judge-tools.ts +1 -1
- package/src/jev/questions/intent.ts +17 -1
- package/src/context/context-tail.ts +0 -50
|
@@ -5,13 +5,13 @@ import type {
|
|
|
5
5
|
ExtensionContext,
|
|
6
6
|
ToolDefinition,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import {
|
|
9
|
-
import { appendContextTail, renderContextTail } from "../src/context/context-tail.ts";
|
|
8
|
+
import { DEFAULT_PUSH_MINUTES } from "../src/context/cadence.ts";
|
|
10
9
|
import { createPhaseTool } from "../src/context/devsys-tool.ts";
|
|
11
10
|
import { intentLineFor } from "../src/context/intent-trigger.ts";
|
|
12
11
|
import { registerModelAdvice } from "../src/context/model-advice.ts";
|
|
12
|
+
import { NUDGE_ENTRY_TYPE, renderNudge } from "../src/context/nudge.ts";
|
|
13
13
|
import { renderStatus, renderStatusLine, STATUS_KEY } from "../src/context/status.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { applyPromptSections } from "../src/context/system-prompt.ts";
|
|
15
15
|
import { DEFAULT_VERIFIER_MAX, registerTurnVerifier } from "../src/context/turn-verifier.ts";
|
|
16
16
|
import { type Exec, timeoutAsFailure } from "../src/core/exec.ts";
|
|
17
17
|
import { defaultMatrix } from "../src/core/models.ts";
|
|
@@ -89,26 +89,30 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
89
89
|
pi.on("session_start", (_event, ctx) => rebuild(ctx));
|
|
90
90
|
pi.on("session_tree", (_event, ctx) => rebuild(ctx));
|
|
91
91
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
92
|
+
// Prompt-cache discipline (ADR-0004): the system prompt carries two sections — static principles
|
|
93
|
+
// and a small state section pi only resends when it changes — and anything one-shot (intent
|
|
94
|
+
// nudge, cadence warning) is returned here as a persisted message. No `context` handler: an
|
|
95
|
+
// unpersisted edit at the end of the request sits on the provider's cache breakpoint.
|
|
96
|
+
let lastCadence: string | undefined;
|
|
97
|
+
let cadenceFor: string | undefined;
|
|
95
98
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
99
|
+
const current = state.get();
|
|
100
|
+
applyPromptSections(event, current, nonNegotiables);
|
|
101
|
+
if (current.lastPushAt !== cadenceFor) {
|
|
102
|
+
cadenceFor = current.lastPushAt;
|
|
103
|
+
lastCadence = undefined;
|
|
104
|
+
}
|
|
105
|
+
const intentLine = await intentLineFor(event.prompt, current, jevHolder.forContext(ctx));
|
|
106
|
+
const nudge = renderNudge(current, Date.now(), pushMinutes, intentLine, lastCadence);
|
|
107
|
+
if (nudge === undefined) return undefined;
|
|
108
|
+
lastCadence = nudge.cadence;
|
|
109
|
+
return {
|
|
110
|
+
message: {
|
|
111
|
+
customType: NUDGE_ENTRY_TYPE,
|
|
112
|
+
content: nudge.text,
|
|
113
|
+
display: false,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
112
116
|
});
|
|
113
117
|
|
|
114
118
|
const exec: Exec = async (command, args, options) =>
|
package/package.json
CHANGED
package/src/context/cadence.ts
CHANGED
|
@@ -2,7 +2,13 @@ export const DEFAULT_PUSH_MINUTES = 60;
|
|
|
2
2
|
|
|
3
3
|
import type { DevsysState } from "../core/types.ts";
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/** Width of the buckets the elapsed time is reported in, so the line changes rarely. */
|
|
6
|
+
const CADENCE_BUCKET_MINUTES = 30;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Advisory only: a long gap since the last push while implementing hints the increment is too big.
|
|
10
|
+
* The minutes are rounded down to a bucket so the text stays the same across many turns.
|
|
11
|
+
*/
|
|
6
12
|
export function cadenceLine(
|
|
7
13
|
state: DevsysState,
|
|
8
14
|
now: number,
|
|
@@ -12,7 +18,10 @@ export function cadenceLine(
|
|
|
12
18
|
const pushed = Date.parse(state.lastPushAt);
|
|
13
19
|
if (!Number.isFinite(pushed)) return undefined;
|
|
14
20
|
const minutes = Math.floor((now - pushed) / 60_000);
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
if (minutes <= pushMinutes) return undefined;
|
|
22
|
+
const bucket = Math.max(
|
|
23
|
+
pushMinutes,
|
|
24
|
+
Math.floor(minutes / CADENCE_BUCKET_MINUTES) * CADENCE_BUCKET_MINUTES,
|
|
25
|
+
);
|
|
26
|
+
return `⚠ over ${bucket} min since last push — is this increment too big?`;
|
|
18
27
|
}
|
|
@@ -3,23 +3,23 @@ import { Type } from "typebox";
|
|
|
3
3
|
import type { SessionState } from "../state/session-state.ts";
|
|
4
4
|
import { phaseGuide } from "./phase-guide.ts";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Static on purpose: tool declarations head every request, so a description that changed with the
|
|
8
|
+
* phase would invalidate the provider's cache for the whole conversation. The phase lives in the
|
|
9
|
+
* system prompt's state section; calling the tool returns the guidance for it.
|
|
10
|
+
*/
|
|
11
|
+
export const DEVSYS_TOOL_DESCRIPTION =
|
|
12
|
+
"What the development system expects in the current workflow phase (named in the system prompt's Current state), and which tools to use. Call it when unsure what comes next.";
|
|
8
13
|
|
|
9
|
-
/** `devsys`: a model-only tool
|
|
14
|
+
/** `devsys`: a model-only tool that returns the current phase's guidance. */
|
|
10
15
|
export function createPhaseTool(deps: { state: SessionState }): ToolDefinition {
|
|
11
16
|
return {
|
|
12
17
|
name: "devsys",
|
|
13
18
|
label: "Workflow guide",
|
|
14
|
-
description:
|
|
19
|
+
description: DEVSYS_TOOL_DESCRIPTION,
|
|
15
20
|
promptSnippet: "Show what the workflow expects in the current phase",
|
|
16
21
|
parameters: Type.Object({}),
|
|
17
22
|
exposure: "model-only",
|
|
18
|
-
prepareLoadout: () => ({
|
|
19
|
-
descriptions: {
|
|
20
|
-
devsys: `${BASE} Current phase (${deps.state.get().phase}): ${phaseGuide(deps.state.get().phase)}`,
|
|
21
|
-
},
|
|
22
|
-
}),
|
|
23
23
|
execute() {
|
|
24
24
|
const { phase, sizing, activeSlice } = deps.state.get();
|
|
25
25
|
const text = [
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DevsysState } from "../core/types.ts";
|
|
2
|
+
import { cadenceLine } from "./cadence.ts";
|
|
3
|
+
|
|
4
|
+
export const NUDGE_ENTRY_TYPE = "devsys-nudge";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pure: the one-shot message for a run, or undefined when there is nothing to say.
|
|
8
|
+
*
|
|
9
|
+
* Why a message and not the system prompt or the context: pi persists the message returned from
|
|
10
|
+
* `before_agent_start` right after the user prompt, so it becomes part of the stable prefix the
|
|
11
|
+
* provider caches. A per-call context edit sits on the cache breakpoint and is never persisted,
|
|
12
|
+
* which made every request a cache miss. A system-prompt edit is resent in full to the model.
|
|
13
|
+
*
|
|
14
|
+
* `last` is the previous cadence text already in the conversation; the same text is not repeated.
|
|
15
|
+
*/
|
|
16
|
+
export function renderNudge(
|
|
17
|
+
state: DevsysState,
|
|
18
|
+
now: number,
|
|
19
|
+
pushMinutes: number,
|
|
20
|
+
intentLine: string | undefined,
|
|
21
|
+
lastCadence: string | undefined,
|
|
22
|
+
): { text: string; cadence: string | undefined } | undefined {
|
|
23
|
+
const cadence = cadenceLine(state, now, pushMinutes);
|
|
24
|
+
const lines = [
|
|
25
|
+
...(intentLine === undefined ? [] : [intentLine]),
|
|
26
|
+
...(cadence === undefined || cadence === lastCadence ? [] : [cadence]),
|
|
27
|
+
];
|
|
28
|
+
if (lines.length === 0) return undefined;
|
|
29
|
+
return { text: ["[development-system]", ...lines].join("\n"), cadence: cadence ?? lastCadence };
|
|
30
|
+
}
|
|
@@ -1,30 +1,60 @@
|
|
|
1
1
|
import type { BeforeAgentStartEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { DevsysState } from "../core/types.ts";
|
|
2
|
+
import type { Departure, DevsysState } from "../core/types.ts";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Two sections, kept apart on purpose. pi diffs system-prompt sections per run and appends
|
|
6
|
+
* only the ones whose text changed, so the static section is paid for once per conversation
|
|
7
|
+
* and only the small state section is resent when the workflow state moves. Nothing in either
|
|
8
|
+
* may change from call to call within a run (that would break the provider's prefix cache).
|
|
9
|
+
*/
|
|
10
|
+
const PRINCIPLES_SECTION = "development-system";
|
|
11
|
+
const STATE_SECTION = "development-system-state";
|
|
5
12
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
const MAX_DEPARTURES = 12;
|
|
14
|
+
const REMINDER =
|
|
15
|
+
"Call devsys_record_departure before acting against a default; hard stops need the user.";
|
|
16
|
+
|
|
17
|
+
const scopeLabel = (d: Departure): string =>
|
|
18
|
+
d.scope.kind === "slice" ? `slice ${d.scope.slice}` : d.scope.kind;
|
|
19
|
+
|
|
20
|
+
/** Pure: the static principles section — byte-identical whatever the state. */
|
|
21
|
+
export function buildPrinciplesSection(nonNegotiables: string): string {
|
|
22
|
+
return nonNegotiables.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pure: the small state section. Jev availability is deliberately absent — it flaps, and the
|
|
27
|
+
* status line already shows it. Timestamps and counters that tick are absent for the same reason.
|
|
28
|
+
*/
|
|
29
|
+
export function buildStateSection(state: DevsysState): string {
|
|
30
|
+
const lines = [
|
|
12
31
|
"## Current state",
|
|
13
32
|
`- phase: ${state.phase}`,
|
|
14
|
-
`-
|
|
15
|
-
`-
|
|
16
|
-
`-
|
|
17
|
-
]
|
|
33
|
+
`- sizing: ${state.sizing ?? "none"}`,
|
|
34
|
+
`- active slice: ${state.activeSlice ?? "none"}`,
|
|
35
|
+
`- profiles: ${(state.profiles?.length ?? 0) > 0 ? state.profiles?.join(", ") : "none"}`,
|
|
36
|
+
];
|
|
37
|
+
const open = state.openDepartures;
|
|
38
|
+
if (open.length === 0) {
|
|
39
|
+
lines.push("- open departures: none");
|
|
40
|
+
} else {
|
|
41
|
+
lines.push("- open departures:");
|
|
42
|
+
const shown = open.slice(0, MAX_DEPARTURES);
|
|
43
|
+
for (const d of shown) {
|
|
44
|
+
lines.push(` - ${d.gate} — ${d.chosen.replace(/\s+/g, " ")} (${scopeLabel(d)})`);
|
|
45
|
+
}
|
|
46
|
+
if (open.length > shown.length) lines.push(` - …and ${open.length - shown.length} more`);
|
|
47
|
+
}
|
|
48
|
+
lines.push("", REMINDER);
|
|
49
|
+
return lines.join("\n");
|
|
18
50
|
}
|
|
19
51
|
|
|
20
|
-
/** Adapter:
|
|
21
|
-
export function
|
|
52
|
+
/** Adapter: sets both sections on a before_agent_start event (never returns systemPrompt). */
|
|
53
|
+
export function applyPromptSections(
|
|
22
54
|
event: BeforeAgentStartEvent,
|
|
23
55
|
state: DevsysState,
|
|
24
56
|
nonNegotiables: string,
|
|
25
57
|
): void {
|
|
26
|
-
event.systemPromptOptions.sections[
|
|
27
|
-
|
|
28
|
-
nonNegotiables,
|
|
29
|
-
);
|
|
58
|
+
event.systemPromptOptions.sections[PRINCIPLES_SECTION] = buildPrinciplesSection(nonNegotiables);
|
|
59
|
+
event.systemPromptOptions.sections[STATE_SECTION] = buildStateSection(state);
|
|
30
60
|
}
|
package/src/jev/judge-tools.ts
CHANGED
|
@@ -15,7 +15,7 @@ const JUDGE_NAMESPACE = {
|
|
|
15
15
|
description:
|
|
16
16
|
"Jev judgements as data: probabilities and labels a script can branch on (sizing, test-change, lenses, task readiness).",
|
|
17
17
|
instructions:
|
|
18
|
-
|
|
18
|
+
'Each tool returns JSON text: the judgement. When Jev is unavailable the call rejects with a message holding {"error": …}, so wrap calls in try/catch or Promise.allSettled. Inputs are redacted and clipped before they reach Jev. Use these inside codemode scripts; they change no state.',
|
|
19
19
|
} as const;
|
|
20
20
|
|
|
21
21
|
type Deps = { jev: (ctx: ExtensionContext) => Jev };
|
|
@@ -23,13 +23,29 @@ export const INTENT_QUESTION: ClassifierChoiceQuestion = {
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
const PROMPT_MAX = 1500;
|
|
26
|
+
const SKILL_END = "</skill>";
|
|
27
|
+
const GAP = "\n…\n";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pi expands `/skill:name args` into `<skill …>body</skill>\n\nargs`; the body is the skill's own
|
|
31
|
+
* instructions, not the request. Judge the text after it, and when a prompt is still long keep both
|
|
32
|
+
* ends so a request that comes last is not clipped away.
|
|
33
|
+
*/
|
|
34
|
+
export function requestText(prompt: string): string {
|
|
35
|
+
const trimmed = prompt.trimStart();
|
|
36
|
+
const end = trimmed.startsWith("<skill ") ? trimmed.indexOf(SKILL_END) : -1;
|
|
37
|
+
const text = (end === -1 ? trimmed : trimmed.slice(end + SKILL_END.length)).trim();
|
|
38
|
+
if (text.length <= PROMPT_MAX) return text;
|
|
39
|
+
const half = Math.floor((PROMPT_MAX - GAP.length) / 2);
|
|
40
|
+
return `${text.slice(0, half)}${GAP}${text.slice(-half)}`;
|
|
41
|
+
}
|
|
26
42
|
const isIntent = (value: string): value is Intent => INTENTS.some((i) => i === value);
|
|
27
43
|
|
|
28
44
|
export async function judgeIntent(
|
|
29
45
|
jev: Jev,
|
|
30
46
|
input: { prompt: string; phase: string },
|
|
31
47
|
): Promise<Result<{ intent: Intent; confidence: number }, JevError>> {
|
|
32
|
-
const prompt = redactSecrets(input.prompt
|
|
48
|
+
const prompt = requestText(redactSecrets(input.prompt));
|
|
33
49
|
const asked = await jev.ask({ prompt, phase: input.phase }, { intent: INTENT_QUESTION });
|
|
34
50
|
if (!asked.ok) return asked;
|
|
35
51
|
const answer = asked.value.intent;
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import type { Departure, DevsysState } from "../core/types.ts";
|
|
3
|
-
|
|
4
|
-
const MAX_LINES = 25;
|
|
5
|
-
const REMINDER =
|
|
6
|
-
"Departures: call devsys_record_departure before acting against a default; hard stops need the user.";
|
|
7
|
-
|
|
8
|
-
const scopeLabel = (d: Departure): string =>
|
|
9
|
-
d.scope.kind === "slice" ? `slice ${d.scope.slice}` : d.scope.kind;
|
|
10
|
-
|
|
11
|
-
/** Small, cache-friendly reminder appended at the end of the context; undefined when nothing to say. */
|
|
12
|
-
export function renderContextTail(
|
|
13
|
-
state: DevsysState,
|
|
14
|
-
cadence?: string,
|
|
15
|
-
note?: string,
|
|
16
|
-
): string | undefined {
|
|
17
|
-
if (state.phase === "idle" && state.openDepartures.length === 0 && note === undefined) {
|
|
18
|
-
return undefined;
|
|
19
|
-
}
|
|
20
|
-
const head = [
|
|
21
|
-
"[development-system]",
|
|
22
|
-
`phase: ${state.phase} · slice: ${state.activeSlice ?? "none"}`,
|
|
23
|
-
`Jev: ${state.jev}`,
|
|
24
|
-
...((state.profiles?.length ?? 0) > 0 ? [`profiles: ${state.profiles?.join(", ")}`] : []),
|
|
25
|
-
];
|
|
26
|
-
const tailLines = [
|
|
27
|
-
...(note === undefined ? [] : [note]),
|
|
28
|
-
...(cadence === undefined ? [] : [cadence]),
|
|
29
|
-
REMINDER,
|
|
30
|
-
];
|
|
31
|
-
const room = MAX_LINES - head.length - tailLines.length - 1;
|
|
32
|
-
const lines = state.openDepartures.map(
|
|
33
|
-
(d) => `- ${d.gate} — ${d.chosen.replace(/\s+/g, " ")} (${scopeLabel(d)})`,
|
|
34
|
-
);
|
|
35
|
-
const shown =
|
|
36
|
-
lines.length <= room
|
|
37
|
-
? lines
|
|
38
|
-
: [...lines.slice(0, room - 1), `- …and ${lines.length - room + 1} more`];
|
|
39
|
-
return [...head, "Open departures:", ...shown, ...tailLines].join("\n");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Appends the tail as a user-role message at the end of `messages`. */
|
|
43
|
-
export function appendContextTail(
|
|
44
|
-
messages: ReadonlyArray<AgentMessage>,
|
|
45
|
-
tail: string | undefined,
|
|
46
|
-
now: number,
|
|
47
|
-
): AgentMessage[] | undefined {
|
|
48
|
-
if (tail === undefined) return undefined;
|
|
49
|
-
return [...messages, { role: "user", content: tail, timestamp: now }];
|
|
50
|
-
}
|