@mgiles/perk 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -0
- package/extension/adapters/planAdapterPlannotator.ts +269 -0
- package/extension/adapters/planAdapterTombell.ts +147 -0
- package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
- package/extension/checkpoints/checkpoints.ts +542 -0
- package/extension/checkpoints/planSteps.ts +108 -0
- package/extension/doors/address.ts +360 -0
- package/extension/doors/askUser.ts +194 -0
- package/extension/doors/ciExecutor.ts +583 -0
- package/extension/doors/land.ts +222 -0
- package/extension/doors/learn.ts +235 -0
- package/extension/doors/learnDocs.ts +99 -0
- package/extension/doors/lifecycleGates.ts +171 -0
- package/extension/doors/prReview.ts +339 -0
- package/extension/doors/ready.ts +86 -0
- package/extension/doors/selfcheck.ts +155 -0
- package/extension/doors/submit.ts +253 -0
- package/extension/factories/objective.ts +240 -0
- package/extension/factories/objectiveAuthor.ts +114 -0
- package/extension/factories/objectiveDraft.ts +343 -0
- package/extension/factories/objectivePlan.ts +838 -0
- package/extension/factories/objectiveSave.ts +285 -0
- package/extension/factories/planDraft.ts +140 -0
- package/extension/factories/planMode.ts +214 -0
- package/extension/factories/planReview.ts +644 -0
- package/extension/factories/planSave.ts +589 -0
- package/extension/factories/planTitle.ts +123 -0
- package/extension/index.ts +459 -0
- package/extension/substrate/bindingDelivery.ts +199 -0
- package/extension/substrate/bindings.ts +180 -0
- package/extension/substrate/cache.ts +163 -0
- package/extension/substrate/coldDoor.ts +226 -0
- package/extension/substrate/config.ts +339 -0
- package/extension/substrate/miniYaml.ts +262 -0
- package/extension/substrate/prompts.ts +35 -0
- package/extension/substrate/providers.ts +177 -0
- package/extension/substrate/registry.ts +62 -0
- package/extension/substrate/resources.ts +41 -0
- package/extension/substrate/result.ts +72 -0
- package/extension/substrate/runId.ts +49 -0
- package/extension/substrate/sessionData.ts +229 -0
- package/extension/substrate/structuredOutput.ts +141 -0
- package/extension/substrate/toolGating.ts +400 -0
- package/extension/substrate/toolParams.ts +106 -0
- package/extension/substrate/workflowState.ts +233 -0
- package/extension/surfaces/footerProvider.ts +43 -0
- package/extension/surfaces/report.ts +34 -0
- package/extension/surfaces/surfaces.ts +460 -0
- package/extension/vendor/btw/btw.ts +964 -0
- package/extension/vendor/btw/core.ts +153 -0
- package/extension/vendor/whimsical/whimsical.ts +485 -0
- package/extension/worker/readOnlySession.ts +282 -0
- package/extension/worker/worker.ts +765 -0
- package/extension/workerMain.ts +150 -0
- package/package.json +55 -0
- package/prompts/README.md +15 -0
- package/prompts/_fixtures/cases.yaml +140 -0
- package/prompts/_fixtures/golden/address-action-model.txt +10 -0
- package/prompts/_fixtures/golden/address-action.txt +10 -0
- package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
- package/prompts/_fixtures/golden/address-preview.txt +6 -0
- package/prompts/_fixtures/golden/hello.txt +1 -0
- package/prompts/_fixtures/golden/implement-github.txt +8 -0
- package/prompts/_fixtures/golden/learn-docs.txt +8 -0
- package/prompts/_fixtures/golden/learn-github.txt +11 -0
- package/prompts/_fixtures/golden/learn-linear.txt +11 -0
- package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
- package/prompts/_fixtures/golden/learn-other.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
- package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
- package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
- package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
- package/prompts/_fixtures/golden/with_include.txt +4 -0
- package/prompts/_fixtures/templates/_greeting.md +1 -0
- package/prompts/_fixtures/templates/hello.md +1 -0
- package/prompts/_fixtures/templates/with_include.md +4 -0
- package/prompts/common/objective-read/linear.md +1 -0
- package/prompts/common/plan-read/github.md +1 -0
- package/prompts/common/plan-read/linear.md +1 -0
- package/prompts/common/plan-read/other.md +1 -0
- package/prompts/stages/address/action.md +10 -0
- package/prompts/stages/address/preview.md +6 -0
- package/prompts/stages/implement.md +8 -0
- package/prompts/stages/learn-docs.md +8 -0
- package/prompts/stages/learn.md +21 -0
- package/prompts/stages/objective-plan/guidance.md +12 -0
- package/prompts/stages/objective-plan/seed.md +20 -0
- package/shared/README.md +29 -0
- package/shared/bindings.yaml +64 -0
- package/shared/contracts-history.md +403 -0
- package/shared/contracts.md +4172 -0
- package/shared/providers.yaml +221 -0
- package/shared/registry.yaml +199 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Mint perk run ids in the TS plane (contracts.md §8.2 mint doctrine).
|
|
2
|
+
// Warm sessions with no identity (decideClaim's `none` path) mint their own ULID;
|
|
3
|
+
// cold launches still mint in Python (perk/state/run_id.py) and hand off via PERK_RUN_ID.
|
|
4
|
+
//
|
|
5
|
+
// Hand-rolled spec-conformant ULID (no npm dependency): a 48-bit `Date.now()` timestamp
|
|
6
|
+
// + 80 bits of `randomBytes(10)`, Crockford base32, 26 chars (10 time + 16 randomness).
|
|
7
|
+
// Per-process monotonicity is NOT required — one mint per session.
|
|
8
|
+
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
/** Crockford base32 alphabet (no I/L/O/U) — the ULID character set. */
|
|
12
|
+
export const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
13
|
+
|
|
14
|
+
/** Mint a spec-conformant ULID: 10 time chars (48-bit ms) + 16 randomness chars (80 bits). */
|
|
15
|
+
export function mintRunId(): string {
|
|
16
|
+
// Time component: most-significant-first base32 of Date.now() (2^48 ms ≈ year 10889).
|
|
17
|
+
let ms = Date.now();
|
|
18
|
+
const time = new Array<string>(10);
|
|
19
|
+
for (let i = 9; i >= 0; i--) {
|
|
20
|
+
time[i] = CROCKFORD[ms % 32] as string;
|
|
21
|
+
ms = Math.floor(ms / 32);
|
|
22
|
+
}
|
|
23
|
+
// Randomness component: 10 bytes → 16 five-bit chunks, standard bit-walk.
|
|
24
|
+
const bytes = randomBytes(10);
|
|
25
|
+
let rand = "";
|
|
26
|
+
let acc = 0;
|
|
27
|
+
let bits = 0;
|
|
28
|
+
for (const byte of bytes) {
|
|
29
|
+
acc = (acc << 8) | byte;
|
|
30
|
+
bits += 8;
|
|
31
|
+
while (bits >= 5) {
|
|
32
|
+
bits -= 5;
|
|
33
|
+
rand += CROCKFORD[(acc >> bits) & 31];
|
|
34
|
+
acc &= (1 << bits) - 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return time.join("") + rand;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Decode the first 10 chars of a ULID to epoch milliseconds (test aid). */
|
|
41
|
+
export function decodeTime(runId: string): number {
|
|
42
|
+
let ms = 0;
|
|
43
|
+
for (const ch of runId.slice(0, 10)) {
|
|
44
|
+
const value = CROCKFORD.indexOf(ch);
|
|
45
|
+
if (value < 0) throw new Error(`invalid ULID time character: ${ch}`);
|
|
46
|
+
ms = ms * 32 + value;
|
|
47
|
+
}
|
|
48
|
+
return ms;
|
|
49
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// The session-data accessor seam (contracts.md §8.1).
|
|
2
|
+
//
|
|
3
|
+
// Every run-scoped session artifact lives under `.pi/workflow/scratch/runs/<run_id>/data/`, and
|
|
4
|
+
// ALL session-data paths flow through this module (interior) or `perk/state/cache.py` (exterior) — the
|
|
5
|
+
// guard tests (cacheGuard.test.ts / tests/test_cache_guard.py) forbid manual construction of the
|
|
6
|
+
// `scratch`/`runs` path segments anywhere else.
|
|
7
|
+
//
|
|
8
|
+
// Seam doctrine — degrade gracefully, never invent identity:
|
|
9
|
+
// - The current run_id resolves from the rebuilt `perk:workflow-state` and degrades to `null`
|
|
10
|
+
// when the session has no identity. CONTRAST with `coldDoor.activeRunId`, which falls back to
|
|
11
|
+
// a `cold-door-<ts>` stamp for stdin-staging debuggability: a stamp here would orphan data
|
|
12
|
+
// dirs and break run_id-keyed provenance, so this seam never stamps.
|
|
13
|
+
// - Reads return `null` on absence (normal, branchable) and on I/O errors (with a loud stderr
|
|
14
|
+
// warning); writes return the written path or `null` on failure (with a warning). Never
|
|
15
|
+
// throws — a broken disk must not wedge a session.
|
|
16
|
+
//
|
|
17
|
+
// Provenance doctrine (contracts §8.1/§8.3) — the pointer makes it consumable:
|
|
18
|
+
// - A session artifact is *consumable* only via its `session_artifacts` pointer
|
|
19
|
+
// ({run_id, name, path, digest, at}) in the rebuilt `perk:workflow-state`. A bare file on
|
|
20
|
+
// disk is never trusted: `writeSessionArtifact` returns a path only once BOTH the file and
|
|
21
|
+
// the pointer landed; `readSessionArtifact` validates the on-disk bytes against the rebuilt
|
|
22
|
+
// pointer and fails open to `null` when validation refuses.
|
|
23
|
+
// - Validation always derives the path from `run_id` + `name` through the seam; the recorded
|
|
24
|
+
// `pointer.path` is informational/debugging only and is never dereferenced (workflow-state
|
|
25
|
+
// entries are reconstructable from untrusted session history).
|
|
26
|
+
// - The four lifecycle guarantees: REWIND ⇒ the rebuilt branch carries an older pointer while
|
|
27
|
+
// disk holds newer bytes ⇒ digest mismatch ⇒ refusal. FORK ⇒ the child run_id no longer
|
|
28
|
+
// matches the inherited pointer's ⇒ silent refusal (no inheritance; fresh dir).
|
|
29
|
+
// RELOAD/COMPACTION ⇒ same run_id ⇒ pointer + dir persist. CONCURRENT SESSIONS ⇒ run_id
|
|
30
|
+
// keying isolates dirs and pointers alike — validation never crosses run_ids.
|
|
31
|
+
//
|
|
32
|
+
// Imports only node builtins + cache.ts + workflowState.ts + report.ts so the module stays
|
|
33
|
+
// loadable under `node --test`; accepts a minimal structural ctx (`BranchSource & { cwd }`).
|
|
34
|
+
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
36
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
37
|
+
import { join, relative } from "node:path";
|
|
38
|
+
import type { ReportTarget } from "../surfaces/report.ts";
|
|
39
|
+
import { sessionDataDir } from "./cache.ts";
|
|
40
|
+
import {
|
|
41
|
+
appendWorkflowState,
|
|
42
|
+
type BranchSource,
|
|
43
|
+
branchOf,
|
|
44
|
+
type EntrySink,
|
|
45
|
+
rebuildWorkflowState,
|
|
46
|
+
type SessionArtifactPointer,
|
|
47
|
+
} from "./workflowState.ts";
|
|
48
|
+
|
|
49
|
+
/** Minimal context slice — `ExtensionContext` satisfies it (the `BranchSource` precedent). */
|
|
50
|
+
export interface SessionDataCtx extends BranchSource {
|
|
51
|
+
cwd: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The current session's run_id from the rebuilt workflow-state; `null` when the session has no
|
|
56
|
+
* identity (no stamp fallback — see the header).
|
|
57
|
+
*/
|
|
58
|
+
export function activeSessionRunId(ctx: SessionDataCtx): string | null {
|
|
59
|
+
try {
|
|
60
|
+
const runId = rebuildWorkflowState(branchOf(ctx)).run_id;
|
|
61
|
+
if (typeof runId === "string" && runId.length > 0) return runId;
|
|
62
|
+
} catch {
|
|
63
|
+
// a throwing getBranch means no resolvable identity — degrade to null
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The current session's data dir as a pure path (no mkdir); `null` without a run_id. */
|
|
69
|
+
export function activeSessionDataDir(ctx: SessionDataCtx): string | null {
|
|
70
|
+
const runId = activeSessionRunId(ctx);
|
|
71
|
+
if (runId === null) return null;
|
|
72
|
+
return sessionDataDir(ctx.cwd, runId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Ensure (mkdir -p) the current session's data dir; `null` + a warning on failure. */
|
|
76
|
+
export function ensureSessionDataDir(ctx: SessionDataCtx): string | null {
|
|
77
|
+
const dir = activeSessionDataDir(ctx);
|
|
78
|
+
if (dir === null) return null;
|
|
79
|
+
try {
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.error(`perk: warning: could not create session data dir ${dir}: ${error}`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return dir;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read a session-data file; `null` on no run_id or an absent file (normal, branchable), and on
|
|
90
|
+
* read errors (with a stderr warning). Never throws.
|
|
91
|
+
*/
|
|
92
|
+
export function readSessionData(ctx: SessionDataCtx, name: string): string | null {
|
|
93
|
+
const dir = activeSessionDataDir(ctx);
|
|
94
|
+
if (dir === null) return null;
|
|
95
|
+
const path = join(dir, name);
|
|
96
|
+
if (!existsSync(path)) return null;
|
|
97
|
+
try {
|
|
98
|
+
return readFileSync(path, "utf8");
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error(`perk: warning: could not read session data ${path}: ${error}`);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Write a session-data file (creating the data dir lazily); returns the absolute path, or
|
|
107
|
+
* `null` + a stderr warning on any failure. Never throws.
|
|
108
|
+
*/
|
|
109
|
+
export function writeSessionData(
|
|
110
|
+
ctx: SessionDataCtx,
|
|
111
|
+
name: string,
|
|
112
|
+
content: string,
|
|
113
|
+
): string | null {
|
|
114
|
+
const dir = ensureSessionDataDir(ctx);
|
|
115
|
+
if (dir === null) return null;
|
|
116
|
+
const path = join(dir, name);
|
|
117
|
+
try {
|
|
118
|
+
writeFileSync(path, content, "utf8");
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error(`perk: warning: could not write session data ${path}: ${error}`);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return path;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The session-artifact digest convention: `sha256:` + lowercase hex of the UTF-8 bytes. */
|
|
127
|
+
export function digestSessionData(content: string): string {
|
|
128
|
+
return `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Per-name pointer identity: same run_id + same digest (the custom-equals comparator). */
|
|
132
|
+
function artifactMapsEqual(
|
|
133
|
+
rebuilt: Record<string, SessionArtifactPointer> | null | undefined,
|
|
134
|
+
expected: Record<string, SessionArtifactPointer> | null | undefined,
|
|
135
|
+
): boolean {
|
|
136
|
+
const a = rebuilt ?? {};
|
|
137
|
+
const b = expected ?? {};
|
|
138
|
+
const names = Object.keys(b);
|
|
139
|
+
if (Object.keys(a).length !== names.length) return false;
|
|
140
|
+
return names.every(
|
|
141
|
+
(name) => a[name]?.run_id === b[name]?.run_id && a[name]?.digest === b[name]?.digest,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Write a session artifact AND record its provenance pointer in `perk:workflow-state`.
|
|
147
|
+
* Returns the absolute written path only when the artifact is *fully recorded* (file written,
|
|
148
|
+
* read back, digested, pointer strict-appended); `null` on any failure — the seam/module has
|
|
149
|
+
* already warned, and an orphan file (pointer-append failure) is gitignored scratch for the
|
|
150
|
+
* GC to prune. Never throws.
|
|
151
|
+
*/
|
|
152
|
+
export function writeSessionArtifact(
|
|
153
|
+
sink: EntrySink,
|
|
154
|
+
ctx: SessionDataCtx & ReportTarget,
|
|
155
|
+
name: string,
|
|
156
|
+
content: string,
|
|
157
|
+
): string | null {
|
|
158
|
+
const written = writeSessionData(ctx, name, content);
|
|
159
|
+
if (written === null) return null; // already warned; never point at an unwritten file
|
|
160
|
+
|
|
161
|
+
// Digest the bytes as read back from disk — catches encoding/disk surprises.
|
|
162
|
+
const readBack = readSessionData(ctx, name);
|
|
163
|
+
if (readBack === null) {
|
|
164
|
+
console.error(`perk: warning: session artifact ${written} unreadable after write`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const runId = activeSessionRunId(ctx);
|
|
169
|
+
if (runId === null) return null; // unreachable after a successful write; belt-and-braces
|
|
170
|
+
const pointer: SessionArtifactPointer = {
|
|
171
|
+
run_id: runId,
|
|
172
|
+
name,
|
|
173
|
+
path: relative(ctx.cwd, written),
|
|
174
|
+
digest: digestSessionData(readBack),
|
|
175
|
+
at: new Date().toISOString(),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Per-field LWW: each append must carry the WHOLE merged map so sibling artifacts survive.
|
|
179
|
+
const merged: Record<string, SessionArtifactPointer> = {
|
|
180
|
+
...(rebuildWorkflowState(branchOf(ctx)).session_artifacts ?? {}),
|
|
181
|
+
[name]: pointer,
|
|
182
|
+
};
|
|
183
|
+
const ok = appendWorkflowState(sink, ctx, {
|
|
184
|
+
data: { session_artifacts: merged },
|
|
185
|
+
field: "session_artifacts",
|
|
186
|
+
expected: merged,
|
|
187
|
+
scope: "session-data",
|
|
188
|
+
failure: `session_artifacts pointer read-back failed for ${name}`,
|
|
189
|
+
equals: artifactMapsEqual,
|
|
190
|
+
});
|
|
191
|
+
return ok ? written : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Read a session artifact through its provenance pointer; fail-open `null` when validation
|
|
196
|
+
* refuses. Tiering: no identity / no pointer / run_id mismatch (the designed fork-isolation
|
|
197
|
+
* path) → silent `null`; pointer matches but the file is absent, unreadable, or its digest
|
|
198
|
+
* differs (rewind, tamper) → stderr warning + `null`. The path is always DERIVED from
|
|
199
|
+
* `run_id` + `name` via the seam — `pointer.path` is never dereferenced. Never throws.
|
|
200
|
+
*/
|
|
201
|
+
export function readSessionArtifact(
|
|
202
|
+
ctx: SessionDataCtx,
|
|
203
|
+
name: string,
|
|
204
|
+
): { path: string; content: string } | null {
|
|
205
|
+
const runId = activeSessionRunId(ctx);
|
|
206
|
+
if (runId === null) return null;
|
|
207
|
+
let pointer: SessionArtifactPointer | undefined;
|
|
208
|
+
try {
|
|
209
|
+
pointer = rebuildWorkflowState(branchOf(ctx)).session_artifacts?.[name];
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
if (pointer === undefined) return null;
|
|
214
|
+
if (pointer.run_id !== runId) return null; // fork / concurrent isolation — by design, silent
|
|
215
|
+
|
|
216
|
+
const path = join(sessionDataDir(ctx.cwd, runId), name);
|
|
217
|
+
const content = readSessionData(ctx, name);
|
|
218
|
+
if (content === null) {
|
|
219
|
+
console.error(`perk: warning: session artifact ${name} has a pointer but no file at ${path}`);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
if (digestSessionData(content) !== pointer.digest) {
|
|
223
|
+
console.error(
|
|
224
|
+
`perk: warning: session artifact ${path} digest mismatch (rewound or modified) — refusing`,
|
|
225
|
+
);
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
return { path, content };
|
|
229
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// A small, reusable structured-output substrate over `@earendil-works/pi-ai`.
|
|
2
|
+
//
|
|
3
|
+
// pi-ai has no dedicated JSON-mode; structured output is done via tool calling. This module wraps
|
|
4
|
+
// that idiom into two pure, dependency-light, NEVER-throwing helpers:
|
|
5
|
+
// - `resolveModelAuth(ctx)` reuses the session's configured + authenticated model (the sanctioned
|
|
6
|
+
// `ModelRegistry.getApiKeyAndHeaders` path), and
|
|
7
|
+
// - `completeStructured(opts)` builds a single-tool `Context`, calls `complete`, and validates the
|
|
8
|
+
// returned tool-call arguments against a TypeBox schema.
|
|
9
|
+
// Both report failure via a soft `{ ok:false, error }` outcome — no throws ever reach the caller, so
|
|
10
|
+
// every consumer can stay fail-safe with a deterministic fallback. The first consumer is
|
|
11
|
+
// `extension/factories/planTitle.ts` (LLM-generated plan-issue titles).
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
type Api,
|
|
15
|
+
type Context,
|
|
16
|
+
complete,
|
|
17
|
+
type Model,
|
|
18
|
+
type Static,
|
|
19
|
+
type Tool,
|
|
20
|
+
type ToolCall,
|
|
21
|
+
type TSchema,
|
|
22
|
+
validateToolCall,
|
|
23
|
+
} from "@earendil-works/pi-ai";
|
|
24
|
+
|
|
25
|
+
/** Structurally-minimal slice of `ExtensionContext` needed to reuse the session's model + auth. */
|
|
26
|
+
export interface ModelAuthContext {
|
|
27
|
+
model: Model<Api> | undefined;
|
|
28
|
+
modelRegistry: {
|
|
29
|
+
getApiKeyAndHeaders(
|
|
30
|
+
model: Model<Api>,
|
|
31
|
+
): Promise<
|
|
32
|
+
{ ok: true; apiKey?: string; headers?: Record<string, string> } | { ok: false; error: string }
|
|
33
|
+
>;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolved model + auth, or a soft failure (no model / unresolved auth). */
|
|
38
|
+
export type ResolvedModelAuth =
|
|
39
|
+
| { ok: true; model: Model<Api>; apiKey?: string; headers?: Record<string, string> }
|
|
40
|
+
| { ok: false; error: string };
|
|
41
|
+
|
|
42
|
+
/** The generic structured-output outcome — soft success/failure, never a throw. */
|
|
43
|
+
export interface StructuredOutcome<T> {
|
|
44
|
+
ok: boolean;
|
|
45
|
+
value?: T;
|
|
46
|
+
error?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the session's model and its API key + headers via the sanctioned `ModelRegistry`. Returns
|
|
51
|
+
* `{ ok:false }` when no model is configured or auth cannot be resolved (offline, no key). Pure
|
|
52
|
+
* apart from the single `getApiKeyAndHeaders` await, which is wrapped so a throw becomes a soft
|
|
53
|
+
* failure.
|
|
54
|
+
*/
|
|
55
|
+
export async function resolveModelAuth(ctx: ModelAuthContext): Promise<ResolvedModelAuth> {
|
|
56
|
+
const model = ctx.model;
|
|
57
|
+
if (!model) return { ok: false, error: "no model configured for this session" };
|
|
58
|
+
try {
|
|
59
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
60
|
+
if (!auth.ok) return { ok: false, error: auth.error };
|
|
61
|
+
return { ok: true, model, apiKey: auth.apiKey, headers: auth.headers };
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CompleteStructuredOptions<S extends TSchema> {
|
|
68
|
+
model: Model<Api>;
|
|
69
|
+
/** TypeBox schema describing the structured result (becomes the single tool's parameters). */
|
|
70
|
+
schema: S;
|
|
71
|
+
/** The forced tool's name (the model is instructed to call it). */
|
|
72
|
+
toolName: string;
|
|
73
|
+
toolDescription: string;
|
|
74
|
+
/** Optional system prompt. */
|
|
75
|
+
system?: string;
|
|
76
|
+
/** Instruction prepended to the input in the single user message. */
|
|
77
|
+
instruction: string;
|
|
78
|
+
/** The payload (e.g. the document to summarize/classify). */
|
|
79
|
+
input: string;
|
|
80
|
+
apiKey?: string;
|
|
81
|
+
headers?: Record<string, string>;
|
|
82
|
+
signal?: AbortSignal;
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Ask the model for a structured object via a single tool call, validated against `schema`. Never
|
|
88
|
+
* throws: any model error, missing tool call, or schema-invalid arguments yields `{ ok:false }`.
|
|
89
|
+
*
|
|
90
|
+
* Deliberately sets no provider-specific `toolChoice` — the generic `complete` surface has no
|
|
91
|
+
* portable forced-tool value (providers disagree: `"required"` vs `"any"`), so tool use is requested
|
|
92
|
+
* via the prompt and the call site keeps a deterministic fallback. No `maxTokens` cap is set, so
|
|
93
|
+
* reasoning models are not truncated before emitting the tool call.
|
|
94
|
+
*/
|
|
95
|
+
export async function completeStructured<S extends TSchema>(
|
|
96
|
+
opts: CompleteStructuredOptions<S>,
|
|
97
|
+
): Promise<StructuredOutcome<Static<S>>> {
|
|
98
|
+
const tool: Tool = {
|
|
99
|
+
name: opts.toolName,
|
|
100
|
+
description: opts.toolDescription,
|
|
101
|
+
parameters: opts.schema,
|
|
102
|
+
};
|
|
103
|
+
const context: Context = {
|
|
104
|
+
systemPrompt: opts.system,
|
|
105
|
+
messages: [
|
|
106
|
+
{
|
|
107
|
+
role: "user",
|
|
108
|
+
content: `${opts.instruction}\n\n${opts.input}`,
|
|
109
|
+
timestamp: Date.now(),
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
tools: [tool],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
let msg: Awaited<ReturnType<typeof complete>>;
|
|
116
|
+
try {
|
|
117
|
+
msg = await complete(opts.model, context, {
|
|
118
|
+
apiKey: opts.apiKey,
|
|
119
|
+
headers: opts.headers,
|
|
120
|
+
signal: opts.signal,
|
|
121
|
+
timeoutMs: opts.timeoutMs,
|
|
122
|
+
});
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (msg.stopReason === "error" || msg.stopReason === "aborted") {
|
|
128
|
+
return { ok: false, error: msg.errorMessage ?? `model stopped: ${msg.stopReason}` };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const toolCalls = msg.content.filter((b): b is ToolCall => b.type === "toolCall");
|
|
132
|
+
const call = toolCalls.find((c) => c.name === opts.toolName) ?? toolCalls[0];
|
|
133
|
+
if (!call) return { ok: false, error: "model returned no tool call" };
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const value = validateToolCall([tool], call) as Static<S>;
|
|
137
|
+
return { ok: true, value };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
140
|
+
}
|
|
141
|
+
}
|