@turri-dev/cli 0.1.2 → 0.1.3-next.162a2c3
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 +1 -1
- package/dist/chat-skills.js +1 -1
- package/dist/cli-options.js +82 -0
- package/dist/config.js +26 -3
- package/dist/conform-cli.js +13 -5
- package/dist/environment.js +19 -0
- package/dist/extension.js +174 -62
- package/dist/identity.js +1 -1
- package/dist/init-config.js +3 -3
- package/dist/loop-cli.js +157 -29
- package/dist/loop-tools.js +1 -1
- package/dist/pi-launch.js +18 -4
- package/dist/pi.js +14 -2
- package/dist/picture-row.js +21 -0
- package/dist/plain/errors.js +1 -1
- package/dist/plain/files.js +1 -1
- package/dist/plain/liquid.js +2 -2
- package/dist/question-dialog.js +5 -1
- package/dist/regenerate/activity.js +19 -0
- package/dist/regenerate/confine.js +1 -0
- package/dist/regenerate/conformance-agents.js +220 -11
- package/dist/regenerate/conformance.js +87 -33
- package/dist/regenerate/debugger.js +27 -3
- package/dist/regenerate/environment-error.js +63 -9
- package/dist/regenerate/loop.js +78 -6
- package/dist/regenerate/panel-dock.js +86 -0
- package/dist/regenerate/prompt.js +4 -2
- package/dist/regenerate/regenerate.js +24 -6
- package/dist/regenerate/release.js +8 -6
- package/dist/regenerate/requires.js +55 -2
- package/dist/regenerate/side-panel.js +14 -4
- package/dist/regenerate/stop-report.js +13 -0
- package/dist/regenerate/test-scripts.js +45 -9
- package/dist/regenerate/unit-review.js +11 -0
- package/dist/regenerate-cli.js +24 -29
- package/dist/resume-line.js +4 -0
- package/dist/run-report.js +289 -0
- package/dist/setup.js +35 -16
- package/dist/spec-diff-cli.js +17 -6
- package/dist/spec-tools.js +8 -44
- package/docs/config.md +25 -13
- package/docs/development.md +7 -0
- package/docs/headless.md +170 -16
- package/docs/installation.md +1 -1
- package/docs/parser.md +1 -1
- package/docs/regenerating.md +72 -17
- package/docs/using.md +16 -5
- package/genome/skills/implement-conformance-testing-script/SKILL.md +1 -1
- package/package.json +3 -3
package/dist/identity.js
CHANGED
|
@@ -25,7 +25,7 @@ export const TURRI_IDENTITY = [
|
|
|
25
25
|
"- A pulse is one unit of work regenerating the code - a spec diff regenerated in a codebase, or its conformance",
|
|
26
26
|
" tests. It was called a job, and still is in places: the job_id a tool takes is a pulse's id, and parallel-jobs and",
|
|
27
27
|
" allow-to-stop-jobs in config.turri.yaml are about pulses. \"Job\" in what you read is a pulse; say pulse.",
|
|
28
|
-
"- regen is the side panel (
|
|
28
|
+
"- regen is the side panel (ctrl+h): the spec with each functionality and item marked built, regenerating,",
|
|
29
29
|
" planned or waiting - and behind it the pulses (/pulses), every pulse regenerating, planned and finished. Say regen,",
|
|
30
30
|
" lowercase. It was the regeneration, the job queue, or the job panel.",
|
|
31
31
|
"- mutate, in a skill's activity, is write_spec_diff: a spec change written and regenerated in one step.",
|
package/dist/init-config.js
CHANGED
|
@@ -292,7 +292,7 @@ function whose(vendor, bedrock) {
|
|
|
292
292
|
return `${VENDORS[choice.maker].label}'s on Amazon Bedrock (${choice.region})`;
|
|
293
293
|
}
|
|
294
294
|
/**
|
|
295
|
-
* What a config.yaml in `folder` says that turri reads too -
|
|
295
|
+
* What a config.yaml in `folder` says that turri reads too - another tool's testing scripts, say - by turri's names
|
|
296
296
|
* (`template_dir` read as `template-dir`), in the file's order; and the keys it leaves, turri reading none of them. The
|
|
297
297
|
* two models are the vendor's, so they are not taken.
|
|
298
298
|
*/
|
|
@@ -338,7 +338,7 @@ export function initConfig(dir, options = {}) {
|
|
|
338
338
|
const existing = existsSync(path) && options.force !== true;
|
|
339
339
|
const { harness, coding } = modelsOf(vendor, options.bedrock);
|
|
340
340
|
const lines = modelLines(harness, coding);
|
|
341
|
-
// A new file takes what a config.yaml beside it -
|
|
341
|
+
// A new file takes what a config.yaml beside it - another tool's, say - says that turri reads too: its testing scripts, say.
|
|
342
342
|
const shared = existing ? { carried: [], left: [] } : sharedWithLegacy(folder);
|
|
343
343
|
const config = existing
|
|
344
344
|
? withModels(readFileSync(path, "utf8"), lines)
|
|
@@ -384,7 +384,7 @@ const USAGE = [
|
|
|
384
384
|
"a mix of Anthropic and Google, the starting point for any provider pi supports. On amazon-bedrock, --maker and",
|
|
385
385
|
"--region say whose models and where (anthropic: global, us, eu, jp, au; openai: global, us) - asked when not given.",
|
|
386
386
|
"A config.turri.yaml already there keeps everything but its two models; --force writes the whole of it again. A new",
|
|
387
|
-
"one takes from a config.yaml beside it -
|
|
387
|
+
"one takes from a config.yaml beside it - another tool's, say - the keys turri reads too, its testing scripts say, and leaves",
|
|
388
388
|
"config.yaml as it is.",
|
|
389
389
|
].join("\n");
|
|
390
390
|
/** Ask until the answer is one `read` takes. */
|
package/dist/loop-cli.js
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import { sweepOrphanedGroups } from "./regenerate/shell.js";
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
import { configForSpec, modelSpec, projectModels, readProjectConfig } from "./config.js";
|
|
3
|
+
import { resolve, sep } from "node:path";
|
|
4
|
+
import { configForSpec, inheritModel, modelOnly, modelSpec, projectModels, readProjectConfig, roleModel, ROLE_THINKING, splitModelSpec, } from "./config.js";
|
|
5
5
|
import { PACKAGE_ROOT } from "./environment.js";
|
|
6
6
|
import { c } from "./module-report.js";
|
|
7
7
|
import { tryItCommand } from "./regenerate/delivery.js";
|
|
8
8
|
import { describeDiagnosis, diagnoseFailure } from "./regenerate/diagnose.js";
|
|
9
9
|
import { createJobRegistry, pulseName } from "./regenerate/jobs.js";
|
|
10
10
|
import { conformanceChooser } from "./regenerate/choose-conformance.js";
|
|
11
|
-
import { createWorkLoop, describeModules, promptOf } from "./regenerate/loop.js";
|
|
12
|
-
import { projectDirOf } from "./regenerate/paths.js";
|
|
11
|
+
import { createWorkLoop, describeModules, functionalityCompletion, promptOf } from "./regenerate/loop.js";
|
|
12
|
+
import { MODULES_DIR, projectDirOf } from "./regenerate/paths.js";
|
|
13
|
+
import { exportModule } from "./regenerate/release.js";
|
|
14
|
+
import { listFiles } from "./regenerate/testing.js";
|
|
15
|
+
import { startRunReport } from "./run-report.js";
|
|
16
|
+
import { configKeyOptions, optionValue } from "./cli-options.js";
|
|
13
17
|
/**
|
|
14
18
|
* What every command that builds - `turri regenerate`, `conform`, `spec-diff` - does around its own
|
|
15
19
|
* request of the work loop, as the chat does it: the project's models, a job that fails run once more and, failing again,
|
|
16
20
|
* diagnosed, a line as each job starts and ends; then the loop run until nothing more can start, how each job ended,
|
|
17
|
-
* and exit 1 when any failed. With `dryRun`, the work is planned and the prompt of the first job each codebase would
|
|
21
|
+
* and exit 1 when any failed or a module is not complete. With `dryRun`, the work is planned and the prompt of the first job each codebase would
|
|
18
22
|
* start printed, and nothing runs.
|
|
19
23
|
*/
|
|
20
24
|
export async function runLoop(options) {
|
|
@@ -24,14 +28,48 @@ export async function runLoop(options) {
|
|
|
24
28
|
if (orphans > 0)
|
|
25
29
|
console.log(`Stopped ${orphans} process group${orphans === 1 ? "" : "s"} a turri that was killed left running.`);
|
|
26
30
|
const project = { ...readProjectConfig(process.cwd()), ...Object.fromEntries(Object.entries(configForSpec(plainFile)).filter(([, value]) => value !== undefined)) };
|
|
27
|
-
|
|
31
|
+
// The models as the config has them - with a command's own options read as its keys (headlessOptions), as the chat reads them.
|
|
32
|
+
const model = modelSpec(projectModels(project).coding);
|
|
28
33
|
const projectDir = projectDirOf(plainFile);
|
|
34
|
+
const buildDir = options.buildDir !== undefined && options.dryRun !== true ? resolve(options.buildDir) : undefined;
|
|
35
|
+
if (buildDir !== undefined)
|
|
36
|
+
refuseBuildDir(buildDir, projectDir);
|
|
37
|
+
// The code, exported into --build-dir as the process exits - however it exits - before the report's last write.
|
|
38
|
+
let exported;
|
|
39
|
+
if (buildDir !== undefined) {
|
|
40
|
+
process.once("exit", () => {
|
|
41
|
+
exported = exportCode(plainFile, buildDir);
|
|
42
|
+
console.error(exported.exported === true ? `The code is in ${buildDir} (${String(exported.module)} at ${String(exported.commit)}).` : `Nothing exported to ${buildDir}: ${String(exported.why)}`);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const report = options.report !== undefined && options.dryRun !== true
|
|
46
|
+
? startRunReport({
|
|
47
|
+
file: options.report,
|
|
48
|
+
command: options.command ?? process.argv.slice(2),
|
|
49
|
+
specFile: plainFile,
|
|
50
|
+
projectDir,
|
|
51
|
+
...(buildDir !== undefined ? { buildDir } : {}),
|
|
52
|
+
conformanceTests: conformanceLabel(project.conformanceTests),
|
|
53
|
+
review: project.review === true,
|
|
54
|
+
models: reportModels(project, model),
|
|
55
|
+
})
|
|
56
|
+
: undefined;
|
|
57
|
+
try {
|
|
58
|
+
return await runLoopReporting({ ...options, plainFile, project, model, projectDir, reporter: report, exported: () => exported });
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
report?.crashed(error);
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function runLoopReporting(options) {
|
|
66
|
+
const { plainFile, project, model, projectDir, reporter: report } = options;
|
|
29
67
|
// Each job as it starts and as it ends - the transcript of each is in .turri/jobs/.
|
|
30
68
|
const seen = new Map();
|
|
31
69
|
const registry = createJobRegistry({
|
|
32
70
|
retries: 1,
|
|
33
71
|
diagnose: async (job, failures, jobLog) => {
|
|
34
|
-
const diagnosis = await diagnoseFailure({ job, failures, projectDir, harnessDir: PACKAGE_ROOT, jobLog, model: modelSpec(
|
|
72
|
+
const diagnosis = await diagnoseFailure({ job, failures, projectDir, harnessDir: PACKAGE_ROOT, jobLog, model: modelSpec(modelOnly(job.model ?? model)) });
|
|
35
73
|
return diagnosis !== undefined ? describeDiagnosis(diagnosis) : undefined;
|
|
36
74
|
},
|
|
37
75
|
onChange: (job) => {
|
|
@@ -47,18 +85,28 @@ export async function runLoop(options) {
|
|
|
47
85
|
console.log(`${mark} ${job.id} ${pulseName(job)} - ${job.status}`);
|
|
48
86
|
if (job.status !== "succeeded")
|
|
49
87
|
console.log(c.dim((job.announce ?? job.report ?? job.error ?? "").trim()));
|
|
88
|
+
report?.write();
|
|
50
89
|
},
|
|
51
90
|
});
|
|
52
91
|
const planner = modelSpec(projectModels(project).harness);
|
|
53
92
|
const loop = createWorkLoop({
|
|
54
93
|
registry,
|
|
55
94
|
cwd: process.cwd(),
|
|
56
|
-
...(options.buildDir !== undefined ? { buildDir: resolve(options.buildDir) } : {}),
|
|
57
95
|
...(model !== undefined ? { modelFor: () => model } : {}),
|
|
58
96
|
...(planner !== undefined ? { plannerModel: planner } : {}),
|
|
59
97
|
...(options.dryRun === true ? { dryRun: true } : {}),
|
|
60
|
-
// Under conformance-tests: chosen or module, the chooser decides as a pulse ends, as in the chat
|
|
61
|
-
|
|
98
|
+
// Under conformance-tests: chosen or module, the chooser decides as a pulse ends, as in the chat - on the harness
|
|
99
|
+
// model, else the coding model: never pi's own default, which may be one this account cannot reach.
|
|
100
|
+
onChoose: conformanceChooser({ model: () => planner ?? model, ...(options.dryRun === true ? { dryRun: true } : {}) }),
|
|
101
|
+
});
|
|
102
|
+
report?.attach({
|
|
103
|
+
jobs: () => registry.list(),
|
|
104
|
+
modules: () => moduleStates(loop.drift(plainFile)),
|
|
105
|
+
completion: () => functionalityCompletion(plainFile),
|
|
106
|
+
atExit: () => {
|
|
107
|
+
const exported = options.exported();
|
|
108
|
+
return exported !== undefined ? { build_dir_export: exported } : undefined;
|
|
109
|
+
},
|
|
62
110
|
});
|
|
63
111
|
let planned = await options.ask(loop);
|
|
64
112
|
console.log(describeModules(planned, { for: "command line" }));
|
|
@@ -68,6 +116,13 @@ export async function runLoop(options) {
|
|
|
68
116
|
const why = toStartOver
|
|
69
117
|
.map(({ startOver }) => `${startOver.codebases.join(", ")} was built without ${startOver.requires.join(", ")}, which it now requires: no change to its code can bring it in.`)
|
|
70
118
|
.join("\n");
|
|
119
|
+
// No terminal and no --start-over: no one to ask, so the run stops here rather than build nothing and say it is done.
|
|
120
|
+
if (options.startOver !== true && process.stdin.isTTY !== true) {
|
|
121
|
+
const message = `${why}\nThere is no terminal to ask whether to start over: run it again with --start-over to start over without asking.`;
|
|
122
|
+
console.error(c.red(message));
|
|
123
|
+
report?.end("incomplete", message);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
71
126
|
const agreed = options.startOver === true ||
|
|
72
127
|
(await askAtTerminal("Start over?", `${why}\nStarting over takes its code and conformance tests out - they stay in its history - and builds it again from the bottom of the chain.`));
|
|
73
128
|
if (agreed === true) {
|
|
@@ -91,31 +146,104 @@ export async function runLoop(options) {
|
|
|
91
146
|
console.log(`\n${describeModules(loop.status(), { for: "command line" })}`);
|
|
92
147
|
const ended = registry.list();
|
|
93
148
|
const failed = ended.filter((job) => job.status === "failed");
|
|
94
|
-
|
|
149
|
+
// Complete: every codebase regenerated from the spec as it is, its conformance tests too - nothing halted, nothing
|
|
150
|
+
// waiting for a start over, nothing the planner left out.
|
|
151
|
+
const unfinished = moduleStates(loop.drift(plainFile)).filter((module) => module.state !== "complete");
|
|
152
|
+
const verdict = failed.length > 0
|
|
153
|
+
? c.red(c.bold(`${failed.length} job${failed.length === 1 ? "" : "s"} failed`))
|
|
154
|
+
: unfinished.length > 0
|
|
155
|
+
? c.red(c.bold(`not complete: ${unfinished.map((module) => `${module.module}${module.why !== undefined ? ` - ${module.why}` : ""}`).join("; ")}`))
|
|
156
|
+
: c.green(c.bold("all jobs done"));
|
|
157
|
+
console.log(`\n${verdict}`);
|
|
95
158
|
for (const job of [...ended].reverse())
|
|
96
159
|
console.log(` ${job.id} ${pulseName(job)}: ${job.status}`);
|
|
97
160
|
if (ended.some((job) => job.status === "succeeded"))
|
|
98
161
|
console.log(`\n${tryItCommand(plainFile)}`);
|
|
99
|
-
|
|
162
|
+
report?.end(failed.length > 0 ? "failed" : unfinished.length > 0 ? "incomplete" : "completed");
|
|
163
|
+
process.exit(failed.length === 0 && unfinished.length === 0 ? 0 : 1);
|
|
100
164
|
}
|
|
101
|
-
/**
|
|
102
|
-
export function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
165
|
+
/** How each codebase stands, from what is left to regenerate there. */
|
|
166
|
+
export function moduleStates(reports) {
|
|
167
|
+
return reports.map((report) => {
|
|
168
|
+
const state = report.running !== undefined
|
|
169
|
+
? "running"
|
|
170
|
+
: report.startOver !== undefined
|
|
171
|
+
? "start_over_needed"
|
|
172
|
+
: report.why !== undefined
|
|
173
|
+
? "halted"
|
|
174
|
+
: report.code === null && (report.conformance === null || report.conformance === undefined) && report.scheduled.length === 0
|
|
175
|
+
? "complete"
|
|
176
|
+
: "incomplete";
|
|
177
|
+
const why = state === "start_over_needed"
|
|
178
|
+
? `built without ${report.startOver.requires.join(", ")}, which it now requires: run with --start-over`
|
|
179
|
+
: state === "incomplete"
|
|
180
|
+
? report.code !== null && report.code !== undefined
|
|
181
|
+
? "its code is not regenerated from the spec as it is"
|
|
182
|
+
: "its conformance tests are not written for what is built"
|
|
183
|
+
: report.why;
|
|
184
|
+
return { module: report.module, state, ...(why !== undefined ? { why } : {}) };
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* A --build-dir the export would wipe something in: the project, a folder holding it, or one inside plain_modules.
|
|
189
|
+
* Anything else is the export's own, its contents replaced as the run ends.
|
|
190
|
+
*/
|
|
191
|
+
function refuseBuildDir(buildDir, projectDir) {
|
|
192
|
+
const within = (dir, of) => dir === of || dir.startsWith(`${of}${sep}`);
|
|
193
|
+
const why = within(projectDir, buildDir)
|
|
194
|
+
? "it holds the project, and the export replaces what is in it"
|
|
195
|
+
: within(buildDir, resolve(projectDir, MODULES_DIR))
|
|
196
|
+
? `it is inside ${MODULES_DIR}, which turri regenerates in`
|
|
197
|
+
: undefined;
|
|
198
|
+
if (why === undefined)
|
|
199
|
+
return;
|
|
200
|
+
console.error(`--build-dir ${buildDir} cannot be used: ${why}. Name a folder of its own - the code is exported there as the run ends.`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
/** The furthest-built codebase's last commit, exported into `dir` - or why nothing was. */
|
|
204
|
+
function exportCode(plainFile, dir) {
|
|
205
|
+
try {
|
|
206
|
+
const deployment = exportModule(plainFile, undefined, dir);
|
|
207
|
+
const files = listFiles(dir).length;
|
|
208
|
+
// Its history began, and nothing landed in it yet: only the harness's own files, which are left out.
|
|
209
|
+
if (files === 0)
|
|
210
|
+
return { exported: false, dir, why: `nothing has landed in ${deployment.moduleName} yet: its codebase holds no code` };
|
|
211
|
+
return { exported: true, dir, module: deployment.moduleName, commit: deployment.commit, files };
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
return { exported: false, dir, why: error instanceof Error ? error.message : String(error) };
|
|
117
215
|
}
|
|
118
|
-
|
|
216
|
+
}
|
|
217
|
+
/** `conformance-tests:` as the report says it - not set is false. */
|
|
218
|
+
function conformanceLabel(setting) {
|
|
219
|
+
return setting === true ? "all" : setting === "chosen" || setting === "module" ? setting : "false";
|
|
220
|
+
}
|
|
221
|
+
/** The models of the three roles as this run uses them: the coding model given, the others as they fall back - each with the level it thinks at. */
|
|
222
|
+
function reportModels(project, model) {
|
|
223
|
+
const coding = splitModelSpec(model);
|
|
224
|
+
const harness = projectModels(project).harness ?? modelOnly(model);
|
|
225
|
+
// As the judge's agent finds it: its models: entry over judge-model over models.default, else the coding model's.
|
|
226
|
+
const judge = inheritModel(roleModel(project, "judge"), modelOnly(model)) ?? {};
|
|
227
|
+
const choice = (given, fallback) => ({
|
|
228
|
+
...(given.model !== undefined ? { model: given.model } : {}),
|
|
229
|
+
...((given.thinking ?? fallback) !== undefined ? { thinking: given.thinking ?? fallback } : {}),
|
|
230
|
+
});
|
|
231
|
+
return {
|
|
232
|
+
harness: choice(harness, ROLE_THINKING.planner),
|
|
233
|
+
coding: choice(coding, ROLE_THINKING.coding),
|
|
234
|
+
judge: choice(judge, ROLE_THINKING.judge),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The options every command that regenerates takes for a headless run: `--report` and `--build-dir`, and the config's
|
|
239
|
+
* own keys (configKeyOptions) - `--harness-model`, `--coding-model` (`--model`), `--judge-model`, `--review` and, where
|
|
240
|
+
* `conformance` is true, `--conformance-tests` - read as if config.turri.yaml had them. Exits on a value it cannot read.
|
|
241
|
+
*/
|
|
242
|
+
export function headlessOptions(options, conformance) {
|
|
243
|
+
configKeyOptions(options, { conformance, review: true });
|
|
244
|
+
const report = optionValue(options, "report");
|
|
245
|
+
const buildDir = optionValue(options, "build-dir");
|
|
246
|
+
return { ...(report !== undefined ? { report } : {}), ...(buildDir !== undefined ? { buildDir } : {}) };
|
|
119
247
|
}
|
|
120
248
|
/** Yes or no at the terminal - or undefined when there is none, and so no one to ask. */
|
|
121
249
|
async function askAtTerminal(title, body) {
|
package/dist/loop-tools.js
CHANGED
|
@@ -73,7 +73,7 @@ export async function loopReply(loop, statuses, wait) {
|
|
|
73
73
|
export function stopJob(jobs, named, reason) {
|
|
74
74
|
const matches = jobs.list().filter((job) => job.id.startsWith(named.trim()));
|
|
75
75
|
if (matches.length === 0)
|
|
76
|
-
return `No pulse ${named}. /
|
|
76
|
+
return `No pulse ${named}. /pulses shows them.`;
|
|
77
77
|
if (matches.length > 1)
|
|
78
78
|
return `'${named}' matches ${matches.map((job) => job.id).join(", ")}. Give more of the id.`;
|
|
79
79
|
const target = matches[0];
|
package/dist/pi-launch.js
CHANGED
|
@@ -9,19 +9,33 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
11
|
import { main, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { ProcessTerminal } from "@earendil-works/pi-tui";
|
|
12
13
|
import "./terminal-images.js";
|
|
14
|
+
import { resumeLine } from "./resume-line.js";
|
|
13
15
|
/**
|
|
14
16
|
* pi, started the way its own CLI starts it, for `turri`.
|
|
15
17
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* takes
|
|
19
|
-
* machine, so it is answered for this run instead. `--verbose` still shows it.
|
|
18
|
+
* What differs is pi's own branding, because turri shows its own: its startup header - its
|
|
19
|
+
* version, key hints and the list of every loaded skill, extension and theme - its update notice
|
|
20
|
+
* and its tab title. pi only takes the header from the `quietStartup` setting, and a setting would
|
|
21
|
+
* quiet every `pi` on the machine, so it is answered for this run instead. `--verbose` still shows it.
|
|
20
22
|
*/
|
|
21
23
|
const args = process.argv.slice(2);
|
|
22
24
|
if (!args.includes("--verbose")) {
|
|
23
25
|
SettingsManager.prototype.getQuietStartup = () => true;
|
|
24
26
|
}
|
|
27
|
+
// pi's "Update Available" notice is left out: the pi turri runs is its pinned dependency, which `pi update`
|
|
28
|
+
// does not touch - it moves when turri does.
|
|
29
|
+
process.env.PI_SKIP_VERSION_CHECK = "1";
|
|
30
|
+
// The terminal's tab title: pi writes `π - <folder>` (its APP_TITLE, a constant), turri a jellyfish and its name.
|
|
31
|
+
const setTitle = ProcessTerminal.prototype.setTitle;
|
|
32
|
+
ProcessTerminal.prototype.setTitle = function (title) {
|
|
33
|
+
setTitle.call(this, title.replace(/^π(?= |$)/, "🪼 turri"));
|
|
34
|
+
};
|
|
35
|
+
// What pi prints on the way out to resume the session names pi - its APP_NAME, a constant - which may not be on the
|
|
36
|
+
// PATH at all: turri takes the same flags, so it is turri the line names.
|
|
37
|
+
const write = process.stdout.write.bind(process.stdout);
|
|
38
|
+
process.stdout.write = ((chunk, ...rest) => write(typeof chunk === "string" ? resumeLine(chunk) : chunk, ...rest));
|
|
25
39
|
// pi's own keys stand: a new line is shift+enter or ctrl+j. turri's side panel is on ctrl+h (job-panel.ts PANEL_KEY).
|
|
26
40
|
// What pi's CLI does before main: the process title, pi's environment markers, the HTTP
|
|
27
41
|
// dispatcher. Not exported, so loaded from the file - beside pi's entry point, wherever npm put it.
|
package/dist/pi.js
CHANGED
|
@@ -6,7 +6,7 @@ import { inheritModel, stackOf, readProjectConfig, ROLE_THINKING, roleModel, rol
|
|
|
6
6
|
import { maskToolResults } from "./regenerate/masking.js";
|
|
7
7
|
import { checkAgainstScript, ENVIRONMENT_ERROR_TOOL, EnvironmentError, environmentErrorTool, sandboxGapOf } from "./regenerate/environment-error.js";
|
|
8
8
|
import { MODULES_DIR, projectRootOf, sessionsDir } from "./regenerate/paths.js";
|
|
9
|
-
import { currentStopSignal, describeToolCall, describeToolResult, JobStoppedError, shownName, reportActivity, reportNow, reportAgent, reportTranscript, reportUsage, } from "./regenerate/activity.js";
|
|
9
|
+
import { currentStopSignal, describeToolCall, describeToolResult, JobStoppedError, shownName, reportActivity, reportNow, reportAgent, reportTranscript, reportModelEvent, reportUsage, } from "./regenerate/activity.js";
|
|
10
10
|
import { debugLogOf, startDebugLog } from "./regenerate/debug-log.js";
|
|
11
11
|
let thinkingSource;
|
|
12
12
|
/**
|
|
@@ -262,6 +262,7 @@ async function askOnce(session, text, onDelta) {
|
|
|
262
262
|
transcript({ kind: "prompt", text: task !== undefined ? `${task}\n\n${text}` : text });
|
|
263
263
|
if (agent !== undefined)
|
|
264
264
|
reportAgent({ label: agent.label, ...(agent.model !== undefined ? { model: agent.model } : {}), turns: 1 });
|
|
265
|
+
reportModelEvent({ kind: "turn", ...modelEventOf(session) });
|
|
265
266
|
const unsubscribe = session.subscribe((event) => {
|
|
266
267
|
if (event.type === "message_update") {
|
|
267
268
|
const update = event.assistantMessageEvent;
|
|
@@ -332,6 +333,7 @@ async function askOnce(session, text, onDelta) {
|
|
|
332
333
|
reportUsage(spent);
|
|
333
334
|
if (agent !== undefined)
|
|
334
335
|
reportAgent({ label: agent.label, usage: spent });
|
|
336
|
+
reportModelEvent({ kind: "call", ...modelEventOf(session), usage: spent, ms: Date.now() - stepStarted });
|
|
335
337
|
}
|
|
336
338
|
// A provider error counts only if the turn ends on it: a later message that ends well - pi's own retry, the
|
|
337
339
|
// stream picked up again - means it recovered, and the turn's work stands.
|
|
@@ -514,6 +516,7 @@ export async function compactIfDue(session) {
|
|
|
514
516
|
const who = agent?.label ?? "the agent";
|
|
515
517
|
reportActivity(` compacting ${who}'s work: ${thousands(conversation)} tokens of it, past ${thousands(settings.atTokens)}`);
|
|
516
518
|
try {
|
|
519
|
+
const compactStarted = Date.now();
|
|
517
520
|
const result = await session.compact(settings.instructions);
|
|
518
521
|
const usage = result.usage;
|
|
519
522
|
if (usage !== undefined) {
|
|
@@ -521,7 +524,9 @@ export async function compactIfDue(session) {
|
|
|
521
524
|
reportUsage(spent);
|
|
522
525
|
if (agent !== undefined)
|
|
523
526
|
reportAgent({ label: agent.label, usage: spent });
|
|
527
|
+
reportModelEvent({ kind: "call", ...modelEventOf(session), usage: spent, ms: Date.now() - compactStarted });
|
|
524
528
|
}
|
|
529
|
+
reportModelEvent({ kind: "compaction", ...modelEventOf(session) });
|
|
525
530
|
if (agent !== undefined)
|
|
526
531
|
reportAgent({ label: agent.label, compactions: 1 });
|
|
527
532
|
reportActivity(` compacted ${who}'s work${result.estimatedTokensAfter !== undefined ? ` to about ${thousands(result.estimatedTokensAfter)} tokens` : ""}`);
|
|
@@ -542,6 +547,13 @@ const agentRoles = new WeakMap();
|
|
|
542
547
|
function agentOf(session) {
|
|
543
548
|
return agentLabels.get(session);
|
|
544
549
|
}
|
|
550
|
+
/** Who a model event is from: the agent's model as it runs now, its role and its label - an agent with no label too. */
|
|
551
|
+
function modelEventOf(session) {
|
|
552
|
+
const agent = agentOf(session);
|
|
553
|
+
const role = agentRoles.get(session);
|
|
554
|
+
const model = session.model !== undefined ? `${session.model.provider}/${session.model.id}${session.thinkingLevel ? `:${session.thinkingLevel}` : ""}` : agent?.model;
|
|
555
|
+
return { ...(model !== undefined ? { model } : {}), ...(role !== undefined ? { role } : {}), ...(agent !== undefined ? { label: agent.label } : {}) };
|
|
556
|
+
}
|
|
545
557
|
/**
|
|
546
558
|
* Have this session's turns timed, as an agent a render starts has: `limitMs` each - its role's in the config - or
|
|
547
559
|
* the default.
|
|
@@ -700,7 +712,7 @@ export async function createExecutor(opts) {
|
|
|
700
712
|
if (chosen.model !== undefined) {
|
|
701
713
|
const model = findModel(session, chosen.model);
|
|
702
714
|
if (model === undefined) {
|
|
703
|
-
throw new Error(`Model '${chosen.model}' is not
|
|
715
|
+
throw new Error(`Model '${chosen.model}' is not a model pi knows: no such id for that provider. \`turri --list-models <search>\` lists the ones it does; one of your own goes in ~/.pi/agent/models.json.`);
|
|
704
716
|
}
|
|
705
717
|
await session.setModel(model);
|
|
706
718
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { compositeTuiLine } from "@earendil-works/pi-tui";
|
|
2
|
+
/** What the banner puts between a row's text and the picture after it: back to the jellyfish's column. */
|
|
3
|
+
export const PICTURE_AT = "\x1b[2G";
|
|
4
|
+
/** The picture's escape sequence, as the banner writes it after PICTURE_AT: Kitty's, in chunks, or iTerm2's - climbing first on iTerm2. */
|
|
5
|
+
const PICTURE = /^(?:\x1b\[\d+A)?(?:(?:\x1b_G[^\x1b]*\x1b\\)+|\x1b\]1337;File=[^\x07]*\x07)/;
|
|
6
|
+
/**
|
|
7
|
+
* The banner's picture row with an overlay drawn over its text, the picture after it - left out while the overlay
|
|
8
|
+
* reaches over the picture, `pictureRight` columns from the left edge. Undefined for a row that holds no picture.
|
|
9
|
+
*
|
|
10
|
+
* What follows the picture - the padding a row narrower than the screen is given, the chat's beside the docked panel -
|
|
11
|
+
* is the row's text: written after the picture, from the picture's column, it would blank the row.
|
|
12
|
+
*/
|
|
13
|
+
export function overPictureRow(base, overlay, col, width, total, pictureRight) {
|
|
14
|
+
const at = base.lastIndexOf(PICTURE_AT);
|
|
15
|
+
const picture = at === -1 ? null : PICTURE.exec(base.slice(at + PICTURE_AT.length));
|
|
16
|
+
if (picture === null)
|
|
17
|
+
return undefined;
|
|
18
|
+
const after = base.slice(at + PICTURE_AT.length + picture[0].length);
|
|
19
|
+
const drawn = compositeTuiLine(`${base.slice(0, at)}${after}`, overlay, col, width, total);
|
|
20
|
+
return col >= pictureRight ? `${drawn}${PICTURE_AT}${picture[0]}` : drawn;
|
|
21
|
+
}
|
package/dist/plain/errors.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Ports of the exception classes
|
|
1
|
+
/** Ports of the exception classes of the Python reference implementation that this parser raises. */
|
|
2
2
|
export class PlainSyntaxError extends Error {
|
|
3
3
|
constructor(message) {
|
|
4
4
|
super(message);
|
package/dist/plain/files.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { createEnvironment } from "./liquid.js";
|
|
4
|
-
/** Ports of the
|
|
4
|
+
/** Ports of the Python reference implementation's file helpers this parser needs. */
|
|
5
5
|
export function openFrom(dirs, fileName) {
|
|
6
6
|
for (const dir of dirs) {
|
|
7
7
|
const fullPath = join(dir, fileName);
|
package/dist/plain/liquid.js
CHANGED
|
@@ -7,7 +7,7 @@ function currentScope() {
|
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
9
|
* Leading indentation of the line the tag sits on, and whether that line is a
|
|
10
|
-
* comment. Ported from
|
|
10
|
+
* comment. Ported from the reference implementation's include node, which walks
|
|
11
11
|
* backwards from the tag to the start of its line.
|
|
12
12
|
*/
|
|
13
13
|
function scanLine(source, start) {
|
|
@@ -70,7 +70,7 @@ export function createEnvironment(options) {
|
|
|
70
70
|
" 2. The directory specified by --template-dir (if provided)\n" +
|
|
71
71
|
" 3. The built-in 'standard_template_library' directory\n");
|
|
72
72
|
}
|
|
73
|
-
//
|
|
73
|
+
// The reference implementation's loader: rstrip, then re-indent every line to match the
|
|
74
74
|
// include site. The rstrip is what stops a block-level include from ending
|
|
75
75
|
// the surrounding list.
|
|
76
76
|
const indented = templateSource.replace(/\s+$/, "").split("\n").join("\n" + " ".repeat(whitespaces));
|
package/dist/question-dialog.js
CHANGED
|
@@ -58,7 +58,11 @@ export class QuestionDialog {
|
|
|
58
58
|
const choices = this.options.options ?? [];
|
|
59
59
|
const body = this.input !== undefined
|
|
60
60
|
? this.input.render(inner)
|
|
61
|
-
:
|
|
61
|
+
: // A long option wraps, its lines under its first, rather than cut off where the person cannot read it.
|
|
62
|
+
choices.flatMap((choice, index) => wrapTextWithAnsi(choice, Math.max(1, inner - 2)).map((line, at) => {
|
|
63
|
+
const shown = `${at === 0 && index === this.selected ? "›" : " "} ${line}`;
|
|
64
|
+
return index === this.selected ? style.accent(shown) : shown;
|
|
65
|
+
}));
|
|
62
66
|
const hint = this.input !== undefined
|
|
63
67
|
? "enter answers · esc dismisses"
|
|
64
68
|
: "↑↓ choose · enter picks · esc dismisses";
|
|
@@ -84,6 +84,25 @@ export function describeUsage(usage) {
|
|
|
84
84
|
return usage.cost > 0 ? `${size} tok · $${usage.cost.toFixed(2)}` : `${size} tok`;
|
|
85
85
|
}
|
|
86
86
|
const store = new AsyncLocalStorage();
|
|
87
|
+
const modelListeners = new Set();
|
|
88
|
+
/** Hear of every model call, turn and compaction from now on. Returns what stops it. */
|
|
89
|
+
export function onModelEvent(listener) {
|
|
90
|
+
modelListeners.add(listener);
|
|
91
|
+
return () => modelListeners.delete(listener);
|
|
92
|
+
}
|
|
93
|
+
export function reportModelEvent(event) {
|
|
94
|
+
if (modelListeners.size === 0)
|
|
95
|
+
return;
|
|
96
|
+
const job = store.getStore()?.job?.id;
|
|
97
|
+
for (const listener of modelListeners) {
|
|
98
|
+
try {
|
|
99
|
+
listener(job !== undefined ? { ...event, job } : event);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* a listener that fails does not take the agent down */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
87
106
|
export function withActivity(sink, run) {
|
|
88
107
|
return store.run(sink, run);
|
|
89
108
|
}
|
|
@@ -188,6 +188,7 @@ export function sandboxNote(fence) {
|
|
|
188
188
|
const report = fence.environmentTool !== undefined
|
|
189
189
|
? `report it as an environment error with ${fence.environmentTool}`
|
|
190
190
|
: "say so plainly - what is missing and what needs it - and end your turn";
|
|
191
|
+
lines.push("- A command refused a file outside your folder - `Operation not permitted`, `Permission denied`, a lock it could not", ` take - is these walls, not the code, and nothing you change gets past them: ${report} at once, naming the file`, " and the command that wanted it.");
|
|
191
192
|
lines.push(fence.write.length > 0
|
|
192
193
|
? "- The project's dependencies are yours: declare them in the build manifest, and let the build tools fetch them."
|
|
193
194
|
: "- You install nothing.", `${fence.write.length > 0 ? " Installing anything else is not" : " Nor is installing anything on the machine yours"}: no toolchains, system packages, drivers, global packages or \`sudo\`. If the`, ` machine lacks something the code needs, ${report}, name what is missing, and go no further with what depends on it.`, " The person decides.", "- Ports and processes: you may stop only what you started. If a port is taken by something else, use another port.", " `ps` may show nothing but what you started: check a server you started with `kill -0 $!`, its log, or a request to it.", "- Temporary files go in `$TMPDIR`: `/tmp` is outside your walls.");
|