@turri-dev/cli 0.1.25 → 0.1.26-next.f80f259
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 -0
- package/dist/chat-records.d.ts +4 -0
- package/dist/chat-records.js +5 -0
- package/dist/chats.d.ts +25 -8
- package/dist/chats.js +280 -43
- package/dist/config-command.js +30 -0
- package/dist/config-edit.js +2 -2
- package/dist/config.d.ts +6 -3
- package/dist/config.js +2 -2
- package/dist/extension.js +12 -9
- package/dist/host-session.d.ts +4 -2
- package/dist/host.d.ts +1 -1
- package/dist/host.js +1 -1
- package/dist/init-config.d.ts +1 -1
- package/dist/init-config.js +3 -2
- package/dist/notice.d.ts +33 -2
- package/dist/notice.js +24 -1
- package/dist/regenerate/ledger.d.ts +4 -0
- package/dist/sentinel-chat.d.ts +5 -0
- package/dist/sentinel-chat.js +30 -3
- package/dist/sentinel.d.ts +8 -2
- package/dist/sentinel.js +49 -11
- package/docs/chats.md +2 -2
- package/docs/config.md +1 -1
- package/docs/hosting.md +7 -3
- package/docs/sentinel.md +28 -10
- package/docs/using.md +2 -1
- package/genome/rules/turri-regeneration.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -159,6 +159,7 @@ In a new project **turri** starts it itself, without asking how: it chooses the
|
|
|
159
159
|
- **`/halt`** halts a pulse, **`/lineage`** shows every generation of a module, and the **`drift`** tool says what is left to regenerate;
|
|
160
160
|
- **`/btw <question>`** asks turri something on the side - answered at once, even while a question waits for you (typed as the answer, under "Something else…"), and left out of the conversation;
|
|
161
161
|
- **`/turri-config`** changes any setting of the project's `config.turri.yaml` - the models, review, conformance tests, how many pulses at once - and says when it takes effect: now, at your next message, or at the next pulse. No restart: `/turri-config review false`, or `/turri-config` alone to pick from the list;
|
|
162
|
+
- **`/sentinel`** says whether the sentinel checks every pulse that finishes: `ask` (you are asked before it first opens), `on` or `off`;
|
|
162
163
|
- **`/stack`** chooses the stack to build with, in a project whose backend and frontend each have their own testing scripts in `config.turri.<stack>.yaml` - `/stack backend`, `/stack none` - for the session (`--stack <name>` on the command line). The chat's model builds each part with its own stack by itself, pinned to that module's pulses.
|
|
163
164
|
|
|
164
165
|
Other things to ask for:
|
package/dist/chat-records.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ export interface RunRecord {
|
|
|
24
24
|
/** How many runs' chats are kept: the most recent, the one `--continue` reopens, among them. */
|
|
25
25
|
export declare const RUNS_KEPT = 5;
|
|
26
26
|
export declare function chatsFile(project: string): string;
|
|
27
|
+
/** A chat's saved conversation it no longer has - the sentinel's, before a pulse's fresh one - deleted, unless kept. */
|
|
28
|
+
export declare function forgetSessionFile(file: string, options?: {
|
|
29
|
+
keep?: boolean;
|
|
30
|
+
}): void;
|
|
27
31
|
/** The runs recorded here, the most recent first. */
|
|
28
32
|
export declare function recordedRuns(project: string): RunRecord[];
|
|
29
33
|
/**
|
package/dist/chat-records.js
CHANGED
|
@@ -31,6 +31,11 @@ function write(project, file) {
|
|
|
31
31
|
// A chat not recorded is not reopened; nothing else is lost.
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
/** A chat's saved conversation it no longer has - the sentinel's, before a pulse's fresh one - deleted, unless kept. */
|
|
35
|
+
export function forgetSessionFile(file, options = {}) {
|
|
36
|
+
if (options.keep !== true)
|
|
37
|
+
deleteSession(file);
|
|
38
|
+
}
|
|
34
39
|
function deleteSession(file) {
|
|
35
40
|
try {
|
|
36
41
|
rmSync(file, { force: true });
|
package/dist/chats.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { AgentSession, ExtensionAPI, ExtensionContext, ExtensionFactory, Ex
|
|
|
2
2
|
import { type ChatScope } from "./chat-scope.ts";
|
|
3
3
|
import { type RunRecord } from "./chat-records.ts";
|
|
4
4
|
import { type ChatUi } from "./chat-ui.ts";
|
|
5
|
+
import { type SentinelSetting } from "./config.ts";
|
|
5
6
|
import type { AskUserIO } from "./ask.ts";
|
|
6
7
|
import type { Job, JobRegistry } from "./regenerate/jobs.ts";
|
|
7
8
|
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
@@ -23,7 +24,10 @@ import { type ChatRole, type Conclusion, type Notice, type NoticeAnswer, type No
|
|
|
23
24
|
*
|
|
24
25
|
* With the project's sentinel on (sentinel.ts), every finished pulse goes to the sentinel - a chat of the session's own,
|
|
25
26
|
* opened in the background with the first, and again after it is closed - and the chat the person looks at shows only
|
|
26
|
-
* that it landed.
|
|
27
|
+
* that it landed. The sentinel checks each pulse in a conversation of its own: its chat keeps its id, and is given a new
|
|
28
|
+
* session for each pulse, one at a time. What the sentinel finds wrong it puts to the person as a notice, in the chat
|
|
29
|
+
* they look at. Under `sentinel: ask`, the person is asked first - a notice of turri's own, before it first opens in the
|
|
30
|
+
* session - the pulses that end meanwhile held until they answer.
|
|
27
31
|
*/
|
|
28
32
|
/** The most chats a session has open at once. */
|
|
29
33
|
export declare const MAX_CHATS = 9;
|
|
@@ -59,11 +63,16 @@ export interface TurriHost {
|
|
|
59
63
|
* work: what it returns is what is sent. For a host that needs to change anything else.
|
|
60
64
|
*/
|
|
61
65
|
systemPrompt?(prompt: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* A chat's session replaced by a new one, under the same id: the sentinel's, a fresh conversation for each pulse it
|
|
68
|
+
* checks - told before the pulse is put to it. The host follows this session in place of the one it had.
|
|
69
|
+
*/
|
|
70
|
+
onChatSession?(chat: string, session: AgentSession): void;
|
|
62
71
|
/** The sentinel is done with a pulse - told to the chat it names, whose agent says so. */
|
|
63
72
|
onConcluded?(conclusion: Conclusion & {
|
|
64
73
|
chat: string;
|
|
65
74
|
}): void;
|
|
66
|
-
/** What the sentinel puts to the person: the host draws it in the chat it names, and answers it with `answer`. */
|
|
75
|
+
/** What the sentinel puts to the person - and, `kind: "sentinel-start"`, whether to start it: the host draws it in the chat it names, and answers it with `answer`. */
|
|
67
76
|
onNotice?(notice: Notice): void;
|
|
68
77
|
/** A notice is over - answered, dismissed, or superseded by a newer one about its module: the host takes its card down. */
|
|
69
78
|
onNoticeOver?(notice: Notice): void;
|
|
@@ -88,11 +97,10 @@ export interface ChatInstance {
|
|
|
88
97
|
landed(job: Job, options?: {
|
|
89
98
|
tell?: boolean;
|
|
90
99
|
}): void;
|
|
91
|
-
/** The sentinel's: a pulse ended - its agent
|
|
100
|
+
/** The sentinel's: a pulse ended - its agent, in a conversation of its own for this pulse, told of it. */
|
|
92
101
|
brief(job: Job, context: {
|
|
93
102
|
asked?: string;
|
|
94
103
|
focused?: string;
|
|
95
|
-
replace: boolean;
|
|
96
104
|
}): Promise<void>;
|
|
97
105
|
/** A notice put in the chat: drawn, and - in the terminal - put to the person once the chat is idle. */
|
|
98
106
|
notice(notice: Notice): void;
|
|
@@ -207,8 +215,11 @@ export interface TurriCore {
|
|
|
207
215
|
concluded(conclusion: Conclusion): string | undefined;
|
|
208
216
|
/** The notices put to the person and not yet answered. */
|
|
209
217
|
notices(): Notice[];
|
|
210
|
-
/**
|
|
211
|
-
|
|
218
|
+
/**
|
|
219
|
+
* A notice answered: what it asked done in the chat it is in, dismissed, or - an ignorable one - ignored for good.
|
|
220
|
+
* `refused`: `ignore` on one that is not ignorable.
|
|
221
|
+
*/
|
|
222
|
+
answer(id: string, answer: NoticeAnswer): "done" | "dismissed" | "ignored" | "refused" | "missing" | "over";
|
|
212
223
|
/** For the extension: the jobs changed - every chat draws them again. */
|
|
213
224
|
jobsChanged(): void;
|
|
214
225
|
/** The chat whose work this is, else the one shown: its instance. */
|
|
@@ -229,8 +240,14 @@ export interface CoreOptions {
|
|
|
229
240
|
persist?: boolean;
|
|
230
241
|
/** The host's dialogs, a chat's own. */
|
|
231
242
|
dialogs?: (chat: string) => AskUserIO | undefined;
|
|
232
|
-
/** Whether the project wants a sentinel - read as each pulse finishes. Unset: none. */
|
|
233
|
-
sentinel?: () =>
|
|
243
|
+
/** Whether the project wants a sentinel - read as each pulse finishes: on, off, or ask the person first. Unset: none. */
|
|
244
|
+
sentinel?: () => SentinelSetting;
|
|
245
|
+
/** Under `sentinel: ask`: what the person is asked before the sentinel first opens, about the pulse that would open it. */
|
|
246
|
+
sentinelStart?: (job: Job) => NoticeInput;
|
|
247
|
+
/** The person chose for the project, asked whether to start the sentinel: `sentinel:` written. */
|
|
248
|
+
setSentinel?: (on: boolean) => void;
|
|
249
|
+
/** The person ignored a notice for good: kept in the sentinel's guidance, for every conversation after. */
|
|
250
|
+
ignored?: (notice: Notice) => void;
|
|
234
251
|
}
|
|
235
252
|
/** A chat's session, as stopping what it does reaches it. */
|
|
236
253
|
export type Stoppable = Pick<AgentSession, "isStreaming" | "isCompacting" | "isRetrying" | "isBashRunning" | "abort" | "abortBash">;
|
package/dist/chats.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { currentChat, inChat } from "./chat-scope.js";
|
|
4
|
-
import { adoptRun, forgetChat, recordRun } from "./chat-records.js";
|
|
4
|
+
import { adoptRun, forgetChat, forgetSessionFile, recordRun } from "./chat-records.js";
|
|
5
5
|
import { createChatUi } from "./chat-ui.js";
|
|
6
6
|
import { debugMode } from "./config.js";
|
|
7
7
|
import { record } from "./regenerate/ledger.js";
|
|
8
|
-
import { newNotice, noticeTask, SENTINEL, wentWrong } from "./notice.js";
|
|
8
|
+
import { newNotice, noticeTask, SENTINEL, sentinelStartChoice, wentWrong } from "./notice.js";
|
|
9
9
|
/**
|
|
10
10
|
* The chats of a turri session.
|
|
11
11
|
*
|
|
@@ -23,7 +23,10 @@ import { newNotice, noticeTask, SENTINEL, wentWrong } from "./notice.js";
|
|
|
23
23
|
*
|
|
24
24
|
* With the project's sentinel on (sentinel.ts), every finished pulse goes to the sentinel - a chat of the session's own,
|
|
25
25
|
* opened in the background with the first, and again after it is closed - and the chat the person looks at shows only
|
|
26
|
-
* that it landed.
|
|
26
|
+
* that it landed. The sentinel checks each pulse in a conversation of its own: its chat keeps its id, and is given a new
|
|
27
|
+
* session for each pulse, one at a time. What the sentinel finds wrong it puts to the person as a notice, in the chat
|
|
28
|
+
* they look at. Under `sentinel: ask`, the person is asked first - a notice of turri's own, before it first opens in the
|
|
29
|
+
* session - the pulses that end meanwhile held until they answer.
|
|
27
30
|
*/
|
|
28
31
|
/** The most chats a session has open at once. */
|
|
29
32
|
export const MAX_CHATS = 9;
|
|
@@ -88,8 +91,19 @@ export function createChats(options) {
|
|
|
88
91
|
let notices = [];
|
|
89
92
|
/** What the sentinel put to the person, and turri itself, this session: open until answered or superseded. */
|
|
90
93
|
const open = [];
|
|
91
|
-
/** The pulse the sentinel is on
|
|
92
|
-
let
|
|
94
|
+
/** The pulse the sentinel's conversation is on, from its brief until its agent is done: a newer one of the same module replaces a check of one that went well. */
|
|
95
|
+
let checking;
|
|
96
|
+
/** Pulses that finished while the sentinel was at work: each checked in turn, in a conversation of its own. */
|
|
97
|
+
const waiting = [];
|
|
98
|
+
/** What the sentinel is given is given one pulse at a time: its conversations made, and its pulses put to them, in order. */
|
|
99
|
+
let sentinelWork = Promise.resolve();
|
|
100
|
+
/**
|
|
101
|
+
* Under `sentinel: ask`, whether the person said to start the sentinel this session: asked - the notice `asking` open,
|
|
102
|
+
* the pulses that ended meanwhile `held` - started, or not this session.
|
|
103
|
+
*/
|
|
104
|
+
let consent = "unasked";
|
|
105
|
+
let asking;
|
|
106
|
+
const held = [];
|
|
93
107
|
const run = { run: randomUUID().slice(0, 8), pid: process.pid, started: new Date().toISOString(), chats: [] };
|
|
94
108
|
const changed = () => {
|
|
95
109
|
for (const listener of listeners) {
|
|
@@ -106,14 +120,19 @@ export function createChats(options) {
|
|
|
106
120
|
const sentinelChat = () => chats.find((chat) => chat.role === SENTINEL);
|
|
107
121
|
/** The person's chats: all but the sentinel. */
|
|
108
122
|
const people = () => chats.filter((chat) => chat.role !== SENTINEL);
|
|
109
|
-
const
|
|
123
|
+
const setting = () => {
|
|
110
124
|
try {
|
|
111
|
-
return options.sentinel?.()
|
|
125
|
+
return options.sentinel?.() ?? false;
|
|
112
126
|
}
|
|
113
127
|
catch {
|
|
114
128
|
return false;
|
|
115
129
|
}
|
|
116
130
|
};
|
|
131
|
+
/** On, or asked for - and not declined this session. */
|
|
132
|
+
const wantsSentinel = () => {
|
|
133
|
+
const now = setting();
|
|
134
|
+
return now === true || (now === "ask" && consent !== "declined");
|
|
135
|
+
};
|
|
117
136
|
/** The chat the person looks at, for what is put to them - never the sentinel: when it is shown, `fallback`, else their first. */
|
|
118
137
|
const personFacing = (fallback) => {
|
|
119
138
|
const shown = chats.find((chat) => chat.id === shownId);
|
|
@@ -160,12 +179,15 @@ export function createChats(options) {
|
|
|
160
179
|
}
|
|
161
180
|
else if (event.type === "agent_end") {
|
|
162
181
|
chat.working = false;
|
|
163
|
-
if (chat.role === SENTINEL)
|
|
164
|
-
sentinelOnJob = undefined;
|
|
165
182
|
if (shownId !== chat.id)
|
|
166
183
|
chat.unread = true;
|
|
167
184
|
persist();
|
|
168
185
|
changed();
|
|
186
|
+
// Done with its pulse - or with what the person said to it: the next pulse waiting, in a conversation of its own.
|
|
187
|
+
if (chat.role === SENTINEL && event.willRetry !== true) {
|
|
188
|
+
checking = undefined;
|
|
189
|
+
toSentinel(() => nextPulse(chat));
|
|
190
|
+
}
|
|
169
191
|
}
|
|
170
192
|
else if (event.type === "session_info_changed") {
|
|
171
193
|
persist();
|
|
@@ -250,6 +272,14 @@ export function createChats(options) {
|
|
|
250
272
|
}
|
|
251
273
|
const recorded = run.chats.find((kept) => kept.id === id);
|
|
252
274
|
chats.splice(chats.indexOf(chat), 1);
|
|
275
|
+
// Closed, the sentinel is let go with what it had to check: the next pulse that finishes opens it again.
|
|
276
|
+
if (chat.role === SENTINEL) {
|
|
277
|
+
checking = undefined;
|
|
278
|
+
waiting.length = 0;
|
|
279
|
+
}
|
|
280
|
+
// The question whether to start the sentinel was in it: put again in the chat the person looks at now.
|
|
281
|
+
if (asking?.chat === id)
|
|
282
|
+
askAgain();
|
|
253
283
|
chat.unsubscribe?.();
|
|
254
284
|
chat.ui?.close();
|
|
255
285
|
inChat(chat.scope, () => record({ kind: "chat.closed" }, { project: cwd }));
|
|
@@ -343,26 +373,19 @@ export function createChats(options) {
|
|
|
343
373
|
},
|
|
344
374
|
sentinelOn: () => driver !== undefined && wantsSentinel(),
|
|
345
375
|
jobFinished(job) {
|
|
376
|
+
// The question was settled in config.turri.yaml while it was open: what it held goes as the file now says.
|
|
377
|
+
if (consent === "asking" && setting() !== "ask")
|
|
378
|
+
settleStart(setting() === true ? "given" : "declined", "superseded");
|
|
346
379
|
if (core.sentinelOn()) {
|
|
380
|
+
if (consent === "asking" || (setting() === "ask" && consent === "unasked" && sentinelChat() === undefined && askToStart(job))) {
|
|
381
|
+
held.push(job);
|
|
382
|
+
changed();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
347
385
|
sentinelFinished(job);
|
|
348
386
|
return;
|
|
349
387
|
}
|
|
350
|
-
|
|
351
|
-
const chat = asked?.instance !== undefined ? asked : (core.shownChat() ?? chats[0]);
|
|
352
|
-
if (chat === undefined)
|
|
353
|
-
return;
|
|
354
|
-
if (chat.id !== shownId)
|
|
355
|
-
chat.unread = true;
|
|
356
|
-
try {
|
|
357
|
-
chat.instance?.announce(job);
|
|
358
|
-
}
|
|
359
|
-
catch {
|
|
360
|
-
// A chat let go as the pulse ended: the one shown is told instead.
|
|
361
|
-
const shown = core.shownChat();
|
|
362
|
-
if (shown !== undefined && shown !== chat)
|
|
363
|
-
shown.instance?.announce(job);
|
|
364
|
-
}
|
|
365
|
-
changed();
|
|
388
|
+
toAsker(job);
|
|
366
389
|
},
|
|
367
390
|
jobsChanged() {
|
|
368
391
|
for (const chat of chats) {
|
|
@@ -384,7 +407,9 @@ export function createChats(options) {
|
|
|
384
407
|
for (const older of open.filter((notice) => notice.state === "open" && notice.module === input.module))
|
|
385
408
|
over(older, "superseded");
|
|
386
409
|
}
|
|
387
|
-
|
|
410
|
+
// Only the sentinel's own may be ignored: never the question whether to start it.
|
|
411
|
+
const { ignorable, ...rest } = input;
|
|
412
|
+
const notice = newNotice(ignorable === true && input.kind !== "sentinel-start" ? input : rest, target.id);
|
|
388
413
|
open.push(notice);
|
|
389
414
|
if (target.id !== shownId)
|
|
390
415
|
target.unread = true;
|
|
@@ -437,6 +462,21 @@ export function createChats(options) {
|
|
|
437
462
|
return "missing";
|
|
438
463
|
if (notice.state !== "open")
|
|
439
464
|
return "over";
|
|
465
|
+
// Left as the build has it, for good: nothing done in the chat, and the sentinel told not to raise it again.
|
|
466
|
+
if ("ignore" in answer) {
|
|
467
|
+
if (notice.ignorable !== true)
|
|
468
|
+
return "refused";
|
|
469
|
+
try {
|
|
470
|
+
options.ignored?.(notice);
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
core.notify(`The sentinel's guidance could not be written: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
474
|
+
}
|
|
475
|
+
over(notice, "ignored");
|
|
476
|
+
return "ignored";
|
|
477
|
+
}
|
|
478
|
+
if (notice.kind === "sentinel-start")
|
|
479
|
+
return answerStart(notice, answer);
|
|
440
480
|
const task = noticeTask(notice, answer);
|
|
441
481
|
over(notice, "dismiss" in answer ? "dismissed" : "steer" in answer ? "steered" : "accepted");
|
|
442
482
|
if (task === undefined)
|
|
@@ -491,11 +531,94 @@ export function createChats(options) {
|
|
|
491
531
|
if (chat !== undefined)
|
|
492
532
|
inChat(chat.scope, () => record({ kind: "notice.over", notice: notice.id, state }, { project: cwd }));
|
|
493
533
|
}
|
|
534
|
+
/** A pulse ended, told to the chat that asked for it - as without a sentinel - or the chat shown, when that one is closed. */
|
|
535
|
+
function toAsker(job) {
|
|
536
|
+
const asked = chats.find((chat) => chat.id === job.chat);
|
|
537
|
+
const chat = asked?.instance !== undefined ? asked : (core.shownChat() ?? chats[0]);
|
|
538
|
+
if (chat === undefined)
|
|
539
|
+
return;
|
|
540
|
+
if (chat.id !== shownId)
|
|
541
|
+
chat.unread = true;
|
|
542
|
+
try {
|
|
543
|
+
chat.instance?.announce(job);
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
// A chat let go as the pulse ended: the one shown is told instead.
|
|
547
|
+
const shown = core.shownChat();
|
|
548
|
+
if (shown !== undefined && shown !== chat)
|
|
549
|
+
shown.instance?.announce(job);
|
|
550
|
+
}
|
|
551
|
+
changed();
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Under `sentinel: ask`, the first pulse that would open the sentinel this session: the person asked whether to start it,
|
|
555
|
+
* in the chat they look at. False when there is nothing to ask with, or no chat to ask in: the sentinel opens as asked.
|
|
556
|
+
*/
|
|
557
|
+
function askToStart(job) {
|
|
558
|
+
let input;
|
|
559
|
+
try {
|
|
560
|
+
input = options.sentinelStart?.(job);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
input = undefined;
|
|
564
|
+
}
|
|
565
|
+
if (input === undefined)
|
|
566
|
+
return false;
|
|
567
|
+
consent = "asking";
|
|
568
|
+
asking = core.notice({ ...input, kind: "sentinel-start" });
|
|
569
|
+
if (asking === undefined)
|
|
570
|
+
consent = "unasked";
|
|
571
|
+
return asking !== undefined;
|
|
572
|
+
}
|
|
573
|
+
/** The chat the question was put in is gone: it is put in the chat the person looks at - none left, the pulses go as without one. */
|
|
574
|
+
function askAgain() {
|
|
575
|
+
const question = asking;
|
|
576
|
+
over(question, "superseded");
|
|
577
|
+
asking = undefined;
|
|
578
|
+
consent = "unasked";
|
|
579
|
+
if (!askToStart(held[0]))
|
|
580
|
+
settleStart("declined", "superseded");
|
|
581
|
+
}
|
|
582
|
+
/** The person answered whether to start the sentinel: written to the project, when they chose for it, and what was held let go. */
|
|
583
|
+
function answerStart(notice, answer) {
|
|
584
|
+
const choice = sentinelStartChoice(notice, answer);
|
|
585
|
+
try {
|
|
586
|
+
if (choice === "always" || choice === "never")
|
|
587
|
+
options.setSentinel?.(choice === "always");
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
core.notify(`config.turri.yaml could not be written: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
591
|
+
}
|
|
592
|
+
settleStart(choice === "start" || choice === "always" ? "given" : "declined", "dismiss" in answer ? "dismissed" : "steer" in answer ? "steered" : "accepted");
|
|
593
|
+
// Their own words are the chat's to act on: the sentinel is not started.
|
|
594
|
+
if ("steer" in answer) {
|
|
595
|
+
const task = noticeTask(notice, answer);
|
|
596
|
+
const chat = chats.find((candidate) => candidate.id === notice.chat) ?? personFacing();
|
|
597
|
+
if (task !== undefined)
|
|
598
|
+
chat?.instance?.act(task);
|
|
599
|
+
}
|
|
600
|
+
return "dismiss" in answer ? "dismissed" : "done";
|
|
601
|
+
}
|
|
602
|
+
/** Whether to start the sentinel is settled: its question closed, and the pulses held go to it - or, declined, to their chats. */
|
|
603
|
+
function settleStart(now, state) {
|
|
604
|
+
consent = now;
|
|
605
|
+
const question = asking;
|
|
606
|
+
asking = undefined;
|
|
607
|
+
if (question !== undefined && question.state === "open")
|
|
608
|
+
over(question, state);
|
|
609
|
+
for (const job of held.splice(0)) {
|
|
610
|
+
if (core.sentinelOn())
|
|
611
|
+
sentinelFinished(job);
|
|
612
|
+
else
|
|
613
|
+
toAsker(job);
|
|
614
|
+
}
|
|
615
|
+
changed();
|
|
616
|
+
}
|
|
494
617
|
/**
|
|
495
618
|
* A pulse ended, with the sentinel on: the chat the person looks at says so - its agent told of one that landed, to
|
|
496
619
|
* tell the person it landed and what the sentinel does with it, and nothing more; of one that went wrong, not, the
|
|
497
|
-
* sentinel's notice being what it hears of it - and the sentinel is
|
|
498
|
-
*
|
|
620
|
+
* sentinel's notice being what it hears of it - and the sentinel is given it, opened in the background when it is not
|
|
621
|
+
* open.
|
|
499
622
|
*/
|
|
500
623
|
function sentinelFinished(job) {
|
|
501
624
|
const asked = chats.find((chat) => chat.id === job.chat && chat.role !== SENTINEL);
|
|
@@ -511,7 +634,7 @@ export function createChats(options) {
|
|
|
511
634
|
}
|
|
512
635
|
}
|
|
513
636
|
changed();
|
|
514
|
-
|
|
637
|
+
toSentinel(async () => {
|
|
515
638
|
let sentinel = sentinelChat();
|
|
516
639
|
if (sentinel === undefined) {
|
|
517
640
|
try {
|
|
@@ -527,23 +650,137 @@ export function createChats(options) {
|
|
|
527
650
|
}
|
|
528
651
|
if (sentinel?.instance === undefined)
|
|
529
652
|
return;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
653
|
+
await takePulse(sentinel, job);
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
/** The sentinel's work, after what it was given before. */
|
|
657
|
+
function toSentinel(work) {
|
|
658
|
+
sentinelWork = sentinelWork.then(work).catch((error) => {
|
|
659
|
+
core.notify(`The sentinel: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
/** Whether the sentinel is at work: on a pulse - its turn starts as it is told of one - or on what the person said to it. */
|
|
663
|
+
const sentinelBusy = (sentinel) => sentinel.session?.isStreaming === true;
|
|
664
|
+
/**
|
|
665
|
+
* A finished pulse, for the sentinel: checked now when it is free. Busy, a newer pulse of a module replaces a check of
|
|
666
|
+
* the module's last pulse that went well - and a pulse of it that went well and waits - and one that went wrong is
|
|
667
|
+
* looked into to its end; the rest wait their turn.
|
|
668
|
+
*/
|
|
669
|
+
function takePulse(sentinel, job) {
|
|
670
|
+
if (!sentinelBusy(sentinel))
|
|
671
|
+
return check(sentinel, job);
|
|
672
|
+
const sameModule = (other) => job.module !== undefined && other.module === job.module;
|
|
673
|
+
if (checking !== undefined && !checking.wrong && sameModule(checking.job))
|
|
674
|
+
return check(sentinel, job, checking.job);
|
|
675
|
+
const older = waiting.findIndex((other) => sameModule(other) && !wentWrong(other));
|
|
676
|
+
if (older !== -1)
|
|
677
|
+
waiting.splice(older, 1, job);
|
|
678
|
+
else
|
|
679
|
+
waiting.push(job);
|
|
680
|
+
}
|
|
681
|
+
/** The sentinel free again: the next pulse that waits, checked. */
|
|
682
|
+
function nextPulse(sentinel) {
|
|
683
|
+
if (!chats.includes(sentinel) || sentinelBusy(sentinel))
|
|
684
|
+
return;
|
|
685
|
+
const job = waiting.shift();
|
|
686
|
+
if (job !== undefined)
|
|
687
|
+
return check(sentinel, job);
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* The sentinel on a pulse, from scratch: a conversation of its own - its prompt, the project's guidance and this
|
|
691
|
+
* pulse's brief, nothing of the pulses before it - in place of the one it had, stopped when it was on `replacing`.
|
|
692
|
+
*/
|
|
693
|
+
async function check(sentinel, job, replacing) {
|
|
694
|
+
checking = { job, wrong: wentWrong(job) };
|
|
695
|
+
const module = job.module;
|
|
696
|
+
inChat(sentinel.scope, () => record({ kind: "sentinel.checking", ...(module !== undefined ? { module } : {}), ...(replacing !== undefined ? { replaced: replacing.id } : {}) }, { project: cwd, job: job.id }));
|
|
697
|
+
const session = sentinel.session;
|
|
698
|
+
if (session !== undefined && (session.messages.length > 0 || session.isStreaming))
|
|
699
|
+
await freshConversation(sentinel);
|
|
700
|
+
if (sentinel.id !== shownId)
|
|
701
|
+
sentinel.unread = true;
|
|
702
|
+
const asked = chats.find((chat) => chat.id === job.chat && chat.role !== SENTINEL && chat.instance !== undefined);
|
|
703
|
+
const where = personFacing(asked);
|
|
704
|
+
try {
|
|
705
|
+
await sentinel.instance.brief(job, {
|
|
706
|
+
...(asked !== undefined ? { asked: titleFor(asked) } : {}),
|
|
707
|
+
...(where !== undefined ? { focused: titleFor(where) } : {}),
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
core.notify(`The sentinel could not be told of ${job.id}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
712
|
+
checking = undefined;
|
|
713
|
+
}
|
|
714
|
+
changed();
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The sentinel's chat given a new session, under the same id: what it does now stopped, and what it said let go - its
|
|
718
|
+
* saved conversation deleted outside debug mode. The model and thinking it had carry over: what the person picked in
|
|
719
|
+
* it stands. Shown in the terminal, the screen is drawn from the new one. A new session that cannot be made leaves it
|
|
720
|
+
* the one it had.
|
|
721
|
+
*/
|
|
722
|
+
async function freshConversation(chat) {
|
|
723
|
+
const old = { session: chat.session, services: chat.services, instance: chat.instance };
|
|
724
|
+
chat.unsubscribe?.();
|
|
725
|
+
chat.unsubscribe = undefined;
|
|
726
|
+
try {
|
|
727
|
+
await old.session.abort();
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
/* stopped already */
|
|
731
|
+
}
|
|
732
|
+
chat.working = false;
|
|
733
|
+
const model = old.session.model;
|
|
734
|
+
const thinking = old.session.thinkingLevel;
|
|
735
|
+
// Made as a chat is opened, one at a time: its extension built while it is the one attaching.
|
|
736
|
+
const made = opening.then(async () => {
|
|
737
|
+
attaching = chat;
|
|
535
738
|
try {
|
|
536
|
-
await
|
|
537
|
-
...(asked !== undefined ? { asked: titleFor(asked) } : {}),
|
|
538
|
-
...(where !== undefined ? { focused: titleFor(where) } : {}),
|
|
539
|
-
replace,
|
|
540
|
-
});
|
|
739
|
+
await driver.create(chat, undefined);
|
|
541
740
|
}
|
|
542
|
-
|
|
543
|
-
|
|
741
|
+
finally {
|
|
742
|
+
attaching = undefined;
|
|
544
743
|
}
|
|
545
|
-
|
|
546
|
-
|
|
744
|
+
});
|
|
745
|
+
opening = made.catch(() => undefined);
|
|
746
|
+
try {
|
|
747
|
+
await made;
|
|
748
|
+
}
|
|
749
|
+
catch (error) {
|
|
750
|
+
Object.assign(chat, old);
|
|
751
|
+
follow(chat);
|
|
752
|
+
core.notify(`The sentinel goes on in the conversation it had - a new one could not be started: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const fresh = chat.session;
|
|
756
|
+
try {
|
|
757
|
+
if (model !== undefined && fresh.model !== model)
|
|
758
|
+
await fresh.setModel(model);
|
|
759
|
+
if (thinking !== undefined)
|
|
760
|
+
fresh.setThinkingLevel(thinking);
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
/* the model its session started on, then */
|
|
764
|
+
}
|
|
765
|
+
follow(chat);
|
|
766
|
+
if (shownId === chat.id)
|
|
767
|
+
await driver?.show?.(chat, undefined);
|
|
768
|
+
try {
|
|
769
|
+
old.session.dispose();
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
/* let go already */
|
|
773
|
+
}
|
|
774
|
+
if (old.session.sessionFile !== undefined && old.session.sessionFile !== fresh.sessionFile)
|
|
775
|
+
forgetSessionFile(old.session.sessionFile, { keep: debugMode(cwd) });
|
|
776
|
+
try {
|
|
777
|
+
host.onChatSession?.(chat.id, fresh);
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
/* a host that throws still has the chat's new session in session(id) */
|
|
781
|
+
}
|
|
782
|
+
persist();
|
|
783
|
+
changed();
|
|
547
784
|
}
|
|
548
785
|
return core;
|
|
549
786
|
}
|
package/dist/config-command.js
CHANGED
|
@@ -139,6 +139,36 @@ export function registerTurriConfig(pi, options) {
|
|
|
139
139
|
}
|
|
140
140
|
return top.find((setting) => chosen.startsWith(`${setting.key}:`));
|
|
141
141
|
};
|
|
142
|
+
// /sentinel: its one setting by its own name - asked before it first opens in a session, on without asking, or off.
|
|
143
|
+
const sentinelChoices = [
|
|
144
|
+
["ask", "ask", "you are asked, with what it does, before it first opens in a session"],
|
|
145
|
+
["on", "true", "it opens with the first pulse that finishes, without asking"],
|
|
146
|
+
["off", "false", "no sentinel: each pulse is told to the chat that asked for it"],
|
|
147
|
+
];
|
|
148
|
+
pi.registerCommand("sentinel", {
|
|
149
|
+
description: "Whether the sentinel checks every finished pulse: ask first, on, or off",
|
|
150
|
+
getArgumentCompletions: (prefix) => {
|
|
151
|
+
const items = sentinelChoices.filter(([word]) => word.startsWith(prefix.trim().toLowerCase()));
|
|
152
|
+
return items.length === 0 ? null : items.map(([word, , about]) => ({ value: word, label: word, description: about }));
|
|
153
|
+
},
|
|
154
|
+
handler: async (args, ctx) => {
|
|
155
|
+
const setting = settingOf("sentinel");
|
|
156
|
+
const typed = args.trim().toLowerCase();
|
|
157
|
+
let word = typed;
|
|
158
|
+
if (word === "") {
|
|
159
|
+
if (!ctx.hasUI)
|
|
160
|
+
return;
|
|
161
|
+
const now = currentValue(readConfigText(cwd), setting.key);
|
|
162
|
+
const lines = sentinelChoices.map(([choice, , about]) => `${choice} - ${about}`);
|
|
163
|
+
const chosen = await ctx.ui.select(`/sentinel - now: ${now === undefined ? `unset - ${setting.unset}` : shownValue(now)}`, lines);
|
|
164
|
+
if (chosen === undefined)
|
|
165
|
+
return;
|
|
166
|
+
word = chosen.slice(0, chosen.indexOf(" "));
|
|
167
|
+
}
|
|
168
|
+
const value = sentinelChoices.find(([choice]) => choice === word)?.[1] ?? word;
|
|
169
|
+
await apply(setting, value, ctx);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
142
172
|
pi.registerCommand("turri-config", {
|
|
143
173
|
description: `Change a setting of the project's ${CONFIG_FILE}`,
|
|
144
174
|
getArgumentCompletions: (prefix) => {
|
package/dist/config-edit.js
CHANGED
|
@@ -66,7 +66,7 @@ export function settings(rules = []) {
|
|
|
66
66
|
{ key: "mutation-testing", about: "whether the coding agent and the reviewer can put a bug in the code for one test run (can_it_fail)", kind: "boolean", unset: "true", effect: "next-pulse" },
|
|
67
67
|
{ key: "allow-to-stop-jobs", about: "whether turri stops a running pulse the ***plain has moved on from", kind: "boolean", unset: "true", effect: "now" },
|
|
68
68
|
{ key: "release-after-pulse", about: "whether every pulse that lands code releases it", kind: "boolean", unset: "false", effect: "next-landing" },
|
|
69
|
-
{ key: "sentinel", about: "whether a chat of its own checks every finished pulse, tests the release, and tells you what is wrong", kind: "
|
|
69
|
+
{ key: "sentinel", about: "whether a chat of its own checks every finished pulse, tests the release, and tells you what is wrong - ask: you are asked before it first opens", kind: "choice", options: ["ask", "true", "false"], unset: "ask", effect: "next-landing" },
|
|
70
70
|
{ key: "unittests-script", about: "the unit testing script, by path from the project root", kind: "path", unset: "the one run_unittests*.sh found", effect: "next-test-run" },
|
|
71
71
|
{ key: "conformance-tests-script", about: "the conformance testing script, by path from the project root", kind: "path", unset: "the one run_conformance_tests*.sh found", effect: "next-test-run" },
|
|
72
72
|
{ key: "prepare-environment-script", about: "run on the build before every conformance testing script run, by path from the project root - optional", kind: "path", unset: "the one prepare_environment*.sh found, else none", effect: "next-test-run" },
|
|
@@ -119,7 +119,7 @@ export function parseValue(setting, typed, known) {
|
|
|
119
119
|
case "choice": {
|
|
120
120
|
const word = text.toLowerCase();
|
|
121
121
|
if (setting.options?.includes(word))
|
|
122
|
-
return { value:
|
|
122
|
+
return { value: word === "false" ? false : word === "true" ? true : word };
|
|
123
123
|
return { error: `${setting.key} is one of ${setting.options?.join(", ")}.` };
|
|
124
124
|
}
|
|
125
125
|
case "number": {
|
package/dist/config.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* mutation-testing: false # give the coding agent and the reviewer can_it_fail (default: true)
|
|
20
20
|
* allow-to-stop-jobs: false # stop a running job the spec moved on from (default: true)
|
|
21
21
|
* release-after-pulse: true # release what each pulse lands, as its last stage, before the next pulse (default: false)
|
|
22
|
-
* sentinel:
|
|
22
|
+
* sentinel: true # a chat of its own every finished pulse goes to, which tests the release and tells you what is wrong: true, false, or ask - the person asked before it first opens in a session (default: ask)
|
|
23
23
|
* rules-off: [integrations] # ***plain rules to leave out of the prompts, by name (default: none)
|
|
24
24
|
* unittests-script: test_scripts/run_unittests.sh # the testing scripts - see test-scripts.ts
|
|
25
25
|
* conformance-tests-script: test_scripts/run_conformance_tests.sh
|
|
@@ -174,6 +174,8 @@ export declare function projectModels(config: Pick<ProjectConfig, "harnessModel"
|
|
|
174
174
|
export declare function roleModel(config: Pick<ProjectConfig, "harnessModel" | "judgeModel" | "models">, role: AgentRole): ModelChoice | undefined;
|
|
175
175
|
/** How long each turn of a role's agents may take, in milliseconds: its own, `default`'s, `turn-limit:`'s - or undefined, the harness's default. */
|
|
176
176
|
export declare function roleTurnLimitMs(config: Pick<ProjectConfig, "models" | "turnLimit">, role: AgentRole | undefined): number | undefined;
|
|
177
|
+
/** `sentinel:` - on, off, or `ask`: on once the person says so, asked before it first opens in a session. */
|
|
178
|
+
export type SentinelSetting = boolean | "ask";
|
|
177
179
|
export interface ProjectConfig {
|
|
178
180
|
/** The .plain file the module command builds when it is given none. */
|
|
179
181
|
module?: string;
|
|
@@ -228,9 +230,10 @@ export interface ProjectConfig {
|
|
|
228
230
|
releaseAfterPulse?: boolean;
|
|
229
231
|
/**
|
|
230
232
|
* Whether the session has a sentinel: a chat of its own that every finished pulse goes to - checked, a release tested,
|
|
231
|
-
* and what is wrong put to the person - instead of the chat that asked for it (docs/sentinel.md).
|
|
233
|
+
* and what is wrong put to the person - instead of the chat that asked for it (docs/sentinel.md). `ask`: the person is
|
|
234
|
+
* asked, before it first opens in a session, whether to start it. Unset: ask.
|
|
232
235
|
*/
|
|
233
|
-
sentinel?:
|
|
236
|
+
sentinel?: SentinelSetting;
|
|
234
237
|
/** Path from the root to the unit testing script. */
|
|
235
238
|
unittestsScript?: string;
|
|
236
239
|
/** Path from the root to the conformance testing script. */
|
package/dist/config.js
CHANGED
|
@@ -25,7 +25,7 @@ import { projectDirOf } from "./regenerate/paths.js";
|
|
|
25
25
|
* mutation-testing: false # give the coding agent and the reviewer can_it_fail (default: true)
|
|
26
26
|
* allow-to-stop-jobs: false # stop a running job the spec moved on from (default: true)
|
|
27
27
|
* release-after-pulse: true # release what each pulse lands, as its last stage, before the next pulse (default: false)
|
|
28
|
-
* sentinel:
|
|
28
|
+
* sentinel: true # a chat of its own every finished pulse goes to, which tests the release and tells you what is wrong: true, false, or ask - the person asked before it first opens in a session (default: ask)
|
|
29
29
|
* rules-off: [integrations] # ***plain rules to leave out of the prompts, by name (default: none)
|
|
30
30
|
* unittests-script: test_scripts/run_unittests.sh # the testing scripts - see test-scripts.ts
|
|
31
31
|
* conformance-tests-script: test_scripts/run_conformance_tests.sh
|
|
@@ -561,7 +561,7 @@ function readConfigFile(path) {
|
|
|
561
561
|
mutationTesting: typeof raw[CONFIG_KEYS.mutationTesting] === "boolean" ? raw[CONFIG_KEYS.mutationTesting] : undefined,
|
|
562
562
|
allowToStopJobs: typeof raw[CONFIG_KEYS.allowToStopJobs] === "boolean" ? raw[CONFIG_KEYS.allowToStopJobs] : undefined,
|
|
563
563
|
releaseAfterPulse: typeof raw[CONFIG_KEYS.releaseAfterPulse] === "boolean" ? raw[CONFIG_KEYS.releaseAfterPulse] : undefined,
|
|
564
|
-
sentinel: typeof raw[CONFIG_KEYS.sentinel] === "boolean" ? raw[CONFIG_KEYS.sentinel] : undefined,
|
|
564
|
+
sentinel: typeof raw[CONFIG_KEYS.sentinel] === "boolean" || raw[CONFIG_KEYS.sentinel] === "ask" ? raw[CONFIG_KEYS.sentinel] : undefined,
|
|
565
565
|
unittestsScript: text(raw[CONFIG_KEYS.unittestsScript]),
|
|
566
566
|
conformanceTestsScript: text(raw[CONFIG_KEYS.conformanceTestsScript]),
|
|
567
567
|
prepareEnvironmentScript: text(raw[CONFIG_KEYS.prepareEnvironmentScript]),
|