@jwilger/pi-development-system 0.35.0 → 0.36.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 +22 -1
- package/package.json +1 -1
- package/src/context/cadence.ts +18 -0
- package/src/context/compaction.ts +24 -0
- package/src/context/context-tail.ts +2 -2
- package/src/context/model-advice.ts +58 -0
- package/src/context/state-block.ts +30 -0
- package/src/context/status.ts +1 -1
- package/src/core/model-advice.ts +81 -0
- package/src/core/models.ts +1 -1
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { cadenceLine, DEFAULT_PUSH_MINUTES } from "../src/context/cadence.ts";
|
|
4
|
+
import { registerCompactionResync } from "../src/context/compaction.ts";
|
|
3
5
|
import { appendContextTail, renderContextTail } from "../src/context/context-tail.ts";
|
|
6
|
+
import { registerModelAdvice } from "../src/context/model-advice.ts";
|
|
4
7
|
import { renderStatus, renderStatusLine, STATUS_KEY } from "../src/context/status.ts";
|
|
5
8
|
import { applyPromptSection } from "../src/context/system-prompt.ts";
|
|
6
9
|
import { DEFAULT_VERIFIER_MAX, registerTurnVerifier } from "../src/context/turn-verifier.ts";
|
|
7
10
|
import { type Exec, timeoutAsFailure } from "../src/core/exec.ts";
|
|
11
|
+
import { defaultMatrix } from "../src/core/models.ts";
|
|
8
12
|
import { detectProfiles } from "../src/core/profile.ts";
|
|
9
13
|
import { createApprovalStore } from "../src/gates/approvals.ts";
|
|
10
14
|
import { registerCommitGuard } from "../src/gates/commit-guard.ts";
|
|
@@ -38,6 +42,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
38
42
|
const jevHolder = createJevHolder();
|
|
39
43
|
let jevModel: string | undefined;
|
|
40
44
|
let lastCtx: ExtensionContext | undefined;
|
|
45
|
+
let pushMinutes = DEFAULT_PUSH_MINUTES;
|
|
41
46
|
|
|
42
47
|
const refreshStatus = () => {
|
|
43
48
|
lastCtx?.ui.setStatus(STATUS_KEY, renderStatusLine(state.get(), jevModel));
|
|
@@ -45,6 +50,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
45
50
|
|
|
46
51
|
const refreshProfiles = async (ctx: ExtensionContext) => {
|
|
47
52
|
const config = await loadConfig(ctx.cwd);
|
|
53
|
+
pushMinutes = config.ok ? config.value.cadence.pushMinutes : DEFAULT_PUSH_MINUTES;
|
|
48
54
|
const override = config.ok ? config.value.profiles.override : [];
|
|
49
55
|
const profiles = await detectProfiles(ctx.cwd, override);
|
|
50
56
|
const current = state.get().profiles ?? [];
|
|
@@ -75,7 +81,13 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
75
81
|
});
|
|
76
82
|
|
|
77
83
|
pi.on("context", (event) => {
|
|
78
|
-
const
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const cadence = cadenceLine(state.get(), now, pushMinutes);
|
|
86
|
+
const messages = appendContextTail(
|
|
87
|
+
event.messages,
|
|
88
|
+
renderContextTail(state.get(), cadence),
|
|
89
|
+
now,
|
|
90
|
+
);
|
|
79
91
|
return messages === undefined ? undefined : { messages };
|
|
80
92
|
});
|
|
81
93
|
|
|
@@ -102,6 +114,15 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
102
114
|
});
|
|
103
115
|
registerTestGuard({ pi, state, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
|
|
104
116
|
registerTestEvidence({ pi, state });
|
|
117
|
+
registerCompactionResync({ pi, state });
|
|
118
|
+
registerModelAdvice({
|
|
119
|
+
pi,
|
|
120
|
+
state,
|
|
121
|
+
matrix: async (ctx) => {
|
|
122
|
+
const config = await loadConfig(ctx.cwd);
|
|
123
|
+
return config.ok ? config.value.models : defaultMatrix();
|
|
124
|
+
},
|
|
125
|
+
});
|
|
105
126
|
registerTurnVerifier({
|
|
106
127
|
pi,
|
|
107
128
|
state,
|
package/package.json
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const DEFAULT_PUSH_MINUTES = 60;
|
|
2
|
+
|
|
3
|
+
import type { DevsysState } from "../core/types.ts";
|
|
4
|
+
|
|
5
|
+
/** Advisory only: a long gap since the last push while implementing hints the increment is too big. */
|
|
6
|
+
export function cadenceLine(
|
|
7
|
+
state: DevsysState,
|
|
8
|
+
now: number,
|
|
9
|
+
pushMinutes: number,
|
|
10
|
+
): string | undefined {
|
|
11
|
+
if (state.phase !== "implementing" || state.lastPushAt === undefined) return undefined;
|
|
12
|
+
const pushed = Date.parse(state.lastPushAt);
|
|
13
|
+
if (!Number.isFinite(pushed)) return undefined;
|
|
14
|
+
const minutes = Math.floor((now - pushed) / 60_000);
|
|
15
|
+
return minutes > pushMinutes
|
|
16
|
+
? `⚠ ${minutes} min since last push — is this increment too big?`
|
|
17
|
+
: undefined;
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SessionState } from "../state/session-state.ts";
|
|
3
|
+
import { renderStateBlock } from "./state-block.ts";
|
|
4
|
+
|
|
5
|
+
export const RESYNC_ENTRY_TYPE = "devsys-resync";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* pi lets `session_before_compact` only REPLACE the whole summary (summary, firstKeptEntryId,
|
|
9
|
+
* tokensBefore), which would mean writing our own summariser and paying another model call. So the
|
|
10
|
+
* summary is left to pi, and once it lands we re-state the working state as one message the next
|
|
11
|
+
* request sees. State itself lives in session entries and the per-turn context tail, so nothing is
|
|
12
|
+
* lost; this just makes it visible to the model again.
|
|
13
|
+
*/
|
|
14
|
+
export function registerCompactionResync(deps: { pi: ExtensionAPI; state: SessionState }): void {
|
|
15
|
+
deps.pi.on("session_compact", () => {
|
|
16
|
+
const state = deps.state.get();
|
|
17
|
+
if (state.phase === "idle" && state.openDepartures.length === 0) return;
|
|
18
|
+
deps.pi.sendMessage({
|
|
19
|
+
customType: RESYNC_ENTRY_TYPE,
|
|
20
|
+
content: `[devsys resync] Context was compacted. Current working state:\n\n${renderStateBlock(state)}`,
|
|
21
|
+
display: true,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -9,7 +9,7 @@ const scopeLabel = (d: Departure): string =>
|
|
|
9
9
|
d.scope.kind === "slice" ? `slice ${d.scope.slice}` : d.scope.kind;
|
|
10
10
|
|
|
11
11
|
/** Small, cache-friendly reminder appended at the end of the context; undefined when nothing to say. */
|
|
12
|
-
export function renderContextTail(state: DevsysState): string | undefined {
|
|
12
|
+
export function renderContextTail(state: DevsysState, cadence?: string): string | undefined {
|
|
13
13
|
if (state.phase === "idle" && state.openDepartures.length === 0) return undefined;
|
|
14
14
|
const head = [
|
|
15
15
|
"[development-system]",
|
|
@@ -17,7 +17,7 @@ export function renderContextTail(state: DevsysState): string | undefined {
|
|
|
17
17
|
`Jev: ${state.jev}`,
|
|
18
18
|
...((state.profiles?.length ?? 0) > 0 ? [`profiles: ${state.profiles?.join(", ")}`] : []),
|
|
19
19
|
];
|
|
20
|
-
const tailLines = [REMINDER];
|
|
20
|
+
const tailLines = [...(cadence === undefined ? [] : [cadence]), REMINDER];
|
|
21
21
|
const room = MAX_LINES - head.length - tailLines.length - 1;
|
|
22
22
|
const lines = state.openDepartures.map(
|
|
23
23
|
(d) => `- ${d.gate} — ${d.chosen.replace(/\s+/g, " ")} (${scopeLabel(d)})`,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { adviseModel } from "../core/model-advice.ts";
|
|
3
|
+
import type { ModelMatrix } from "../core/models.ts";
|
|
4
|
+
import type { Phase } from "../core/types.ts";
|
|
5
|
+
import { availableModels } from "../state/models-command.ts";
|
|
6
|
+
import type { SessionState } from "../state/session-state.ts";
|
|
7
|
+
|
|
8
|
+
const ADVICE_ENTRY_TYPE = "devsys-model-advice";
|
|
9
|
+
|
|
10
|
+
export type ModelAdviceDeps = {
|
|
11
|
+
pi: ExtensionAPI;
|
|
12
|
+
state: SessionState;
|
|
13
|
+
matrix(ctx: ExtensionContext): Promise<ModelMatrix>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Seen = { ctx: ExtensionContext; model: { provider: string; id: string } | undefined };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Recommends — never switches — when the coordinator's model tier is below what the phase's slot
|
|
20
|
+
* resolves to. Runs on model_select and on phase change, at most once per (phase, model).
|
|
21
|
+
*/
|
|
22
|
+
export function registerModelAdvice(deps: ModelAdviceDeps): void {
|
|
23
|
+
const told = new Set<string>();
|
|
24
|
+
let last: Seen | undefined;
|
|
25
|
+
let phase: Phase = deps.state.get().phase;
|
|
26
|
+
|
|
27
|
+
const check = async (seen: Seen): Promise<void> => {
|
|
28
|
+
const { model } = seen;
|
|
29
|
+
const current = deps.state.get().phase;
|
|
30
|
+
if (model === undefined) return;
|
|
31
|
+
const ref = `${model.provider}/${model.id}`;
|
|
32
|
+
const key = `${current}|${ref}`;
|
|
33
|
+
if (told.has(key)) return;
|
|
34
|
+
const advice = adviseModel({
|
|
35
|
+
matrix: await deps.matrix(seen.ctx),
|
|
36
|
+
available: availableModels(seen.ctx.modelRegistry),
|
|
37
|
+
phase: current,
|
|
38
|
+
model: ref,
|
|
39
|
+
});
|
|
40
|
+
if (advice === undefined) return;
|
|
41
|
+
told.add(key);
|
|
42
|
+
deps.pi.sendMessage({ customType: ADVICE_ENTRY_TYPE, content: advice, display: true });
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
deps.pi.on("session_start", (_event, ctx) => {
|
|
46
|
+
last = { ctx, model: ctx.model };
|
|
47
|
+
phase = deps.state.get().phase;
|
|
48
|
+
});
|
|
49
|
+
deps.pi.on("model_select", async (event, ctx) => {
|
|
50
|
+
last = { ctx, model: event.model };
|
|
51
|
+
await check(last);
|
|
52
|
+
});
|
|
53
|
+
deps.state.onChange((state) => {
|
|
54
|
+
if (state.phase === phase) return;
|
|
55
|
+
phase = state.phase;
|
|
56
|
+
if (last !== undefined) void check(last).catch(() => undefined);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DevsysState } from "../core/types.ts";
|
|
2
|
+
import { activeReview } from "./status.ts";
|
|
3
|
+
|
|
4
|
+
const MAX_DEPARTURES = 8;
|
|
5
|
+
|
|
6
|
+
/** The durable working state as a short markdown block, for compaction resync and the like. */
|
|
7
|
+
export function renderStateBlock(state: DevsysState): string {
|
|
8
|
+
const departures = state.openDepartures.map(
|
|
9
|
+
(d) => ` - ${d.gate} — ${d.chosen.replace(/\s+/g, " ")}`,
|
|
10
|
+
);
|
|
11
|
+
const shown =
|
|
12
|
+
departures.length <= MAX_DEPARTURES
|
|
13
|
+
? departures
|
|
14
|
+
: [
|
|
15
|
+
...departures.slice(0, MAX_DEPARTURES - 1),
|
|
16
|
+
` - …and ${departures.length - MAX_DEPARTURES + 1} more`,
|
|
17
|
+
];
|
|
18
|
+
const run = state.lastTestRun;
|
|
19
|
+
return [
|
|
20
|
+
"## Development System State",
|
|
21
|
+
`- phase: ${state.phase}`,
|
|
22
|
+
`- sizing: ${state.sizing ?? "unset"}`,
|
|
23
|
+
`- slice: ${state.activeSlice ?? "none"}`,
|
|
24
|
+
`- open departures: ${state.openDepartures.length === 0 ? "none" : state.openDepartures.length}`,
|
|
25
|
+
...shown,
|
|
26
|
+
`- ${activeReview(state) ?? "review: none"}`,
|
|
27
|
+
`- last test run: ${run === undefined ? "none" : `exit ${run.exitCode} — ${run.summary}`}`,
|
|
28
|
+
`- last push: ${state.lastPushAt ?? "none"}`,
|
|
29
|
+
].join("\n");
|
|
30
|
+
}
|
package/src/context/status.ts
CHANGED
|
@@ -13,7 +13,7 @@ const ciLabel = (state: DevsysState): string | undefined => {
|
|
|
13
13
|
return `ci ${ci.status}${sha}`;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
const activeReview = (state: DevsysState): string | undefined => {
|
|
16
|
+
export const activeReview = (state: DevsysState): string | undefined => {
|
|
17
17
|
// Without an active slice (before /devsys-start exists) show the most recent review instead of nothing.
|
|
18
18
|
const review =
|
|
19
19
|
state.activeSlice === undefined ? state.reviews?.at(-1) : reviewOf(state, state.activeSlice);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Available,
|
|
3
|
+
flattenSlot,
|
|
4
|
+
type ModelMatrix,
|
|
5
|
+
matchesPattern,
|
|
6
|
+
resolveSlot,
|
|
7
|
+
type Slot,
|
|
8
|
+
} from "./models.ts";
|
|
9
|
+
import type { Phase } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
/** Highest first. */
|
|
12
|
+
export const TIERS = ["frontier", "strong", "fast"] as const;
|
|
13
|
+
export type Tier = (typeof TIERS)[number];
|
|
14
|
+
|
|
15
|
+
const PHASE_SLOT: Partial<Record<Phase, Slot>> = {
|
|
16
|
+
planning: "planning",
|
|
17
|
+
implementing: "implementer",
|
|
18
|
+
reviewing: "reviewer",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const requiredSlot = (phase: Phase): Slot | undefined => PHASE_SLOT[phase];
|
|
22
|
+
|
|
23
|
+
const split = (ref: string): { provider: string; id: string } | undefined => {
|
|
24
|
+
const slash = ref.indexOf("/");
|
|
25
|
+
return slash <= 0 || slash === ref.length - 1
|
|
26
|
+
? undefined
|
|
27
|
+
: { provider: ref.slice(0, slash), id: ref.slice(slash + 1) };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const matches = (candidates: readonly string[], model: string): boolean => {
|
|
31
|
+
const ref = split(model);
|
|
32
|
+
return (
|
|
33
|
+
ref !== undefined &&
|
|
34
|
+
candidates.some((candidate) => {
|
|
35
|
+
const c = split(candidate);
|
|
36
|
+
return c !== undefined && c.provider === ref.provider && matchesPattern(c.id, ref.id);
|
|
37
|
+
})
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The lowest tier whose candidates include `provider/id`: a model that appears as a fallback in a
|
|
43
|
+
* higher tier (sol in frontier) still belongs to the tier it is natively listed in. Undefined when
|
|
44
|
+
* the model is in none of them.
|
|
45
|
+
*/
|
|
46
|
+
export function tierOf(matrix: ModelMatrix, model: string): Tier | undefined {
|
|
47
|
+
// Direct candidates only: `fast` falls back to `@strong`, which must not make strong models fast.
|
|
48
|
+
return [...TIERS].reverse().find((tier) =>
|
|
49
|
+
matches(
|
|
50
|
+
matrix[tier].filter((c) => !c.startsWith("@")),
|
|
51
|
+
model,
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type AdviceInput = {
|
|
57
|
+
matrix: ModelMatrix;
|
|
58
|
+
available: readonly Available[];
|
|
59
|
+
phase: Phase;
|
|
60
|
+
/** The coordinator's current model as `provider/id`. */
|
|
61
|
+
model: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One recommendation when the coordinator's tier is below what the phase's slot resolves to; never
|
|
66
|
+
* a model switch. Silent when the phase has no slot, the slot cannot be resolved here, or either
|
|
67
|
+
* model is outside the three tiers (nothing to compare).
|
|
68
|
+
*/
|
|
69
|
+
export function adviseModel(input: AdviceInput): string | undefined {
|
|
70
|
+
const slot = requiredSlot(input.phase);
|
|
71
|
+
if (slot === undefined) return undefined;
|
|
72
|
+
const wanted = resolveSlot(input.matrix, slot, input.available);
|
|
73
|
+
if (!wanted.ok) return undefined;
|
|
74
|
+
// Anything the slot itself lists is acceptable for the phase, whatever tier it natively sits in.
|
|
75
|
+
if (matches(flattenSlot(input.matrix, slot), input.model)) return undefined;
|
|
76
|
+
const have = tierOf(input.matrix, input.model);
|
|
77
|
+
const need = tierOf(input.matrix, wanted.value.model);
|
|
78
|
+
if (have === undefined || need === undefined) return undefined;
|
|
79
|
+
if (TIERS.indexOf(have) <= TIERS.indexOf(need)) return undefined;
|
|
80
|
+
return `${input.phase} on ${input.model} (${have} tier) — the matrix prefers ${wanted.value.model} (${need} tier) for the ${slot} slot. Consider asking an advisor subagent for the hard decisions, or continue and record a departure (gate models.phase-mismatch). The system never switches your model for you.`;
|
|
81
|
+
}
|
package/src/core/models.ts
CHANGED
|
@@ -80,7 +80,7 @@ export function compareModelVersions(a: string, b: string): number {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/** `*`-only glob over a model id (no regular expressions built from configuration). */
|
|
83
|
-
function matchesPattern(pattern: string, id: string): boolean {
|
|
83
|
+
export function matchesPattern(pattern: string, id: string): boolean {
|
|
84
84
|
const parts = pattern.split("*");
|
|
85
85
|
if (parts.length === 1) return pattern === id;
|
|
86
86
|
const first = parts[0] ?? "";
|