@turri-dev/cli 0.1.16 → 0.1.17-next.63f454c
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/dist/ask.d.ts +2 -0
- package/dist/ask.js +2 -0
- package/dist/chat-commands.d.ts +25 -0
- package/dist/chat-commands.js +162 -0
- package/dist/chat-records.d.ts +44 -0
- package/dist/chat-records.js +102 -0
- package/dist/chat-scope.d.ts +14 -0
- package/dist/chat-scope.js +15 -0
- package/dist/chat-terminal.d.ts +23 -0
- package/dist/chat-terminal.js +163 -0
- package/dist/chat-ui.d.ts +33 -0
- package/dist/chat-ui.js +192 -0
- package/dist/chats.d.ts +192 -0
- package/dist/chats.js +378 -0
- package/dist/cli.js +5 -7
- package/dist/config.d.ts +8 -2
- package/dist/config.js +18 -4
- package/dist/extension.d.ts +20 -33
- package/dist/extension.js +427 -272
- package/dist/genome.d.ts +13 -7
- package/dist/genome.js +13 -3
- package/dist/host-session.d.ts +58 -0
- package/dist/host-session.js +72 -0
- package/dist/host.d.ts +5 -4
- package/dist/host.js +6 -5
- package/dist/identity.js +5 -0
- package/dist/pi-launch.js +23 -1
- package/dist/question-dialog.d.ts +4 -0
- package/dist/question-dialog.js +13 -2
- package/dist/regenerate/activity.d.ts +8 -1
- package/dist/regenerate/activity.js +23 -2
- package/dist/regenerate/jobs.d.ts +2 -0
- package/dist/regenerate/jobs.js +3 -0
- package/dist/regenerate/ledger.d.ts +7 -0
- package/dist/regenerate/ledger.js +8 -1
- package/dist/regenerate/test-scripts.d.ts +1 -1
- package/dist/stacks.d.ts +2 -1
- package/dist/stacks.js +4 -3
- package/docs/README.md +4 -2
- package/docs/chats.md +75 -0
- package/docs/config.md +3 -3
- package/docs/development.md +4 -1
- package/docs/hosting.md +37 -28
- package/docs/skills-and-gates.md +2 -2
- package/docs/using.md +2 -2
- package/package.json +1 -1
package/dist/extension.js
CHANGED
|
@@ -44,7 +44,7 @@ import { createJobsTreeState, selectJob } from "./regenerate/jobs-tree.js";
|
|
|
44
44
|
import { describeDiagnosis, diagnoseFailure } from "./regenerate/diagnose.js";
|
|
45
45
|
import { HISTORY_DIR, historyLog, historyShow } from "./regenerate/lineage.js";
|
|
46
46
|
import { defaultBuildDir } from "./regenerate/paths.js";
|
|
47
|
-
import { describeTurnIncident, describeUsage, onModelEvent } from "./regenerate/activity.js";
|
|
47
|
+
import { describeTurnIncident, describeUsage, JobStoppedError, onModelEvent } from "./regenerate/activity.js";
|
|
48
48
|
import { ledgerWanted, startLedger } from "./regenerate/ledger.js";
|
|
49
49
|
import { describePastPulse, describeRuns, earlierPulses, ledgerProjects } from "./regenerate/ledger-view.js";
|
|
50
50
|
import { doctorTool } from "./doctor-tool.js";
|
|
@@ -55,13 +55,15 @@ import { beginCapture, captureConsole, describeCaptured, dropNoise } from "./qui
|
|
|
55
55
|
import { loadRules, renderRulesBlock } from "./rules.js";
|
|
56
56
|
import { registerTurriConfig } from "./config-command.js";
|
|
57
57
|
import { describeStdlib } from "./stdlib.js";
|
|
58
|
-
import { peekKeyListener, runAsk } from "./ask.js";
|
|
58
|
+
import { DECLINED, peekKeyListener, runAsk } from "./ask.js";
|
|
59
59
|
import { QuestionDialog } from "./question-dialog.js";
|
|
60
60
|
import { registerStacks } from "./stacks.js";
|
|
61
61
|
import { catalogueLine, chosenCommand, createCommandGate, startPicked, discoverSkills, leftOutNote, startedByCommandOnly } from "./skills.js";
|
|
62
62
|
import { supersededByTurri } from "./chat-skills.js";
|
|
63
63
|
import { conversationIdentity } from "./identity.js";
|
|
64
64
|
import { setSurface, surfaceOf } from "./surface.js";
|
|
65
|
+
import { createChats, scopedToChat } from "./chats.js";
|
|
66
|
+
import { registerChats } from "./chat-commands.js";
|
|
65
67
|
import { turriIdentity } from "./environment.js";
|
|
66
68
|
import { conformanceChooser } from "./regenerate/choose-conformance.js";
|
|
67
69
|
import { answerSideQuestion, askWithAsides, BTW_HOW, runningSkill, skillRunStarted, skillSessionText, transcriptOf } from "./btw.js";
|
|
@@ -90,57 +92,81 @@ aside,
|
|
|
90
92
|
/** A host's own dialogs (TurriHost.dialogs), the question put in them instead of the terminal's. */
|
|
91
93
|
dialogs) {
|
|
92
94
|
return async (request) => {
|
|
93
|
-
if (dialogs
|
|
94
|
-
const asking = (asked) => runAsk(asked, dialogs);
|
|
95
|
-
const bare = { ...request, said: undefined };
|
|
96
|
-
const answer = aside !== undefined ? await askWithAsides(bare, asking, aside) : await asking(bare);
|
|
97
|
-
onExchange(request.question, answer, [request.context, request.said].map((part) => part?.trim() ?? "").filter((part) => part !== "").join("\n\n") || undefined);
|
|
98
|
-
return answer;
|
|
99
|
-
}
|
|
100
|
-
if (!ctx.hasUI) {
|
|
95
|
+
if (dialogs === undefined && !ctx.hasUI) {
|
|
101
96
|
return "No user is available to answer. Decide yourself, and say in your report what you assumed.";
|
|
102
97
|
}
|
|
103
|
-
|
|
98
|
+
// Declined - ctrl+c at it, or the turn that asked it stopped: the dialog open is closed, no other is opened, and it
|
|
99
|
+
// comes back as DECLINED.
|
|
100
|
+
const turn = ctx.signal;
|
|
101
|
+
const declining = { declined: turn?.aborted === true, close: undefined };
|
|
102
|
+
const decline = () => {
|
|
103
|
+
declining.declined = true;
|
|
104
|
+
declining.close?.();
|
|
105
|
+
};
|
|
106
|
+
turn?.addEventListener("abort", decline, { once: true });
|
|
104
107
|
// The question has the keys: ctrl+h is caught here, or it would never reach the shortcut, which only the chat's editor sees.
|
|
105
|
-
const unhook = hookPeekKey(ctx);
|
|
108
|
+
const unhook = dialogs === undefined ? hookPeekKey(ctx) : () => { };
|
|
109
|
+
if (dialogs === undefined)
|
|
110
|
+
ctx.ui.setWorkingMessage("waiting for your answer");
|
|
106
111
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// A dialog of turri's own: a long brief scrolls above the question, and the options always show (question-dialog.ts).
|
|
110
|
-
const dialog = (question, context, options, selected) => ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
111
|
-
const shown = new QuestionDialog({
|
|
112
|
-
question,
|
|
113
|
-
...(context !== undefined ? { context } : {}),
|
|
114
|
-
...(options !== undefined ? { options } : {}),
|
|
115
|
-
...(selected !== undefined ? { selected } : {}),
|
|
116
|
-
rows: () => paddedRows(tui.terminal.rows),
|
|
117
|
-
style: { accent: (text) => theme.fg("accent", text), muted: (text) => theme.fg("muted", text), bold: (text) => theme.bold(text) },
|
|
118
|
-
done: (answer) => {
|
|
119
|
-
if (questionShown === shown)
|
|
120
|
-
questionShown = undefined;
|
|
121
|
-
done(answer);
|
|
122
|
-
},
|
|
123
|
-
requestRender: () => tui.requestRender(),
|
|
124
|
-
});
|
|
125
|
-
questionShown = shown;
|
|
126
|
-
return shown;
|
|
127
|
-
});
|
|
128
|
-
const asking = (asked) => runAsk(asked, {
|
|
129
|
-
select: (title, options, context, at) => dialog(title, context, options, at),
|
|
130
|
-
input: (title, context) => dialog(title, context),
|
|
131
|
-
});
|
|
112
|
+
const io = dialogs !== undefined ? hostDialogs(dialogs, declining) : terminalDialogs(ctx, declining, decline);
|
|
113
|
+
const asking = (asked) => runAsk(asked, io);
|
|
132
114
|
const bare = { ...request, said: undefined };
|
|
133
|
-
const
|
|
115
|
+
const given = declining.declined ? DECLINED : aside !== undefined ? await askWithAsides(bare, asking, aside) : await asking(bare);
|
|
116
|
+
const answer = declining.declined ? DECLINED : given;
|
|
134
117
|
// Kept: what was shown with it, and what it said before it.
|
|
135
118
|
onExchange(request.question, answer, [request.context, request.said].map((part) => part?.trim() ?? "").filter((part) => part !== "").join("\n\n") || undefined);
|
|
136
119
|
return answer;
|
|
137
120
|
}
|
|
138
121
|
finally {
|
|
122
|
+
turn?.removeEventListener("abort", decline);
|
|
139
123
|
unhook();
|
|
140
|
-
|
|
124
|
+
if (dialogs === undefined)
|
|
125
|
+
ctx.ui.setWorkingMessage();
|
|
141
126
|
}
|
|
142
127
|
};
|
|
143
128
|
}
|
|
129
|
+
/** A host's dialogs, for a question that can be declined: its dialog stays the host's to close, the question over all the same. */
|
|
130
|
+
function hostDialogs(dialogs, declining) {
|
|
131
|
+
const until = (asked) => declining.declined ? Promise.resolve(undefined) : Promise.race([asked(), new Promise((resolve) => (declining.close = () => resolve(undefined)))]);
|
|
132
|
+
return {
|
|
133
|
+
select: (...args) => until(() => dialogs.select(...args)),
|
|
134
|
+
input: (...args) => until(() => dialogs.input(...args)),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The terminal's dialogs: turri's own, a long brief scrolling above the question and the options always showing
|
|
139
|
+
* (question-dialog.ts). ctrl+c declines the question - each one it is pressed at, however many in a row; what asked
|
|
140
|
+
* goes on. The turn, and a skill asking in it, is stopped by ctrl+c with no question open.
|
|
141
|
+
*/
|
|
142
|
+
function terminalDialogs(ctx, declining, decline) {
|
|
143
|
+
const dialog = (question, context, options, selected) => declining.declined
|
|
144
|
+
? Promise.resolve(undefined)
|
|
145
|
+
: ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
146
|
+
const shown = new QuestionDialog({
|
|
147
|
+
question,
|
|
148
|
+
...(context !== undefined ? { context } : {}),
|
|
149
|
+
...(options !== undefined ? { options } : {}),
|
|
150
|
+
...(selected !== undefined ? { selected } : {}),
|
|
151
|
+
rows: () => paddedRows(tui.terminal.rows),
|
|
152
|
+
style: { accent: (text) => theme.fg("accent", text), muted: (text) => theme.fg("muted", text), bold: (text) => theme.bold(text) },
|
|
153
|
+
done: (answer) => {
|
|
154
|
+
if (questionShown === shown)
|
|
155
|
+
questionShown = undefined;
|
|
156
|
+
done(answer);
|
|
157
|
+
},
|
|
158
|
+
requestRender: () => tui.requestRender(),
|
|
159
|
+
interrupt: decline,
|
|
160
|
+
});
|
|
161
|
+
declining.close = () => shown.dismiss();
|
|
162
|
+
questionShown = shown;
|
|
163
|
+
return shown;
|
|
164
|
+
});
|
|
165
|
+
return {
|
|
166
|
+
select: (title, options, context, at) => dialog(title, context, options, at),
|
|
167
|
+
input: (title, context) => dialog(title, context),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
144
170
|
/** The side panel, as a question open in the chat reaches it: set once the extension has its panel. */
|
|
145
171
|
let questionPeek;
|
|
146
172
|
/**
|
|
@@ -201,33 +227,25 @@ function conversationSystemPrompt(base, host, blocks) {
|
|
|
201
227
|
const prompt = [base, conversationIdentity(host.surface), ...blocks].filter((part) => part !== "").join("\n\n");
|
|
202
228
|
return host.systemPrompt !== undefined ? host.systemPrompt(prompt) : prompt;
|
|
203
229
|
}
|
|
204
|
-
/**
|
|
230
|
+
/** Whether the person has been told which of pi's skills turri left out: once a session, not once a chat. */
|
|
231
|
+
let leftOutSaid = false;
|
|
232
|
+
/** The host each session's jobs report to. */
|
|
205
233
|
const hosts = new WeakMap();
|
|
206
|
-
/**
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
function
|
|
234
|
+
/**
|
|
235
|
+
* A turri session: its job registry and work loop, the ledger, and its chats, each with turri's extension in it
|
|
236
|
+
* (chats.ts). The terminal makes one as it starts (pi-launch.ts), a host with `createTurri` (host.ts); `persist`
|
|
237
|
+
* records the open chats for `turri --continue`.
|
|
238
|
+
*/
|
|
239
|
+
export function createTurriCore(host = {}, options = {}) {
|
|
212
240
|
dropNoise();
|
|
213
241
|
const cwd = process.cwd();
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
const panel = createJobPanel(() => shownJobs(jobs));
|
|
218
|
-
// Finished work is announced twice, to two audiences, because pi treats them differently.
|
|
219
|
-
// An entry is drawn the moment it is appended: the person sees the job end, now. The agent
|
|
220
|
-
// gets the full report as a hidden message that starts a turn of its own - at once when it is
|
|
221
|
-
// idle, else as soon as the turn it is in ends (a follow-up, never cutting into a reply or an
|
|
222
|
-
// interview) - so it reacts to every job that ends without waiting for the person to speak:
|
|
223
|
-
// does what the project's guidance says to do then (release what was built, say), or says
|
|
224
|
-
// what finished.
|
|
225
|
-
// What release has shipped, module by module: release does nothing for a module whose last commit it already shipped.
|
|
226
|
-
const deployState = createDeployState();
|
|
242
|
+
let core;
|
|
243
|
+
// Finished work is announced twice, to two audiences, because pi treats them differently - see ChatInstance.announce:
|
|
244
|
+
// in the chat that asked for it, or the one shown when that is closed.
|
|
227
245
|
const jobs = createJobRegistry({
|
|
228
246
|
// The loop's plan changed - a module being planned, or planned: the live panel shows it at once.
|
|
229
247
|
onNotify: () => {
|
|
230
|
-
|
|
248
|
+
core?.jobsChanged();
|
|
231
249
|
host.onPlanChange?.();
|
|
232
250
|
},
|
|
233
251
|
// A job that fails is run once more; failing again, it and every job behind it fail, and the
|
|
@@ -249,41 +267,145 @@ function turriExtension(pi, host) {
|
|
|
249
267
|
return diagnosis !== undefined ? describeDiagnosis(diagnosis) : undefined;
|
|
250
268
|
},
|
|
251
269
|
onChange: (job) => {
|
|
252
|
-
|
|
270
|
+
core?.jobsChanged();
|
|
253
271
|
host.onJobChange?.(job);
|
|
254
272
|
},
|
|
255
273
|
onFinish: (job) => {
|
|
256
|
-
|
|
274
|
+
core?.jobsChanged();
|
|
257
275
|
host.onJobChange?.(job);
|
|
258
276
|
// Queued again as another job: that one says how it went.
|
|
259
277
|
if (job.replacedBy !== undefined)
|
|
260
278
|
return;
|
|
261
|
-
announceJob(pi, job);
|
|
262
279
|
// Gone wrong: the next ctrl+h opens on it - the live panel that showed it is gone with the last pulse.
|
|
263
280
|
if (job.status === "failed" || job.status === "stopped" || didNotPass(job))
|
|
264
281
|
needsLooking.set(jobs, job.id);
|
|
265
|
-
|
|
266
|
-
if (job.specConflict !== undefined) {
|
|
267
|
-
void pi.sendMessage({ customType: "turri_spec_conflict", content: specConflictTask(job.specConflict), display: false, details: { jobId: job.id } }, { triggerTurn: true });
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
void pi.sendMessage({
|
|
271
|
-
customType: "turri_job",
|
|
272
|
-
content: (job.status === "succeeded"
|
|
273
|
-
? `Pulse ${job.id} (${job.kind}) finished after ${formatDuration(job)}.\n\n${job.report ?? ""}`
|
|
274
|
-
: job.status === "stopped"
|
|
275
|
-
? `Pulse ${job.id} (${job.kind}) was stopped after ${formatDuration(job)}. ${job.error ?? STOPPED_ON_REQUEST}${job.error === undefined || job.error === STOPPED_ON_REQUEST ? " The person stopped it; what it was building counts as not built." : ""}`
|
|
276
|
-
: `Pulse ${job.id} (${job.kind}) failed after ${formatDuration(job)}.\n\n${job.error ?? ""}`) +
|
|
277
|
-
`\n\n${JOB_FINISHED_REACTION}`,
|
|
278
|
-
display: false,
|
|
279
|
-
details: { jobId: job.id, status: job.status },
|
|
280
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
282
|
+
core?.jobFinished(job);
|
|
281
283
|
},
|
|
282
284
|
});
|
|
285
|
+
core = createChats({
|
|
286
|
+
cwd,
|
|
287
|
+
host,
|
|
288
|
+
jobs,
|
|
289
|
+
extension: (pi, link) => turriExtension(pi, core, link),
|
|
290
|
+
...(options.persist !== undefined ? { persist: options.persist } : {}),
|
|
291
|
+
...(options.dialogs !== undefined ? { dialogs: options.dialogs } : {}),
|
|
292
|
+
});
|
|
293
|
+
const session = core;
|
|
283
294
|
hosts.set(jobs, host);
|
|
284
295
|
// What the model is told of where the person watches the work, deep in the tools too: the host's surface, or the terminal.
|
|
285
296
|
setSurface(jobs, host.surface);
|
|
286
|
-
|
|
297
|
+
// What release has shipped, module by module: release does nothing for a module whose last commit it already shipped.
|
|
298
|
+
deployStates.set(jobs, createDeployState());
|
|
299
|
+
// What this session does, as facts, in the project's .turri/ledger - a pulse's own in its project. Said once, in the
|
|
300
|
+
// chat shown, when it cannot be written.
|
|
301
|
+
if (ledgerWanted()) {
|
|
302
|
+
startLedger({
|
|
303
|
+
mode: Object.keys(host).length > 0 ? "host" : "chat",
|
|
304
|
+
project: cwd,
|
|
305
|
+
warn: (message) => session.notify(message, "warning"),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
// What a turri that was killed left running - its commands' process groups - stopped as this one starts.
|
|
309
|
+
const orphans = sweepOrphanedGroups();
|
|
310
|
+
if (orphans > 0)
|
|
311
|
+
session.notify(`Stopped ${orphans} process group${orphans === 1 ? "" : "s"} a turri that was killed left running.`, "info");
|
|
312
|
+
// A pulse's provider failing and waited out, or a turn turri ended itself: said in the chat shown as it happens - the
|
|
313
|
+
// job panel's activity holds it too, but scrolls by.
|
|
314
|
+
onModelEvent((event) => {
|
|
315
|
+
const line = describeTurnIncident(event);
|
|
316
|
+
if (line !== undefined)
|
|
317
|
+
session.notify(event.job !== undefined ? `${event.job}: ${line}` : line, "warning");
|
|
318
|
+
});
|
|
319
|
+
// Sub-agents think as hard as the chat that started them, not pi's default - and reach the providers its extensions
|
|
320
|
+
// registered - a company's own gateway - which they load none of.
|
|
321
|
+
inheritThinkingLevel(() => session.instanceInScope()?.thinkingLevel());
|
|
322
|
+
inheritProviders(() => session.instanceInScope()?.context()?.modelRegistry);
|
|
323
|
+
return core;
|
|
324
|
+
}
|
|
325
|
+
/** The session pi builds turri's extension into when it is given turri's file rather than a session's factory. */
|
|
326
|
+
const SESSION = Symbol.for("turri.session");
|
|
327
|
+
/** The session of this process: pi-launch.ts's, or one made as pi first builds the extension (`pi -e src/extension.ts`). */
|
|
328
|
+
export function processSession(make = () => createTurriCore()) {
|
|
329
|
+
const global = globalThis;
|
|
330
|
+
return (global[SESSION] ??= make());
|
|
331
|
+
}
|
|
332
|
+
const extension = (pi) => processSession().extension(pi);
|
|
333
|
+
export default extension;
|
|
334
|
+
/**
|
|
335
|
+
* The session's work loop, for a host to read: made with the first chat's context - the models it plans with - when no
|
|
336
|
+
* chat has asked for work yet.
|
|
337
|
+
*/
|
|
338
|
+
export function sessionLoop(session) {
|
|
339
|
+
const known = workLoops.get(session.jobs)?.loop;
|
|
340
|
+
if (known !== undefined)
|
|
341
|
+
return known;
|
|
342
|
+
const live = session.shownChat()?.session ?? session.chats().map((info) => session.session(info.id)).find((found) => found !== undefined);
|
|
343
|
+
if (live === undefined)
|
|
344
|
+
throw new Error("No chat is open yet: the work loop plans with a chat's models.");
|
|
345
|
+
return workLoopFor(session.jobs, session.cwd, live.extensionRunner.createContext());
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* turri's extension, built into one chat of the session: its tools, commands and handlers run in the chat's scope -
|
|
349
|
+
* its stack, its id on the pulses it asks for - on the session's job registry and work loop.
|
|
350
|
+
*/
|
|
351
|
+
function turriExtension(api, session, link) {
|
|
352
|
+
const pi = scopedToChat(api, link.scope);
|
|
353
|
+
const { cwd, host, jobs } = session;
|
|
354
|
+
const dialogs = link.dialogs ?? host.dialogs;
|
|
355
|
+
// turri's own skills, and the project's - .turri/skills, .agents/skills (skills.ts).
|
|
356
|
+
const discoveredSkills = discoverSkills(PACKAGE_ROOT, cwd);
|
|
357
|
+
// What the background work is doing right now, drawn above the editor while it runs.
|
|
358
|
+
const panel = createJobPanel(() => shownJobs(jobs));
|
|
359
|
+
// The chat's context, as its tools and handlers were last given it: for its model and its providers.
|
|
360
|
+
let lastContext;
|
|
361
|
+
const contextNow = () => {
|
|
362
|
+
try {
|
|
363
|
+
void lastContext?.model;
|
|
364
|
+
return lastContext;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// Rebuilt by pi since - a /reload, a /fork: its new extension has the context now.
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
// Finished work is announced twice, to two audiences, because pi treats them differently.
|
|
372
|
+
// An entry is drawn the moment it is appended: the person sees the job end, now. The agent
|
|
373
|
+
// gets the full report as a hidden message that starts a turn of its own - at once when it is
|
|
374
|
+
// idle, else as soon as the turn it is in ends (a follow-up, never cutting into a reply or an
|
|
375
|
+
// interview) - so it reacts to every job that ends without waiting for the person to speak:
|
|
376
|
+
// does what the project's guidance says to do then (release what was built, say), or says
|
|
377
|
+
// what finished. In the chat that asked for the pulse, shown or not.
|
|
378
|
+
const announce = (job) => {
|
|
379
|
+
announceJob(pi, job);
|
|
380
|
+
// A spec that contradicts itself is the person's to settle, now: the agent is started on it.
|
|
381
|
+
if (job.specConflict !== undefined) {
|
|
382
|
+
void pi.sendMessage({ customType: "turri_spec_conflict", content: specConflictTask(job.specConflict), display: false, details: { jobId: job.id } }, { triggerTurn: true });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
void pi.sendMessage({
|
|
386
|
+
customType: "turri_job",
|
|
387
|
+
content: (job.status === "succeeded"
|
|
388
|
+
? `Pulse ${job.id} (${job.kind}) finished after ${formatDuration(job)}.\n\n${job.report ?? ""}`
|
|
389
|
+
: job.status === "stopped"
|
|
390
|
+
? `Pulse ${job.id} (${job.kind}) was stopped after ${formatDuration(job)}. ${job.error ?? STOPPED_ON_REQUEST}${job.error === undefined || job.error === STOPPED_ON_REQUEST ? " The person stopped it; what it was building counts as not built." : ""}`
|
|
391
|
+
: `Pulse ${job.id} (${job.kind}) failed after ${formatDuration(job)}.\n\n${job.error ?? ""}`) +
|
|
392
|
+
`\n\n${JOB_FINISHED_REACTION}`,
|
|
393
|
+
display: false,
|
|
394
|
+
details: { jobId: job.id, status: job.status },
|
|
395
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
396
|
+
};
|
|
397
|
+
link.live({
|
|
398
|
+
announce,
|
|
399
|
+
jobsChanged: () => panel.refresh(),
|
|
400
|
+
thinkingLevel: () => pi.getThinkingLevel(),
|
|
401
|
+
context: contextNow,
|
|
402
|
+
});
|
|
403
|
+
pi.on("session_start", (_event, ctx) => {
|
|
404
|
+
lastContext = ctx;
|
|
405
|
+
});
|
|
406
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
407
|
+
lastContext = ctx;
|
|
408
|
+
});
|
|
287
409
|
// The ***plain rules go into the system prompt in full, every turn. The block is
|
|
288
410
|
// identical each time, so it sits inside the cached prefix rather than invalidating it.
|
|
289
411
|
// With them, the instruction to do turri's work with its tools rather than its command
|
|
@@ -307,7 +429,6 @@ function turriExtension(pi, host) {
|
|
|
307
429
|
const machine = machineNeeds(cwd);
|
|
308
430
|
pi.on("session_start", () => machine.refreshSoon());
|
|
309
431
|
pi.on("agent_settled", () => machine.refreshSoon());
|
|
310
|
-
let leftOutSaid = false;
|
|
311
432
|
pi.on("before_agent_start", (event, ctx) => {
|
|
312
433
|
const offered = event.systemPromptOptions.skills;
|
|
313
434
|
const leftOut = offered.filter((skill) => supersededByTurri(skill, turriRuns));
|
|
@@ -378,66 +499,28 @@ function turriExtension(pi, host) {
|
|
|
378
499
|
};
|
|
379
500
|
pi.on("agent_end", endTurnCapture);
|
|
380
501
|
pi.on("agent_settled", endTurnCapture);
|
|
381
|
-
// A pulse's provider failing and waited out, or a turn turri ended itself: said in the chat as it happens - the job
|
|
382
|
-
// panel's activity holds it too, but scrolls by.
|
|
383
|
-
let stopTurnIncidents;
|
|
384
|
-
pi.on("session_start", (_event, ctx) => {
|
|
385
|
-
stopTurnIncidents?.();
|
|
386
|
-
if (!ctx.hasUI)
|
|
387
|
-
return;
|
|
388
|
-
stopTurnIncidents = onModelEvent((event) => {
|
|
389
|
-
const line = describeTurnIncident(event);
|
|
390
|
-
if (line !== undefined)
|
|
391
|
-
ctx.ui.notify(event.job !== undefined ? `${event.job}: ${line}` : line, "warning");
|
|
392
|
-
});
|
|
393
|
-
});
|
|
394
|
-
// What this session does, as facts, in the project's .turri/ledger - a pulse's own in its project. Said once, in the
|
|
395
|
-
// chat, when it cannot be written.
|
|
396
|
-
const ledgerTrouble = [];
|
|
397
|
-
let tellLedgerTrouble;
|
|
398
|
-
if (ledgerWanted()) {
|
|
399
|
-
startLedger({
|
|
400
|
-
mode: Object.keys(host).length > 0 ? "host" : "chat",
|
|
401
|
-
project: cwd,
|
|
402
|
-
warn: (message) => (tellLedgerTrouble !== undefined ? tellLedgerTrouble(message) : ledgerTrouble.push(message)),
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
pi.on("session_start", (_event, ctx) => {
|
|
406
|
-
if (!ctx.hasUI)
|
|
407
|
-
return;
|
|
408
|
-
tellLedgerTrouble = (message) => ctx.ui.notify(message, "warning");
|
|
409
|
-
for (const message of ledgerTrouble.splice(0))
|
|
410
|
-
tellLedgerTrouble(message);
|
|
411
|
-
});
|
|
412
|
-
// What a turri that was killed left running - its commands' process groups - stopped as this one starts.
|
|
413
|
-
const orphans = sweepOrphanedGroups();
|
|
414
|
-
let orphansSaid = orphans === 0;
|
|
415
|
-
pi.on("session_start", (_event, ctx) => {
|
|
416
|
-
if (orphansSaid || !ctx.hasUI)
|
|
417
|
-
return;
|
|
418
|
-
orphansSaid = true;
|
|
419
|
-
ctx.ui.notify(`Stopped ${orphans} process group${orphans === 1 ? "" : "s"} a turri that was killed left running.`, "info");
|
|
420
|
-
});
|
|
421
502
|
pi.on("session_start", (_event, ctx) => {
|
|
422
503
|
if (!ctx.hasUI)
|
|
423
504
|
return;
|
|
424
|
-
const ui = ctx.ui;
|
|
425
505
|
panel.attach({
|
|
426
|
-
setWidget: (key, content, options) =>
|
|
427
|
-
|
|
506
|
+
setWidget: (key, content, options) => {
|
|
507
|
+
try {
|
|
508
|
+
ctx.ui.setWidget(key, content, options);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
/* rebuilt by pi since: its new extension draws the panel */
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
getToolsExpanded: () => {
|
|
515
|
+
try {
|
|
516
|
+
return ctx.ui.getToolsExpanded();
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
},
|
|
428
522
|
});
|
|
429
523
|
});
|
|
430
|
-
// Sub-agents think as hard as the conversation that started them, not pi's default.
|
|
431
|
-
inheritThinkingLevel(() => pi.getThinkingLevel());
|
|
432
|
-
// And reach the providers its extensions registered - a company's own gateway - which they load none of.
|
|
433
|
-
let conversationModels;
|
|
434
|
-
pi.on("session_start", (_event, ctx) => {
|
|
435
|
-
conversationModels = ctx.modelRegistry;
|
|
436
|
-
});
|
|
437
|
-
pi.on("before_agent_start", (_event, ctx) => {
|
|
438
|
-
conversationModels = ctx.modelRegistry;
|
|
439
|
-
});
|
|
440
|
-
inheritProviders(() => conversationModels);
|
|
441
524
|
registerBranding(pi, discoveredSkills);
|
|
442
525
|
registerInterviewLog(pi);
|
|
443
526
|
registerJobTools(pi, jobs, cwd);
|
|
@@ -445,7 +528,7 @@ function turriExtension(pi, host) {
|
|
|
445
528
|
// once its command is typed, or picked among the options the conversation offers with ask_user.
|
|
446
529
|
const gate = createCommandGate();
|
|
447
530
|
// A skill picked is started at once, with what the person asked for: they never type its command after picking it.
|
|
448
|
-
registerCheckPlain(pi, cwd,
|
|
531
|
+
registerCheckPlain(pi, cwd, dialogs, (answer) => {
|
|
449
532
|
const chosen = chosenCommand(answer, discoveredSkills.skills);
|
|
450
533
|
if (chosen === undefined)
|
|
451
534
|
return undefined;
|
|
@@ -457,11 +540,13 @@ function turriExtension(pi, host) {
|
|
|
457
540
|
registerGlimpse(pi, cwd, jobs);
|
|
458
541
|
registerBind(pi, cwd, jobs);
|
|
459
542
|
registerRenderModels(pi);
|
|
460
|
-
deployStates.
|
|
461
|
-
registerDeploy(pi, cwd, jobs, deployState);
|
|
543
|
+
registerDeploy(pi, cwd, jobs, deployStates.get(jobs) ?? createDeployState());
|
|
462
544
|
registerSteering(pi, cwd);
|
|
463
545
|
registerSideQuestions(pi, cwd, jobs);
|
|
464
546
|
registerStacks(pi, cwd);
|
|
547
|
+
// More chats beside this one, in the terminal: /chat, alt+n, alt+1…9 (chat-commands.ts).
|
|
548
|
+
if (session.terminal)
|
|
549
|
+
registerChats(pi, session);
|
|
465
550
|
const { skills } = discoveredSkills;
|
|
466
551
|
if (skills.length === 0)
|
|
467
552
|
return;
|
|
@@ -513,7 +598,7 @@ function turriExtension(pi, host) {
|
|
|
513
598
|
description: "Start a new run even though an interrupted one exists here, discarding it.",
|
|
514
599
|
})),
|
|
515
600
|
}),
|
|
516
|
-
async execute(_id, params,
|
|
601
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
517
602
|
const skill = skills.find((s) => s.name === params.skill);
|
|
518
603
|
if (!skill) {
|
|
519
604
|
return {
|
|
@@ -629,8 +714,10 @@ function turriExtension(pi, host) {
|
|
|
629
714
|
const ended = skillRunStarted({ skill: skill.name, dir: skill.dir, step: () => live.step, trace: () => [...trace], runDir });
|
|
630
715
|
// From here the time to first regeneration is counted, for a skill that writes spec diffs: its first pulse started.
|
|
631
716
|
const began = Date.now();
|
|
632
|
-
const
|
|
717
|
+
const ran = await captureConsole(() => runSkill({
|
|
633
718
|
skillDir: skill.dir,
|
|
719
|
+
// The chat's turn stopped - ctrl+c, esc - stops the run with it.
|
|
720
|
+
signal,
|
|
634
721
|
cwd,
|
|
635
722
|
runDir,
|
|
636
723
|
resume: params.resume === true && interrupted !== undefined,
|
|
@@ -643,7 +730,7 @@ function turriExtension(pi, host) {
|
|
|
643
730
|
push(` Q: ${question}`);
|
|
644
731
|
push(` A: ${answer}`);
|
|
645
732
|
recordExchange(pi, { skill: skill.name, question, answer, ...(said !== undefined && said.trim() !== "" ? { said: said.trim() } : {}) });
|
|
646
|
-
}, (question) => askOnTheSide(pi, ctx, cwd, jobs, question),
|
|
733
|
+
}, (question) => askOnTheSide(pi, ctx, cwd, jobs, question), dialogs),
|
|
647
734
|
model: conversationModel(ctx),
|
|
648
735
|
// Renders queued by a step land in the same registry as everything else, so
|
|
649
736
|
// pulses sees them without knowing a skill started them.
|
|
@@ -661,21 +748,37 @@ function turriExtension(pi, host) {
|
|
|
661
748
|
haltTool({ jobs, workLoop: () => workLoops.get(jobs)?.loop }),
|
|
662
749
|
conformTool({ cwd, loop: () => workLoopFor(jobs, cwd, ctx), model: (_skill, plainFile, named) => jobModel(ctx, "spec_changes", named, plainFile), wait: () => false }),
|
|
663
750
|
],
|
|
664
|
-
// The escalation becomes a real dialog
|
|
665
|
-
//
|
|
751
|
+
// The escalation becomes a real dialog - the host's own, when it has them. Without one there is nobody to ask,
|
|
752
|
+
// so the gate stands.
|
|
666
753
|
escalate: async ({ question, critique }) => {
|
|
754
|
+
if (dialogs !== undefined) {
|
|
755
|
+
const through = "Let it through";
|
|
756
|
+
const answer = await dialogs.select(`Review gate: ${skill.name} - ${question}`, [through, "Keep the gate"], critique);
|
|
757
|
+
return answer === through;
|
|
758
|
+
}
|
|
667
759
|
if (!ctx.hasUI) {
|
|
668
760
|
push(" no UI available for human review - gate stands");
|
|
669
761
|
return false;
|
|
670
762
|
}
|
|
671
763
|
return ctx.ui.confirm(`Review gate: ${skill.name}`, `${critique}\n\n${question}`);
|
|
672
764
|
},
|
|
673
|
-
}))
|
|
765
|
+
}))
|
|
766
|
+
.catch((error) => {
|
|
767
|
+
if (!(error instanceof JobStoppedError))
|
|
768
|
+
throw error;
|
|
769
|
+
return undefined;
|
|
770
|
+
})
|
|
771
|
+
.finally(() => {
|
|
674
772
|
ended();
|
|
675
773
|
clearInterval(ticker);
|
|
676
774
|
if (ctx.hasUI)
|
|
677
775
|
ctx.ui.setWidget("turri-skill", undefined);
|
|
678
776
|
});
|
|
777
|
+
if (ran === undefined) {
|
|
778
|
+
const text = [`${skill.name}: stopped by the person, in its ${live.step || "first"} step. It can be continued with resume: true.`, "", "<trace>", ...trace, "</trace>"].join("\n");
|
|
779
|
+
return { content: [{ type: "text", text }], details: { stopped: true } };
|
|
780
|
+
}
|
|
781
|
+
const { result, output: captured } = ran;
|
|
679
782
|
const noise = describeCaptured(captured);
|
|
680
783
|
if (noise !== null)
|
|
681
784
|
push(noise);
|
|
@@ -793,22 +896,29 @@ function registerRegenerate(pi, cwd, jobs) {
|
|
|
793
896
|
? `Planning ${module}'s functionalit${again.length === 1 ? "y" : "ies"} ${again.join(", ")} again…`
|
|
794
897
|
: `Planning ${module}'s pulses…`, "info");
|
|
795
898
|
await nextFrame();
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
899
|
+
// Planned in the background: pi reads what is typed next only once a command returns, and planning takes the
|
|
900
|
+
// planner's time - the chat, and switching to another, stay free meanwhile. The plan is said once it is made.
|
|
901
|
+
const target = plainFile;
|
|
902
|
+
void (async () => {
|
|
903
|
+
try {
|
|
904
|
+
// Built with the stack chosen now - pinned to its pulses - or refused, a project of stacks built with none.
|
|
905
|
+
const statuses = await withCallStack(target, undefined, () => scratch
|
|
906
|
+
? loop.fromScratch(target, { ...request, chain })
|
|
907
|
+
: again.length > 0
|
|
908
|
+
? loop.regenerateAgain(target, again, request)
|
|
909
|
+
: loop.bringUp(target, request));
|
|
910
|
+
// Nothing to do: say how to build it again anyway.
|
|
911
|
+
const hint = statuses.length > 0 && statuses.every(isUpToDate)
|
|
912
|
+
? `\nTo regenerate parts of it anyway, name their functionalities - /regenerate ${basename(target)} 2.1 - or start over with /regenerate ${basename(target)} from scratch.`
|
|
913
|
+
: "";
|
|
914
|
+
say(describeModules(statuses) + hint, "info");
|
|
915
|
+
}
|
|
916
|
+
catch (error) {
|
|
917
|
+
say(error instanceof Error ? error.message : String(error), "error");
|
|
918
|
+
}
|
|
919
|
+
})().catch(() => {
|
|
920
|
+
// The chat let go while it planned - closed, or rebuilt: there is nobody to say it to.
|
|
921
|
+
});
|
|
812
922
|
},
|
|
813
923
|
});
|
|
814
924
|
}
|
|
@@ -1141,7 +1251,7 @@ export function projectConfigBlock(root) {
|
|
|
1141
1251
|
return [
|
|
1142
1252
|
...settings(readProjectConfig(dir), withStack(undefined, () => findTestScripts(dir)), dir === root ? `${projectConfigFile(dir)}, for the specs here:` : `${relative(root, dir)}/${projectConfigFile(dir)}, for the specs in ${relative(root, dir)}/ - built in ${relative(root, dir)}/plain_modules:`, where, stacks.length > 0 ? "project of stacks" : "project"),
|
|
1143
1253
|
...(stacks.length > 0
|
|
1144
|
-
? ["", `Stacks: ${stacks.join(", ")} - chosen now: ${chosen ?? "none, so the project's file alone"}. Each module is built with the stack of its part, pinned to its pulses whatever is chosen after: a stack named after the module is its own - backend.plain is built with config.turri.backend.yaml - with nothing to choose. For a module whose stack has another name, pass it as stack to regenerate, conform, glimpse and change_plain. Choose one for
|
|
1254
|
+
? ["", `Stacks: ${stacks.join(", ")} - chosen now: ${chosen ?? "none, so the project's file alone"}. Each module is built with the stack of its part, pinned to its pulses whatever is chosen after: a stack named after the module is its own - backend.plain is built with config.turri.backend.yaml - with nothing to choose. For a module whose stack has another name, pass it as stack to regenerate, conform, glimpse and change_plain. Choose one for this chat with the stack tool only when the user asks.`]
|
|
1145
1255
|
: []),
|
|
1146
1256
|
// A stack: its file over the project's, for everything built while it is chosen.
|
|
1147
1257
|
...stacks.flatMap((stack) => [
|
|
@@ -1203,7 +1313,7 @@ export function projectConfigBlock(root) {
|
|
|
1203
1313
|
`<project config>`,
|
|
1204
1314
|
`A project's ${CONFIG_FILE} sets how its specs are built - and a stack's config.turri.<stack>.yaml, over it, how everything is built`,
|
|
1205
1315
|
"while that stack is chosen: its testing scripts, toolchains, models and conformance - a backend and a frontend each with their own scripts.",
|
|
1206
|
-
"One stack is chosen at a time, for
|
|
1316
|
+
"One stack is chosen at a time, in each chat for that chat: the render tools follow the project's file with the chosen stack's over it;",
|
|
1207
1317
|
`a project with no ${CONFIG_FILE} yet is read from its ${LEGACY_CONFIG_FILE} (and config.<stack>.yaml): to change a setting there, copy`,
|
|
1208
1318
|
`${LEGACY_CONFIG_FILE} to ${CONFIG_FILE} - and each config.<stack>.yaml to config.turri.<stack>.yaml - first, then edit the copy; never edit ${LEGACY_CONFIG_FILE}.`,
|
|
1209
1319
|
"answer questions about the project's settings from it, and say when a render will differ from it because the user",
|
|
@@ -1239,8 +1349,10 @@ function chatModelOf(ctx) {
|
|
|
1239
1349
|
}
|
|
1240
1350
|
}
|
|
1241
1351
|
let lastChatModel;
|
|
1242
|
-
/** The models chosen in this session, by kind of work. Saved in the
|
|
1352
|
+
/** The models chosen in this session, by kind of work - for every chat. Saved in the chat they were chosen in, so reopening it keeps them. */
|
|
1243
1353
|
const chosenModels = {};
|
|
1354
|
+
/** When they were last chosen: a chat reopened with older choices of its own leaves them be. */
|
|
1355
|
+
let chosenModelsAt = 0;
|
|
1244
1356
|
const MODEL_CHOICE_ENTRY = "turri_render_models";
|
|
1245
1357
|
/**
|
|
1246
1358
|
* The model a job's agents run on: the one named on the call, checked now so a wrong name fails
|
|
@@ -1355,6 +1467,7 @@ function setRenderModelTool(pi, conversation) {
|
|
|
1355
1467
|
}
|
|
1356
1468
|
chosenModels[params.work] = resolved.model;
|
|
1357
1469
|
}
|
|
1470
|
+
chosenModelsAt = Date.now();
|
|
1358
1471
|
pi.appendEntry(MODEL_CHOICE_ENTRY, { ...chosenModels });
|
|
1359
1472
|
const text = `The engine now:\n${describeChoices(chosenModels, chatModelOf(ctx), projectModelLabel(ctx))}`;
|
|
1360
1473
|
return { content: [{ type: "text", text }], details: { ...chosenModels } };
|
|
@@ -1363,14 +1476,24 @@ function setRenderModelTool(pi, conversation) {
|
|
|
1363
1476
|
}
|
|
1364
1477
|
/** Choosing which model renders what, for the rest of the session. */
|
|
1365
1478
|
function registerRenderModels(pi) {
|
|
1479
|
+
// The session's choices, every chat's: a chat reopened brings back what it chose, when that is newer than what the
|
|
1480
|
+
// session has - and a fresh chat starts with the session's.
|
|
1481
|
+
let started = false;
|
|
1366
1482
|
pi.on("session_start", (_event, ctx) => {
|
|
1367
|
-
|
|
1368
|
-
|
|
1483
|
+
if (started)
|
|
1484
|
+
return;
|
|
1485
|
+
started = true;
|
|
1369
1486
|
const saved = ctx.sessionManager
|
|
1370
1487
|
.getBranch()
|
|
1371
1488
|
.filter((entry) => entry.type === "custom" && entry.customType === MODEL_CHOICE_ENTRY)
|
|
1372
1489
|
.at(-1);
|
|
1373
|
-
|
|
1490
|
+
const at = Date.parse(saved?.timestamp ?? "") || 1;
|
|
1491
|
+
if (saved === undefined || at <= chosenModelsAt)
|
|
1492
|
+
return;
|
|
1493
|
+
chosenModelsAt = at;
|
|
1494
|
+
for (const key of Object.keys(chosenModels))
|
|
1495
|
+
delete chosenModels[key];
|
|
1496
|
+
Object.assign(chosenModels, saved.data ?? {});
|
|
1374
1497
|
});
|
|
1375
1498
|
pi.registerTool(setRenderModelTool(pi));
|
|
1376
1499
|
pi.registerCommand("engine", {
|
|
@@ -1426,7 +1549,6 @@ function workLoopFor(jobs, cwd, ctx) {
|
|
|
1426
1549
|
});
|
|
1427
1550
|
shared = { loop, from };
|
|
1428
1551
|
workLoops.set(jobs, shared);
|
|
1429
|
-
hosts.get(jobs)?.onWorkLoop?.(loop);
|
|
1430
1552
|
}
|
|
1431
1553
|
shared.from.ctx = ctx;
|
|
1432
1554
|
return shared.loop;
|
|
@@ -1646,6 +1768,133 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1646
1768
|
// screen - beside the chat, the key switches sides, giving the keys to the chat or the panel, as esc gives them to the
|
|
1647
1769
|
// chat, the panel staying; ctrl+c closes it.
|
|
1648
1770
|
// While the panel has the keys, every key is its own.
|
|
1771
|
+
// The side panel is the session's, one for every chat: it stays on the screen - beside the chat or over it - as
|
|
1772
|
+
// another chat is shown, and what it says to a chat it says to the one shown.
|
|
1773
|
+
const sidePanel = sidePanelOf(jobs, cwd);
|
|
1774
|
+
const openPanel = sidePanel.open;
|
|
1775
|
+
pi.on("session_start", () => {
|
|
1776
|
+
sidePanel.say = (text) => pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
1777
|
+
});
|
|
1778
|
+
// /pulses opens the side panel on every pulse, regenerating, planned and finished, and what each regenerates; regen -
|
|
1779
|
+
// the spec, with how far the code has been regenerated into it - is PANEL_KEY's, no command of its own beside
|
|
1780
|
+
// /regenerate. What the agents did is in each pulse's log.
|
|
1781
|
+
pi.registerCommand("pulses", {
|
|
1782
|
+
description: `The pulses, in the side panel: every pulse, regenerating, planned and finished, and what each regenerates. ${PANEL_KEY} opens the panel too.`,
|
|
1783
|
+
handler: async (_args, ctx) => {
|
|
1784
|
+
if (!ctx.hasUI)
|
|
1785
|
+
return;
|
|
1786
|
+
openPanel(ctx, "pulses");
|
|
1787
|
+
},
|
|
1788
|
+
});
|
|
1789
|
+
panelOpeners.set(jobs, (ctx) => openPanel(ctx));
|
|
1790
|
+
// A question open in the chat on the screen peeks at this chat's panel.
|
|
1791
|
+
const peek = {
|
|
1792
|
+
open: (ctx) => openPanel(ctx),
|
|
1793
|
+
shown: () => sidePanel.shown(),
|
|
1794
|
+
};
|
|
1795
|
+
questionPeek = peek;
|
|
1796
|
+
pi.on("session_start", () => {
|
|
1797
|
+
questionPeek = peek;
|
|
1798
|
+
});
|
|
1799
|
+
pi.registerShortcut(PANEL_KEY, {
|
|
1800
|
+
description: "Peek at regen and the pulses - a question waiting in the chat waits for you - or go back to it",
|
|
1801
|
+
handler: async (ctx) => {
|
|
1802
|
+
if (!ctx.hasUI)
|
|
1803
|
+
return;
|
|
1804
|
+
openPanel(ctx);
|
|
1805
|
+
},
|
|
1806
|
+
});
|
|
1807
|
+
// /turri-config: every setting of config.turri.yaml from the chat, each taking effect as it says - what turri fixed at its
|
|
1808
|
+
// start changed here, as it runs.
|
|
1809
|
+
registerTurriConfig(pi, {
|
|
1810
|
+
cwd,
|
|
1811
|
+
rules: () => loadRules(join(PACKAGE_ROOT, "genome", "rules")).map((rule) => rule.name),
|
|
1812
|
+
chatSpec: () => modelSpec(projectModels(readProjectConfig(cwd)).harness),
|
|
1813
|
+
live: {
|
|
1814
|
+
chatModel: async (spec, ctx) => {
|
|
1815
|
+
if (spec === undefined)
|
|
1816
|
+
return "The conversation keeps the model it runs on now; turri starts on pi's own choice next time.";
|
|
1817
|
+
const { model, thinking } = splitModelSpec(spec);
|
|
1818
|
+
if (model !== undefined) {
|
|
1819
|
+
const resolved = resolveModel(model, ctx.modelRegistry);
|
|
1820
|
+
if (!resolved.ok)
|
|
1821
|
+
return `The conversation keeps its model: ${model} cannot be used here.\n${describeResolution(resolved)}`;
|
|
1822
|
+
const slash = resolved.model.indexOf("/");
|
|
1823
|
+
const found = ctx.modelRegistry.find(resolved.model.slice(0, slash), resolved.model.slice(slash + 1));
|
|
1824
|
+
if (found === undefined || !(await pi.setModel(found)))
|
|
1825
|
+
return `The conversation keeps its model: ${resolved.model} could not be switched to.`;
|
|
1826
|
+
}
|
|
1827
|
+
if (thinking !== undefined)
|
|
1828
|
+
pi.setThinkingLevel(thinking);
|
|
1829
|
+
return `The conversation runs on ${spec} now.`;
|
|
1830
|
+
},
|
|
1831
|
+
codingModel: () => {
|
|
1832
|
+
const dropped = [];
|
|
1833
|
+
// What the session chose wins over the file; changed in the file, the file's holds from now on.
|
|
1834
|
+
if (process.env[CODING_MODEL_ENV] !== undefined) {
|
|
1835
|
+
dropped.push(`--coding-model ${process.env[CODING_MODEL_ENV]}`);
|
|
1836
|
+
delete process.env[CODING_MODEL_ENV];
|
|
1837
|
+
}
|
|
1838
|
+
chosenModelsAt = Date.now();
|
|
1839
|
+
for (const work of Object.keys(chosenModels)) {
|
|
1840
|
+
dropped.push(`the engine's ${work === "all" ? "" : `${work.replace(/_/g, " ")} `}model ${chosenModels[work]}`);
|
|
1841
|
+
delete chosenModels[work];
|
|
1842
|
+
}
|
|
1843
|
+
return dropped.length === 0 ? undefined : `Chosen this session, and given way to the config: ${dropped.join("; ")}.`;
|
|
1844
|
+
},
|
|
1845
|
+
parallelJobs: (count) => {
|
|
1846
|
+
const limit = count ?? parallelJobs(cwd);
|
|
1847
|
+
jobs.setMaxRunning(limit);
|
|
1848
|
+
return `Up to ${limit} pulse${limit === 1 ? "" : "s"} run at once now.`;
|
|
1849
|
+
},
|
|
1850
|
+
},
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
export { fromWorkingDir };
|
|
1854
|
+
const WATCH_REDRAW_MS = 500;
|
|
1855
|
+
/**
|
|
1856
|
+
* What the agent is to do with a job that just ended, unasked: what the project's guidance says to do then, or else say
|
|
1857
|
+
* in a line or two what ended - the person saw it end already, in the transcript.
|
|
1858
|
+
*/
|
|
1859
|
+
export const JOB_FINISHED_REACTION = [
|
|
1860
|
+
"React to this now - the person has not asked, and need not. If guidance for this project - its AGENTS.md, a skill,",
|
|
1861
|
+
"what the person said earlier - says what to do when a pulse finishes (release what was built, run something, tell",
|
|
1862
|
+
"someone), do that, as it says. Otherwise say in a line or two what finished and what it means for the person - the",
|
|
1863
|
+
"report is theirs to read in full; do not repeat it - and, when it failed, what they can do. Start no other work.",
|
|
1864
|
+
"A report that says \"Released after the pulse\" was released by turri itself, as the pulse's last stage: do not call",
|
|
1865
|
+
"release for it. Say in a line what was released, or why nothing was - then, when it was released, do what you were",
|
|
1866
|
+
"told to do once something is released (opening it where the person can watch it, saying what to test), if anything.",
|
|
1867
|
+
"A pulse that stopped on its environment - \"stopped: turri's sandbox kept its agents from…\" or \"stopped: the",
|
|
1868
|
+
"environment lacks…\" - stops the same way again until that is fixed: never regenerate it before. Heal it, then regenerate the",
|
|
1869
|
+
"module to resume:",
|
|
1870
|
+
"- turri's sandbox kept the agents from reading a folder on this machine: nothing is missing. Add the folder its report",
|
|
1871
|
+
" names to `toolchains:` in config.turri.yaml - a list, keeping what is there - say so in a line, and resume.",
|
|
1872
|
+
"- turri's sandbox kept the agents from writing a file - a lock a build tool takes, say: no setting lets them, and",
|
|
1873
|
+
" `toolchains:` does not - never add it there, and never resume before the fix. Do what the report says if it is the",
|
|
1874
|
+
" project's to change - a tool told to use `$HOME` in the build's own files - say so, and resume; otherwise tell the",
|
|
1875
|
+
" person, in a line or two, what the report says and that \"resume\" picks it up once it is done.",
|
|
1876
|
+
"- the machine lacks a toolchain or a tool - a JDK, Maven, Gradle, Go, uv, Node, a system library: first find out, in your",
|
|
1877
|
+
" shell, whether it is installed somewhere off the PATH (`command -v`, Homebrew's opt folders, /Library/Java/",
|
|
1878
|
+
" JavaVirtualMachines, a version manager the person uses - mise, asdf, sdkman, nvm). Then ask the person with ask_user",
|
|
1879
|
+
" to let you put it in place the way this machine installs things - the exact command, `brew install openjdk@21`,",
|
|
1880
|
+
" `mise use -g java@21`, `sdk install maven` - and on yes run it in your shell, check that the command is found now as",
|
|
1881
|
+
" the testing script would find it (`command -v`, then the doctor tool, which looks on turri's own PATH), and resume. A tool installed where the PATH turri",
|
|
1882
|
+
" was started with does not reach needs turri restarted: say so, with the line for the shell profile. On no, or when it",
|
|
1883
|
+
" needs what you cannot do - sudo with a password, a licence, a download behind a login - tell the person exactly what",
|
|
1884
|
+
" to do, and that \"resume\" picks it up once it is in.",
|
|
1885
|
+
"- a credential `agent-env:` names that is not there: never make one up - tell the person where it is read from.",
|
|
1886
|
+
].join("\n");
|
|
1887
|
+
const sidePanels = new WeakMap();
|
|
1888
|
+
/** The side panel of the session these jobs are, made as a chat first asks for it. */
|
|
1889
|
+
function sidePanelOf(jobs, cwd) {
|
|
1890
|
+
const known = sidePanels.get(jobs);
|
|
1891
|
+
if (known !== undefined)
|
|
1892
|
+
return known;
|
|
1893
|
+
const self = {
|
|
1894
|
+
open: (ctx, view) => openPanel(ctx, view),
|
|
1895
|
+
shown: () => panel !== undefined && panel.keys,
|
|
1896
|
+
};
|
|
1897
|
+
sidePanels.set(jobs, self);
|
|
1649
1898
|
let panel;
|
|
1650
1899
|
// Beside the chat or over it: as the person last left it, for the conversation.
|
|
1651
1900
|
let panelDocked = false;
|
|
@@ -1797,7 +2046,7 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1797
2046
|
discuss: (page) => {
|
|
1798
2047
|
back();
|
|
1799
2048
|
const why = page.codebase?.stuck?.why;
|
|
1800
|
-
|
|
2049
|
+
self.say?.(`regen says ${page.module} needs me: ${page.status}. What went wrong, and what do we do about it?${why !== undefined ? `\n\n(turri's reason: ${why})` : ""}`);
|
|
1801
2050
|
},
|
|
1802
2051
|
// Something took the keys' focus while the panel showed - a question in the chat - and waits there.
|
|
1803
2052
|
waiting: () => {
|
|
@@ -1914,109 +2163,8 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1914
2163
|
},
|
|
1915
2164
|
});
|
|
1916
2165
|
};
|
|
1917
|
-
|
|
1918
|
-
// the spec, with how far the code has been regenerated into it - is PANEL_KEY's, no command of its own beside
|
|
1919
|
-
// /regenerate. What the agents did is in each pulse's log.
|
|
1920
|
-
pi.registerCommand("pulses", {
|
|
1921
|
-
description: `The pulses, in the side panel: every pulse, regenerating, planned and finished, and what each regenerates. ${PANEL_KEY} opens the panel too.`,
|
|
1922
|
-
handler: async (_args, ctx) => {
|
|
1923
|
-
if (!ctx.hasUI)
|
|
1924
|
-
return;
|
|
1925
|
-
openPanel(ctx, "pulses");
|
|
1926
|
-
},
|
|
1927
|
-
});
|
|
1928
|
-
panelOpeners.set(jobs, (ctx) => openPanel(ctx));
|
|
1929
|
-
questionPeek = {
|
|
1930
|
-
open: (ctx) => openPanel(ctx),
|
|
1931
|
-
shown: () => panel !== undefined && panel.keys,
|
|
1932
|
-
};
|
|
1933
|
-
pi.registerShortcut(PANEL_KEY, {
|
|
1934
|
-
description: "Peek at regen and the pulses - a question waiting in the chat waits for you - or go back to it",
|
|
1935
|
-
handler: async (ctx) => {
|
|
1936
|
-
if (!ctx.hasUI)
|
|
1937
|
-
return;
|
|
1938
|
-
openPanel(ctx);
|
|
1939
|
-
},
|
|
1940
|
-
});
|
|
1941
|
-
// /turri-config: every setting of config.turri.yaml from the chat, each taking effect as it says - what turri fixed at its
|
|
1942
|
-
// start changed here, as it runs.
|
|
1943
|
-
registerTurriConfig(pi, {
|
|
1944
|
-
cwd,
|
|
1945
|
-
rules: () => loadRules(join(PACKAGE_ROOT, "genome", "rules")).map((rule) => rule.name),
|
|
1946
|
-
chatSpec: () => modelSpec(projectModels(readProjectConfig(cwd)).harness),
|
|
1947
|
-
live: {
|
|
1948
|
-
chatModel: async (spec, ctx) => {
|
|
1949
|
-
if (spec === undefined)
|
|
1950
|
-
return "The conversation keeps the model it runs on now; turri starts on pi's own choice next time.";
|
|
1951
|
-
const { model, thinking } = splitModelSpec(spec);
|
|
1952
|
-
if (model !== undefined) {
|
|
1953
|
-
const resolved = resolveModel(model, ctx.modelRegistry);
|
|
1954
|
-
if (!resolved.ok)
|
|
1955
|
-
return `The conversation keeps its model: ${model} cannot be used here.\n${describeResolution(resolved)}`;
|
|
1956
|
-
const slash = resolved.model.indexOf("/");
|
|
1957
|
-
const found = ctx.modelRegistry.find(resolved.model.slice(0, slash), resolved.model.slice(slash + 1));
|
|
1958
|
-
if (found === undefined || !(await pi.setModel(found)))
|
|
1959
|
-
return `The conversation keeps its model: ${resolved.model} could not be switched to.`;
|
|
1960
|
-
}
|
|
1961
|
-
if (thinking !== undefined)
|
|
1962
|
-
pi.setThinkingLevel(thinking);
|
|
1963
|
-
return `The conversation runs on ${spec} now.`;
|
|
1964
|
-
},
|
|
1965
|
-
codingModel: () => {
|
|
1966
|
-
const dropped = [];
|
|
1967
|
-
// What the session chose wins over the file; changed in the file, the file's holds from now on.
|
|
1968
|
-
if (process.env[CODING_MODEL_ENV] !== undefined) {
|
|
1969
|
-
dropped.push(`--coding-model ${process.env[CODING_MODEL_ENV]}`);
|
|
1970
|
-
delete process.env[CODING_MODEL_ENV];
|
|
1971
|
-
}
|
|
1972
|
-
for (const work of Object.keys(chosenModels)) {
|
|
1973
|
-
dropped.push(`the engine's ${work === "all" ? "" : `${work.replace(/_/g, " ")} `}model ${chosenModels[work]}`);
|
|
1974
|
-
delete chosenModels[work];
|
|
1975
|
-
}
|
|
1976
|
-
return dropped.length === 0 ? undefined : `Chosen this session, and given way to the config: ${dropped.join("; ")}.`;
|
|
1977
|
-
},
|
|
1978
|
-
parallelJobs: (count) => {
|
|
1979
|
-
const limit = count ?? parallelJobs(cwd);
|
|
1980
|
-
jobs.setMaxRunning(limit);
|
|
1981
|
-
return `Up to ${limit} pulse${limit === 1 ? "" : "s"} run at once now.`;
|
|
1982
|
-
},
|
|
1983
|
-
},
|
|
1984
|
-
});
|
|
2166
|
+
return self;
|
|
1985
2167
|
}
|
|
1986
|
-
export { fromWorkingDir };
|
|
1987
|
-
const WATCH_REDRAW_MS = 500;
|
|
1988
|
-
/**
|
|
1989
|
-
* What the agent is to do with a job that just ended, unasked: what the project's guidance says to do then, or else say
|
|
1990
|
-
* in a line or two what ended - the person saw it end already, in the transcript.
|
|
1991
|
-
*/
|
|
1992
|
-
export const JOB_FINISHED_REACTION = [
|
|
1993
|
-
"React to this now - the person has not asked, and need not. If guidance for this project - its AGENTS.md, a skill,",
|
|
1994
|
-
"what the person said earlier - says what to do when a pulse finishes (release what was built, run something, tell",
|
|
1995
|
-
"someone), do that, as it says. Otherwise say in a line or two what finished and what it means for the person - the",
|
|
1996
|
-
"report is theirs to read in full; do not repeat it - and, when it failed, what they can do. Start no other work.",
|
|
1997
|
-
"A report that says \"Released after the pulse\" was released by turri itself, as the pulse's last stage: do not call",
|
|
1998
|
-
"release for it. Say in a line what was released, or why nothing was - then, when it was released, do what you were",
|
|
1999
|
-
"told to do once something is released (opening it where the person can watch it, saying what to test), if anything.",
|
|
2000
|
-
"A pulse that stopped on its environment - \"stopped: turri's sandbox kept its agents from…\" or \"stopped: the",
|
|
2001
|
-
"environment lacks…\" - stops the same way again until that is fixed: never regenerate it before. Heal it, then regenerate the",
|
|
2002
|
-
"module to resume:",
|
|
2003
|
-
"- turri's sandbox kept the agents from reading a folder on this machine: nothing is missing. Add the folder its report",
|
|
2004
|
-
" names to `toolchains:` in config.turri.yaml - a list, keeping what is there - say so in a line, and resume.",
|
|
2005
|
-
"- turri's sandbox kept the agents from writing a file - a lock a build tool takes, say: no setting lets them, and",
|
|
2006
|
-
" `toolchains:` does not - never add it there, and never resume before the fix. Do what the report says if it is the",
|
|
2007
|
-
" project's to change - a tool told to use `$HOME` in the build's own files - say so, and resume; otherwise tell the",
|
|
2008
|
-
" person, in a line or two, what the report says and that \"resume\" picks it up once it is done.",
|
|
2009
|
-
"- the machine lacks a toolchain or a tool - a JDK, Maven, Gradle, Go, uv, Node, a system library: first find out, in your",
|
|
2010
|
-
" shell, whether it is installed somewhere off the PATH (`command -v`, Homebrew's opt folders, /Library/Java/",
|
|
2011
|
-
" JavaVirtualMachines, a version manager the person uses - mise, asdf, sdkman, nvm). Then ask the person with ask_user",
|
|
2012
|
-
" to let you put it in place the way this machine installs things - the exact command, `brew install openjdk@21`,",
|
|
2013
|
-
" `mise use -g java@21`, `sdk install maven` - and on yes run it in your shell, check that the command is found now as",
|
|
2014
|
-
" the testing script would find it (`command -v`, then the doctor tool, which looks on turri's own PATH), and resume. A tool installed where the PATH turri",
|
|
2015
|
-
" was started with does not reach needs turri restarted: say so, with the line for the shell profile. On no, or when it",
|
|
2016
|
-
" needs what you cannot do - sudo with a password, a licence, a download behind a login - tell the person exactly what",
|
|
2017
|
-
" to do, and that \"resume\" picks it up once it is in.",
|
|
2018
|
-
"- a credential `agent-env:` names that is not there: never make one up - tell the person where it is read from.",
|
|
2019
|
-
].join("\n");
|
|
2020
2168
|
/** Opens the side panel, and goes back to it from the conversation. */
|
|
2021
2169
|
/** The modules in this project that have a history, by name. */
|
|
2022
2170
|
function modulesWithHistory(cwd) {
|
|
@@ -2225,8 +2373,10 @@ function registerDeploy(pi, cwd, jobs, state) {
|
|
|
2225
2373
|
});
|
|
2226
2374
|
for (const tool of tools)
|
|
2227
2375
|
pi.registerTool(tool);
|
|
2228
|
-
|
|
2229
|
-
|
|
2376
|
+
// What activate started runs until turri ends - not when a chat is opened, closed, forked or reloaded.
|
|
2377
|
+
pi.on("session_shutdown", async (event) => {
|
|
2378
|
+
if (event.reason === "quit")
|
|
2379
|
+
await stopDeployed();
|
|
2230
2380
|
});
|
|
2231
2381
|
}
|
|
2232
2382
|
/** validate, for the conversation and for the skills that write specs alike. */
|
|
@@ -2480,11 +2630,16 @@ function registerBranding(pi, skills) {
|
|
|
2480
2630
|
// where the terminal shows images, drawn in text where it does not.
|
|
2481
2631
|
return new Banner(lines, (rule) => theme.fg("borderMuted", rule), jellyfishArt(theme.appearance, (theme.getColorMode?.() ?? "truecolor") === "truecolor"));
|
|
2482
2632
|
});
|
|
2633
|
+
let bannered = false;
|
|
2483
2634
|
pi.on("session_start", (_event, ctx) => {
|
|
2484
2635
|
if (ctx.hasUI) {
|
|
2485
2636
|
padTheScreen(ctx);
|
|
2486
2637
|
animateJellyfish(ctx);
|
|
2487
2638
|
}
|
|
2639
|
+
// Once a chat: it starts again each time it is shown.
|
|
2640
|
+
if (bannered)
|
|
2641
|
+
return;
|
|
2642
|
+
bannered = true;
|
|
2488
2643
|
try {
|
|
2489
2644
|
const identity = turriIdentity();
|
|
2490
2645
|
pi.appendEntry(BANNER_ENTRY, {
|