@turri-dev/cli 0.1.19 → 0.1.20
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/chat-commands.js +2 -1
- package/dist/chat-records.d.ts +2 -0
- package/dist/chat-terminal.d.ts +1 -0
- package/dist/chat-terminal.js +1 -1
- package/dist/chats.d.ts +60 -3
- package/dist/chats.js +201 -14
- package/dist/config-edit.js +2 -0
- package/dist/config.d.ts +8 -1
- package/dist/config.js +5 -2
- package/dist/extension.js +57 -1
- package/dist/host-session.d.ts +11 -0
- package/dist/host-session.js +2 -0
- package/dist/host.d.ts +1 -0
- package/dist/host.js +2 -0
- package/dist/identity.js +2 -2
- package/dist/init-config.d.ts +1 -0
- package/dist/init-config.js +6 -0
- package/dist/notice.d.ts +59 -0
- package/dist/notice.js +38 -0
- package/dist/pi-launch.js +9 -2
- package/dist/regenerate/agent-shell.d.ts +5 -0
- package/dist/regenerate/agent-shell.js +2 -2
- package/dist/regenerate/audit.js +1 -1
- package/dist/regenerate/codebase.js +1 -1
- package/dist/regenerate/command-check.js +1 -1
- package/dist/regenerate/confine.d.ts +2 -0
- package/dist/regenerate/confine.js +2 -2
- package/dist/regenerate/ledger.d.ts +14 -0
- package/dist/regenerate/paths.d.ts +2 -0
- package/dist/regenerate/paths.js +2 -0
- package/dist/regenerate/records.d.ts +1 -1
- package/dist/regenerate/records.js +3 -1
- package/dist/regenerate/release.js +1 -1
- package/dist/regenerate/testing.js +1 -1
- package/dist/regenerate/workspace.js +1 -1
- package/dist/sentinel-chat.d.ts +14 -0
- package/dist/sentinel-chat.js +178 -0
- package/dist/sentinel.d.ts +55 -0
- package/dist/sentinel.js +279 -0
- package/docs/README.md +2 -0
- package/docs/chats.md +4 -3
- package/docs/config.md +3 -1
- package/docs/deploy.md +2 -2
- package/docs/development.md +1 -0
- package/docs/hosting.md +18 -3
- package/docs/sentinel.md +54 -0
- package/docs/using.md +1 -0
- package/genome/rules/turri-regeneration.md +1 -0
- package/package.json +1 -1
package/dist/chat-commands.js
CHANGED
|
@@ -88,7 +88,8 @@ function closeChat(ctx, session, which) {
|
|
|
88
88
|
const chat = chatNamed(session, which);
|
|
89
89
|
if (typeof chat === "string")
|
|
90
90
|
return ctx.ui.notify(chat, "warning");
|
|
91
|
-
|
|
91
|
+
// The sentinel is not the person's: the last chat of theirs is not closed.
|
|
92
|
+
if (chat.role === undefined && session.chats().filter((other) => other.role === undefined).length === 1)
|
|
92
93
|
return ctx.ui.notify("This is the only chat - /quit leaves turri.", "info");
|
|
93
94
|
later(ctx, session, () => session.close(chat.id));
|
|
94
95
|
}
|
package/dist/chat-records.d.ts
CHANGED
package/dist/chat-terminal.d.ts
CHANGED
|
@@ -20,4 +20,5 @@ export interface CtrlCScreen {
|
|
|
20
20
|
export declare function pressCtrlC(screen: CtrlCScreen, lastStop: number, now?: number): "stopped" | "ignored" | "cleared" | "left";
|
|
21
21
|
export declare function driveTerminal(core: TurriCore, options?: {
|
|
22
22
|
restore?: RunRecord;
|
|
23
|
+
skipFirst?: boolean;
|
|
23
24
|
}): void;
|
package/dist/chat-terminal.js
CHANGED
|
@@ -42,7 +42,7 @@ export function driveTerminal(core, options = {}) {
|
|
|
42
42
|
if (restore !== undefined) {
|
|
43
43
|
const saved = restore;
|
|
44
44
|
restore = undefined;
|
|
45
|
-
void core.restore(saved, { skipFirst:
|
|
45
|
+
void core.restore(saved, { skipFirst: options.skipFirst !== false });
|
|
46
46
|
}
|
|
47
47
|
};
|
|
48
48
|
/**
|
package/dist/chats.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { type RunRecord } from "./chat-records.ts";
|
|
|
4
4
|
import { type ChatUi } from "./chat-ui.ts";
|
|
5
5
|
import type { AskUserIO } from "./ask.ts";
|
|
6
6
|
import type { Job, JobRegistry } from "./regenerate/jobs.ts";
|
|
7
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { type ChatRole, type Conclusion, type Notice, type NoticeAnswer, type NoticeInput } from "./notice.ts";
|
|
7
9
|
/**
|
|
8
10
|
* The chats of a turri session.
|
|
9
11
|
*
|
|
@@ -18,6 +20,10 @@ import type { Job, JobRegistry } from "./regenerate/jobs.ts";
|
|
|
18
20
|
*
|
|
19
21
|
* Where the chats live is a driver's: the terminal's (chat-terminal.ts) swaps which one pi's screen shows; a host's
|
|
20
22
|
* (`createTurri`) makes a session for each and shows them in its own windows.
|
|
23
|
+
*
|
|
24
|
+
* With the project's sentinel on (sentinel.ts), every finished pulse goes to the sentinel - a chat of the session's own,
|
|
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. What the sentinel finds wrong it puts to the person as a notice, in the chat they look at.
|
|
21
27
|
*/
|
|
22
28
|
/** The most chats a session has open at once. */
|
|
23
29
|
export declare const MAX_CHATS = 9;
|
|
@@ -31,6 +37,8 @@ export interface ChatInfo {
|
|
|
31
37
|
status: ChatStatus;
|
|
32
38
|
/** On the screen now - the terminal's, or the window the host said it shows (`focus`). */
|
|
33
39
|
shown: boolean;
|
|
40
|
+
/** `sentinel` for the session's sentinel; none for a chat of the person's. */
|
|
41
|
+
role?: ChatRole;
|
|
34
42
|
}
|
|
35
43
|
/** What turri tells and asks a host that runs its conversation itself. */
|
|
36
44
|
export interface TurriHost {
|
|
@@ -51,11 +59,44 @@ export interface TurriHost {
|
|
|
51
59
|
* work: what it returns is what is sent. For a host that needs to change anything else.
|
|
52
60
|
*/
|
|
53
61
|
systemPrompt?(prompt: string): string;
|
|
62
|
+
/** The sentinel is done with a pulse - told to the chat it names, whose agent says so. */
|
|
63
|
+
onConcluded?(conclusion: Conclusion & {
|
|
64
|
+
chat: string;
|
|
65
|
+
}): void;
|
|
66
|
+
/** What the sentinel puts to the person: the host draws it in the chat it names, and answers it with `answer`. */
|
|
67
|
+
onNotice?(notice: Notice): void;
|
|
68
|
+
/** A notice is over - answered, dismissed, or superseded by a newer one about its module: the host takes its card down. */
|
|
69
|
+
onNoticeOver?(notice: Notice): void;
|
|
70
|
+
/** The sentinel's own tools beside turri's: a host's browser, say. */
|
|
71
|
+
sentinel?: {
|
|
72
|
+
tools?: ToolDefinition[];
|
|
73
|
+
};
|
|
54
74
|
}
|
|
55
75
|
/** What a chat's extension does for its chat when the session asks. */
|
|
56
76
|
export interface ChatInstance {
|
|
57
|
-
/** A pulse it asked for ended: said in the chat, and its agent told. */
|
|
77
|
+
/** A pulse it asked for ended: said in the chat, and its agent told, to react to it. */
|
|
58
78
|
announce(job: Job): void;
|
|
79
|
+
/**
|
|
80
|
+
* A pulse ended, with the sentinel on: said in the chat - and, with `tell`, its agent told it landed and what the
|
|
81
|
+
* sentinel does with it, to tell the person and do nothing more.
|
|
82
|
+
*/
|
|
83
|
+
landed(job: Job, options?: {
|
|
84
|
+
tell?: boolean;
|
|
85
|
+
}): void;
|
|
86
|
+
/** The sentinel's: a pulse ended - its agent told, at once, or after what it does now; `replace`, what it does now stopped. */
|
|
87
|
+
brief(job: Job, context: {
|
|
88
|
+
asked?: string;
|
|
89
|
+
focused?: string;
|
|
90
|
+
replace: boolean;
|
|
91
|
+
}): Promise<void>;
|
|
92
|
+
/** A notice put in the chat: drawn, and - in the terminal - put to the person once the chat is idle. */
|
|
93
|
+
notice(notice: Notice): void;
|
|
94
|
+
/** A notice is over: its dialog closed, if it is open. */
|
|
95
|
+
noticeOver(notice: Notice): void;
|
|
96
|
+
/** The sentinel is done with a pulse: said in the chat, and its agent told, to tell the person and do nothing more. */
|
|
97
|
+
concluded(conclusion: Conclusion): void;
|
|
98
|
+
/** The person answered a notice with something to do: the chat's agent started on it. */
|
|
99
|
+
act(task: string): void;
|
|
59
100
|
/** The jobs changed: what it draws of them is drawn again. */
|
|
60
101
|
jobsChanged(): void;
|
|
61
102
|
/** How hard the chat's model thinks now: sub-agents started from it think as hard. */
|
|
@@ -69,6 +110,8 @@ export interface ChatLink {
|
|
|
69
110
|
readonly scope: ChatScope;
|
|
70
111
|
/** The host's dialogs, for this chat's questions - undefined in the terminal. */
|
|
71
112
|
readonly dialogs: AskUserIO | undefined;
|
|
113
|
+
/** `sentinel` for the session's sentinel. */
|
|
114
|
+
readonly role: ChatRole | undefined;
|
|
72
115
|
/** Its pi session, once it has one. */
|
|
73
116
|
session(): AgentSession | undefined;
|
|
74
117
|
/** The extension built for the chat - again, when pi rebuilds it - and what it does for it. */
|
|
@@ -89,6 +132,7 @@ export interface ChatDriver {
|
|
|
89
132
|
export interface ChatState {
|
|
90
133
|
id: string;
|
|
91
134
|
scope: ChatScope;
|
|
135
|
+
role?: ChatRole;
|
|
92
136
|
/** Its title as recorded, until it says one of its own. */
|
|
93
137
|
recordedTitle?: string;
|
|
94
138
|
session?: AgentSession;
|
|
@@ -119,11 +163,12 @@ export interface TurriCore {
|
|
|
119
163
|
decline(id: string): "declined" | "none" | "missing";
|
|
120
164
|
/** The chat shown now. */
|
|
121
165
|
shown(): string | undefined;
|
|
122
|
-
/** Open a chat - fresh, or the saved one `sessionFile` is - shown at once unless `hidden`. */
|
|
166
|
+
/** Open a chat - fresh, or the saved one `sessionFile` is - shown at once unless `hidden`; the sentinel with `role`. */
|
|
123
167
|
open(options?: {
|
|
124
168
|
sessionFile?: string;
|
|
125
169
|
hidden?: boolean;
|
|
126
170
|
title?: string;
|
|
171
|
+
role?: ChatRole;
|
|
127
172
|
}): Promise<ChatInfo>;
|
|
128
173
|
close(id: string): Promise<void>;
|
|
129
174
|
/** Show the chat - in the terminal, put it on the screen; for a host, the one its person looks at now. */
|
|
@@ -147,8 +192,18 @@ export interface TurriCore {
|
|
|
147
192
|
adopt(session: AgentSession, services: unknown): ChatState;
|
|
148
193
|
/** For a driver: `chat` is on the screen now. */
|
|
149
194
|
showing(chat: ChatState): void;
|
|
150
|
-
/** For the extension: a pulse ended - told to the chat that asked for it. */
|
|
195
|
+
/** For the extension: a pulse ended - told to the chat that asked for it, or, with the sentinel on, to the sentinel. */
|
|
151
196
|
jobFinished(job: Job): void;
|
|
197
|
+
/** Whether finished pulses go to the sentinel now: it is on, and there is somewhere to open it. */
|
|
198
|
+
sentinelOn(): boolean;
|
|
199
|
+
/** A notice put to the person, in the chat they look at - the sentinel's, or turri's own. Undefined: no chat to put it in. */
|
|
200
|
+
notice(input: NoticeInput): Notice | undefined;
|
|
201
|
+
/** The sentinel done with a pulse: told to the chat the person looks at. Undefined: no chat to tell. */
|
|
202
|
+
concluded(conclusion: Conclusion): string | undefined;
|
|
203
|
+
/** The notices put to the person and not yet answered. */
|
|
204
|
+
notices(): Notice[];
|
|
205
|
+
/** A notice answered: what it asked done in the chat it is in, or dismissed. */
|
|
206
|
+
answer(id: string, answer: NoticeAnswer): "done" | "dismissed" | "missing" | "over";
|
|
152
207
|
/** For the extension: the jobs changed - every chat draws them again. */
|
|
153
208
|
jobsChanged(): void;
|
|
154
209
|
/** The chat whose work this is, else the one shown: its instance. */
|
|
@@ -169,6 +224,8 @@ export interface CoreOptions {
|
|
|
169
224
|
persist?: boolean;
|
|
170
225
|
/** The host's dialogs, a chat's own. */
|
|
171
226
|
dialogs?: (chat: string) => AskUserIO | undefined;
|
|
227
|
+
/** Whether the project wants a sentinel - read as each pulse finishes. Unset: none. */
|
|
228
|
+
sentinel?: () => boolean;
|
|
172
229
|
}
|
|
173
230
|
/** A chat's session, as stopping what it does reaches it. */
|
|
174
231
|
export type Stoppable = Pick<AgentSession, "isStreaming" | "isCompacting" | "isRetrying" | "isBashRunning" | "abort" | "abortBash">;
|
package/dist/chats.js
CHANGED
|
@@ -5,6 +5,7 @@ import { adoptRun, forgetChat, 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
9
|
/**
|
|
9
10
|
* The chats of a turri session.
|
|
10
11
|
*
|
|
@@ -19,6 +20,10 @@ import { record } from "./regenerate/ledger.js";
|
|
|
19
20
|
*
|
|
20
21
|
* Where the chats live is a driver's: the terminal's (chat-terminal.ts) swaps which one pi's screen shows; a host's
|
|
21
22
|
* (`createTurri`) makes a session for each and shows them in its own windows.
|
|
23
|
+
*
|
|
24
|
+
* With the project's sentinel on (sentinel.ts), every finished pulse goes to the sentinel - a chat of the session's own,
|
|
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. What the sentinel finds wrong it puts to the person as a notice, in the chat they look at.
|
|
22
27
|
*/
|
|
23
28
|
/** The most chats a session has open at once. */
|
|
24
29
|
export const MAX_CHATS = 9;
|
|
@@ -81,6 +86,10 @@ export function createChats(options) {
|
|
|
81
86
|
/** Chats are opened one at a time: each one's extension is built while it is the one attaching. */
|
|
82
87
|
let opening = Promise.resolve();
|
|
83
88
|
let notices = [];
|
|
89
|
+
/** What the sentinel put to the person, and turri itself, this session: open until answered or superseded. */
|
|
90
|
+
const open = [];
|
|
91
|
+
/** The pulse the sentinel is on now, while its agent works: a newer one of the same module replaces a check of one that went well. */
|
|
92
|
+
let sentinelOnJob;
|
|
84
93
|
const run = { run: randomUUID().slice(0, 8), pid: process.pid, started: new Date().toISOString(), chats: [] };
|
|
85
94
|
const changed = () => {
|
|
86
95
|
for (const listener of listeners) {
|
|
@@ -93,24 +102,44 @@ export function createChats(options) {
|
|
|
93
102
|
}
|
|
94
103
|
};
|
|
95
104
|
/** A chat nothing was said in yet is a new chat: its number is beside it already, in the strip and the picker. */
|
|
96
|
-
const titleFor = (chat) => titleOf(chat.session) ?? chat.recordedTitle ?? NEW_CHAT_TITLE;
|
|
105
|
+
const titleFor = (chat) => (chat.role === SENTINEL ? SENTINEL : (titleOf(chat.session) ?? chat.recordedTitle ?? NEW_CHAT_TITLE));
|
|
106
|
+
const sentinelChat = () => chats.find((chat) => chat.role === SENTINEL);
|
|
107
|
+
/** The person's chats: all but the sentinel. */
|
|
108
|
+
const people = () => chats.filter((chat) => chat.role !== SENTINEL);
|
|
109
|
+
const wantsSentinel = () => {
|
|
110
|
+
try {
|
|
111
|
+
return options.sentinel?.() === true;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
/** The chat the person looks at, for what is put to them - never the sentinel: when it is shown, `fallback`, else their first. */
|
|
118
|
+
const personFacing = (fallback) => {
|
|
119
|
+
const shown = chats.find((chat) => chat.id === shownId);
|
|
120
|
+
if (shown !== undefined && shown.role !== SENTINEL && shown.instance !== undefined)
|
|
121
|
+
return shown;
|
|
122
|
+
return fallback?.instance !== undefined && fallback.role !== SENTINEL ? fallback : people().find((chat) => chat.instance !== undefined);
|
|
123
|
+
};
|
|
97
124
|
const statusOf = (chat) => (chat.ui?.waiting() ?? 0) > 0 ? "question" : chat.working ? "working" : chat.unread ? "unread" : "idle";
|
|
98
125
|
/** What the session keeps of its chats on disk: those with something said in them, in order. */
|
|
99
126
|
const persist = () => {
|
|
100
127
|
if (options.persist !== true)
|
|
101
128
|
return;
|
|
102
|
-
|
|
129
|
+
// The sentinel last: `--continue` opens the first as pi's own chat, and that is the person's.
|
|
130
|
+
run.chats = [...people(), ...chats.filter((chat) => chat.role === SENTINEL)].flatMap((chat) => {
|
|
103
131
|
const file = chat.session?.sessionFile;
|
|
104
|
-
return file !== undefined && existsSync(file) ? [{ id: chat.id, title: titleFor(chat), sessionFile: file }] : [];
|
|
132
|
+
return file !== undefined && existsSync(file) ? [{ id: chat.id, title: titleFor(chat), sessionFile: file, ...(chat.role !== undefined ? { role: chat.role } : {}) }] : [];
|
|
105
133
|
});
|
|
106
134
|
recordRun(cwd, run, { keep: debugMode(cwd) });
|
|
107
135
|
};
|
|
108
|
-
const newChat = (title) => {
|
|
136
|
+
const newChat = (title, role) => {
|
|
109
137
|
const id = randomUUID().slice(0, 8);
|
|
110
138
|
const chat = {
|
|
111
139
|
id,
|
|
112
140
|
scope: { id, stack: process.env.TURRI_STACK?.trim() || undefined, asking: new Set() },
|
|
113
141
|
...(title !== undefined ? { recordedTitle: title } : {}),
|
|
142
|
+
...(role !== undefined ? { role } : {}),
|
|
114
143
|
working: false,
|
|
115
144
|
unread: false,
|
|
116
145
|
};
|
|
@@ -131,6 +160,8 @@ export function createChats(options) {
|
|
|
131
160
|
}
|
|
132
161
|
else if (event.type === "agent_end") {
|
|
133
162
|
chat.working = false;
|
|
163
|
+
if (chat.role === SENTINEL)
|
|
164
|
+
sentinelOnJob = undefined;
|
|
134
165
|
if (shownId !== chat.id)
|
|
135
166
|
chat.unread = true;
|
|
136
167
|
persist();
|
|
@@ -153,7 +184,7 @@ export function createChats(options) {
|
|
|
153
184
|
get terminal() {
|
|
154
185
|
return driver?.terminal === true;
|
|
155
186
|
},
|
|
156
|
-
chats: () => chats.map((chat) => ({ id: chat.id, title: titleFor(chat), status: statusOf(chat), shown: chat.id === shownId })),
|
|
187
|
+
chats: () => chats.map((chat) => ({ id: chat.id, title: titleFor(chat), status: statusOf(chat), shown: chat.id === shownId, ...(chat.role !== undefined ? { role: chat.role } : {}) })),
|
|
157
188
|
session: (id) => chats.find((chat) => chat.id === id)?.session,
|
|
158
189
|
decline(id) {
|
|
159
190
|
const chat = chats.find((candidate) => candidate.id === id);
|
|
@@ -171,12 +202,16 @@ export function createChats(options) {
|
|
|
171
202
|
if (driver === undefined)
|
|
172
203
|
throw new Error("This turri has one chat: there is nowhere to open another.");
|
|
173
204
|
const done = opening.then(async () => {
|
|
174
|
-
const already = asked.sessionFile !== undefined ? chats.find((chat) => chat.session?.sessionFile === asked.sessionFile) : undefined;
|
|
205
|
+
const already = asked.sessionFile !== undefined ? chats.find((chat) => chat.session?.sessionFile === asked.sessionFile) : asked.role === SENTINEL ? sentinelChat() : undefined;
|
|
175
206
|
if (already !== undefined)
|
|
176
207
|
return already;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
208
|
+
// The sentinel has a slot of its own: with it on, the person's chats are one fewer.
|
|
209
|
+
const room = asked.role === SENTINEL ? MAX_CHATS - people().length : (wantsSentinel() ? MAX_CHATS - 1 : MAX_CHATS) - people().length;
|
|
210
|
+
if (room <= 0) {
|
|
211
|
+
const most = wantsSentinel() ? MAX_CHATS - 1 : MAX_CHATS;
|
|
212
|
+
throw new Error(`${most} chats are open, the most there can be${wantsSentinel() ? " beside the sentinel" : ""}: close one first.`);
|
|
213
|
+
}
|
|
214
|
+
const chat = newChat(asked.title, asked.role);
|
|
180
215
|
attaching = chat;
|
|
181
216
|
try {
|
|
182
217
|
await driver.create(chat, asked.sessionFile);
|
|
@@ -189,7 +224,7 @@ export function createChats(options) {
|
|
|
189
224
|
attaching = undefined;
|
|
190
225
|
}
|
|
191
226
|
follow(chat);
|
|
192
|
-
inChat(chat.scope, () => record({ kind: "chat.opened", reopened: asked.sessionFile !== undefined }, { project: cwd }));
|
|
227
|
+
inChat(chat.scope, () => record({ kind: "chat.opened", reopened: asked.sessionFile !== undefined, ...(chat.role !== undefined ? { role: chat.role } : {}) }, { project: cwd }));
|
|
193
228
|
persist();
|
|
194
229
|
changed();
|
|
195
230
|
return chat;
|
|
@@ -206,7 +241,8 @@ export function createChats(options) {
|
|
|
206
241
|
return;
|
|
207
242
|
const chat = chats[index];
|
|
208
243
|
if (shownId === id) {
|
|
209
|
-
const
|
|
244
|
+
const others = chats.filter((other) => other !== chat);
|
|
245
|
+
const next = others.find((other, at) => other.role !== SENTINEL && at >= index) ?? [...others].reverse().find((other) => other.role !== SENTINEL) ?? others[0];
|
|
210
246
|
if (next !== undefined)
|
|
211
247
|
await core.show(next.id);
|
|
212
248
|
else
|
|
@@ -287,6 +323,7 @@ export function createChats(options) {
|
|
|
287
323
|
id: chat.id,
|
|
288
324
|
scope: chat.scope,
|
|
289
325
|
dialogs: chat.dialogs,
|
|
326
|
+
role: chat.role,
|
|
290
327
|
session: () => chat.session,
|
|
291
328
|
live: (instance) => {
|
|
292
329
|
chat.instance = instance;
|
|
@@ -304,7 +341,12 @@ export function createChats(options) {
|
|
|
304
341
|
}
|
|
305
342
|
return chat;
|
|
306
343
|
},
|
|
344
|
+
sentinelOn: () => driver !== undefined && wantsSentinel(),
|
|
307
345
|
jobFinished(job) {
|
|
346
|
+
if (core.sentinelOn()) {
|
|
347
|
+
sentinelFinished(job);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
308
350
|
const asked = chats.find((chat) => chat.id === job.chat);
|
|
309
351
|
const chat = asked?.instance !== undefined ? asked : (core.shownChat() ?? chats[0]);
|
|
310
352
|
if (chat === undefined)
|
|
@@ -332,6 +374,77 @@ export function createChats(options) {
|
|
|
332
374
|
}
|
|
333
375
|
}
|
|
334
376
|
},
|
|
377
|
+
notice(input) {
|
|
378
|
+
const asked = chats.find((chat) => chat.id === (input.job !== undefined ? jobs.get(input.job)?.chat : undefined));
|
|
379
|
+
const target = personFacing(asked);
|
|
380
|
+
if (target === undefined)
|
|
381
|
+
return undefined;
|
|
382
|
+
// One notice open a module: a newer one makes the older one's call to action stale.
|
|
383
|
+
if (input.module !== undefined) {
|
|
384
|
+
for (const older of open.filter((notice) => notice.state === "open" && notice.module === input.module))
|
|
385
|
+
over(older, "superseded");
|
|
386
|
+
}
|
|
387
|
+
const notice = newNotice(input, target.id);
|
|
388
|
+
open.push(notice);
|
|
389
|
+
if (target.id !== shownId)
|
|
390
|
+
target.unread = true;
|
|
391
|
+
inChat(target.scope, () => record({ kind: "notice.put", notice: notice.id, severity: notice.severity, ...(notice.module !== undefined ? { module: notice.module } : {}) }, { project: cwd }));
|
|
392
|
+
try {
|
|
393
|
+
target.instance?.notice(notice);
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
/* a chat let go as it was put: the host still hears it */
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
host.onNotice?.(notice);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
/* a host that throws must not lose the notice */
|
|
403
|
+
}
|
|
404
|
+
changed();
|
|
405
|
+
return notice;
|
|
406
|
+
},
|
|
407
|
+
notices: () => open.filter((notice) => notice.state === "open"),
|
|
408
|
+
concluded(conclusion) {
|
|
409
|
+
const job = jobs.get(conclusion.job);
|
|
410
|
+
const target = personFacing(chats.find((chat) => chat.id === job?.chat));
|
|
411
|
+
if (target === undefined)
|
|
412
|
+
return undefined;
|
|
413
|
+
const module = conclusion.module ?? job?.module;
|
|
414
|
+
const pulse = conclusion.pulse ?? job?.pulse;
|
|
415
|
+
const told = { ...conclusion, ...(module !== undefined ? { module } : {}), ...(pulse !== undefined ? { pulse } : {}) };
|
|
416
|
+
if (target.id !== shownId)
|
|
417
|
+
target.unread = true;
|
|
418
|
+
inChat(target.scope, () => record({ kind: "sentinel.concluded", verdict: told.verdict, ...(module !== undefined ? { module } : {}) }, { project: cwd, job: told.job }));
|
|
419
|
+
try {
|
|
420
|
+
target.instance?.concluded(told);
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
/* a chat let go as it was told: the host still hears it */
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
host.onConcluded?.({ ...told, chat: target.id });
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
/* a host that throws must not lose it */
|
|
430
|
+
}
|
|
431
|
+
changed();
|
|
432
|
+
return target.id;
|
|
433
|
+
},
|
|
434
|
+
answer(id, answer) {
|
|
435
|
+
const notice = open.find((candidate) => candidate.id === id);
|
|
436
|
+
if (notice === undefined)
|
|
437
|
+
return "missing";
|
|
438
|
+
if (notice.state !== "open")
|
|
439
|
+
return "over";
|
|
440
|
+
const task = noticeTask(notice, answer);
|
|
441
|
+
over(notice, "dismiss" in answer ? "dismissed" : "steer" in answer ? "steered" : "accepted");
|
|
442
|
+
if (task === undefined)
|
|
443
|
+
return "dismissed";
|
|
444
|
+
const chat = chats.find((candidate) => candidate.id === notice.chat) ?? personFacing();
|
|
445
|
+
chat?.instance?.act(task);
|
|
446
|
+
return "done";
|
|
447
|
+
},
|
|
335
448
|
instanceInScope() {
|
|
336
449
|
const scoped = currentChat();
|
|
337
450
|
return (chats.find((chat) => chat.id === scoped?.id) ?? core.shownChat() ?? chats[0])?.instance;
|
|
@@ -341,15 +454,16 @@ export function createChats(options) {
|
|
|
341
454
|
},
|
|
342
455
|
async restore(saved, asked = {}) {
|
|
343
456
|
adoptRun(cwd, saved.run);
|
|
344
|
-
|
|
457
|
+
// The first of the person's chats is the one pi reopened itself (pi-launch.ts): the sentinel is never it.
|
|
458
|
+
const first = saved.chats.find((kept) => kept.role === undefined);
|
|
345
459
|
if (first !== undefined && asked.skipFirst === true) {
|
|
346
460
|
const chat = chats[0];
|
|
347
461
|
if (chat !== undefined)
|
|
348
462
|
chat.recordedTitle ??= first.title;
|
|
349
463
|
}
|
|
350
|
-
for (const kept of asked.skipFirst === true ?
|
|
464
|
+
for (const kept of asked.skipFirst === true ? saved.chats.filter((other) => other !== first) : saved.chats) {
|
|
351
465
|
try {
|
|
352
|
-
await core.open({ sessionFile: kept.sessionFile, hidden: true, title: kept.title });
|
|
466
|
+
await core.open({ sessionFile: kept.sessionFile, hidden: true, title: kept.title, ...(kept.role !== undefined ? { role: kept.role } : {}) });
|
|
353
467
|
}
|
|
354
468
|
catch (error) {
|
|
355
469
|
core.notify(`The chat "${kept.title}" could not be reopened: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
@@ -358,6 +472,79 @@ export function createChats(options) {
|
|
|
358
472
|
persist();
|
|
359
473
|
},
|
|
360
474
|
};
|
|
475
|
+
/** A notice ended - answered, dismissed, superseded: its dialog closed, and the host told. */
|
|
476
|
+
function over(notice, state) {
|
|
477
|
+
notice.state = state;
|
|
478
|
+
const chat = chats.find((candidate) => candidate.id === notice.chat);
|
|
479
|
+
try {
|
|
480
|
+
chat?.instance?.noticeOver(notice);
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
/* a chat let go has no dialog to close */
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
host.onNoticeOver?.(notice);
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
/* a host that throws must not keep the notice open */
|
|
490
|
+
}
|
|
491
|
+
if (chat !== undefined)
|
|
492
|
+
inChat(chat.scope, () => record({ kind: "notice.over", notice: notice.id, state }, { project: cwd }));
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* A pulse ended, with the sentinel on: the chat the person looks at says so - its agent told of one that landed, to
|
|
496
|
+
* 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 told, opened in the background when it is not open. A newer pulse of a module replaces a check of the
|
|
498
|
+
* module's last pulse that went well; one that went wrong is looked into to its end.
|
|
499
|
+
*/
|
|
500
|
+
function sentinelFinished(job) {
|
|
501
|
+
const asked = chats.find((chat) => chat.id === job.chat && chat.role !== SENTINEL);
|
|
502
|
+
const where = personFacing(asked);
|
|
503
|
+
if (where !== undefined) {
|
|
504
|
+
if (where.id !== shownId)
|
|
505
|
+
where.unread = true;
|
|
506
|
+
try {
|
|
507
|
+
where.instance?.landed(job, { tell: !wentWrong(job) });
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
/* a chat let go as the pulse ended: the sentinel still hears of it */
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
changed();
|
|
514
|
+
void (async () => {
|
|
515
|
+
let sentinel = sentinelChat();
|
|
516
|
+
if (sentinel === undefined) {
|
|
517
|
+
try {
|
|
518
|
+
await core.open({ hidden: true, role: SENTINEL });
|
|
519
|
+
sentinel = sentinelChat();
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
// No room for it, or nowhere to open it: the pulse is told as it is without one.
|
|
523
|
+
core.notify(`The sentinel could not be opened: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
524
|
+
(asked ?? where)?.instance?.announce(job);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (sentinel?.instance === undefined)
|
|
529
|
+
return;
|
|
530
|
+
const busy = sentinel.session?.isStreaming === true;
|
|
531
|
+
const replace = busy && sentinelOnJob !== undefined && !sentinelOnJob.wrong && sentinelOnJob.module !== undefined && sentinelOnJob.module === job.module;
|
|
532
|
+
sentinelOnJob = { module: job.module, wrong: wentWrong(job) };
|
|
533
|
+
if (sentinel.id !== shownId)
|
|
534
|
+
sentinel.unread = true;
|
|
535
|
+
try {
|
|
536
|
+
await sentinel.instance.brief(job, {
|
|
537
|
+
...(asked !== undefined ? { asked: titleFor(asked) } : {}),
|
|
538
|
+
...(where !== undefined ? { focused: titleFor(where) } : {}),
|
|
539
|
+
replace,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
core.notify(`The sentinel could not be told of ${job.id}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
544
|
+
}
|
|
545
|
+
changed();
|
|
546
|
+
})();
|
|
547
|
+
}
|
|
361
548
|
return core;
|
|
362
549
|
}
|
|
363
550
|
/**
|
package/dist/config-edit.js
CHANGED
|
@@ -20,6 +20,7 @@ const SECONDS = ["0", "60", "120", "180", "300"];
|
|
|
20
20
|
const ROLE_ABOUT = {
|
|
21
21
|
default: "every role not named",
|
|
22
22
|
chat: "the conversation - as harness-model, which it wins over",
|
|
23
|
+
sentinel: "the sentinel: checks every finished pulse and tests the release",
|
|
23
24
|
chooser: "chooses what gets conformance tests under turri regenerate",
|
|
24
25
|
planner: "cuts the work into pulses",
|
|
25
26
|
diagnosis: "looks into a pulse that failed for good",
|
|
@@ -65,6 +66,7 @@ export function settings(rules = []) {
|
|
|
65
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" },
|
|
66
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" },
|
|
67
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: "boolean", unset: "true", effect: "next-landing" },
|
|
68
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" },
|
|
69
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" },
|
|
70
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" },
|
package/dist/config.d.ts
CHANGED
|
@@ -19,6 +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: false # a chat of its own every finished pulse goes to, which tests the release and tells you what is wrong (default: true)
|
|
22
23
|
* rules-off: [integrations] # ***plain rules to leave out of the prompts, by name (default: none)
|
|
23
24
|
* unittests-script: test_scripts/run_unittests.sh # the testing scripts - see test-scripts.ts
|
|
24
25
|
* conformance-tests-script: test_scripts/run_conformance_tests.sh
|
|
@@ -127,7 +128,7 @@ export interface ModelChoice {
|
|
|
127
128
|
* The code fixer of a conformance run and the repair agent are coding agents; the unit-test and conformance arbiters
|
|
128
129
|
* are both `arbiter`.
|
|
129
130
|
*/
|
|
130
|
-
export declare const AGENT_ROLES: readonly ["chat", "chooser", "planner", "diagnosis", "release", "coding", "conformance", "explorer", "reviewer", "test-reviewer", "judge", "arbiter", "debugger"];
|
|
131
|
+
export declare const AGENT_ROLES: readonly ["chat", "sentinel", "chooser", "planner", "diagnosis", "release", "coding", "conformance", "explorer", "reviewer", "test-reviewer", "judge", "arbiter", "debugger"];
|
|
131
132
|
export type AgentRole = (typeof AGENT_ROLES)[number];
|
|
132
133
|
/**
|
|
133
134
|
* How hard each role thinks when nothing names a level - not the config's `models:`, nor a `:level` on the model it is
|
|
@@ -225,6 +226,11 @@ export interface ProjectConfig {
|
|
|
225
226
|
* looking for the project's release guidance as the chat does, and doing what it says. Unset: false.
|
|
226
227
|
*/
|
|
227
228
|
releaseAfterPulse?: boolean;
|
|
229
|
+
/**
|
|
230
|
+
* 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). Unset: true.
|
|
232
|
+
*/
|
|
233
|
+
sentinel?: boolean;
|
|
228
234
|
/** Path from the root to the unit testing script. */
|
|
229
235
|
unittestsScript?: string;
|
|
230
236
|
/** Path from the root to the conformance testing script. */
|
|
@@ -300,6 +306,7 @@ export declare const CONFIG_KEYS: {
|
|
|
300
306
|
readonly mutationTesting: "mutation-testing";
|
|
301
307
|
readonly allowToStopJobs: "allow-to-stop-jobs";
|
|
302
308
|
readonly releaseAfterPulse: "release-after-pulse";
|
|
309
|
+
readonly sentinel: "sentinel";
|
|
303
310
|
readonly unittestsScript: "unittests-script";
|
|
304
311
|
readonly conformanceTestsScript: "conformance-tests-script";
|
|
305
312
|
readonly prepareEnvironmentScript: "prepare-environment-script";
|
package/dist/config.js
CHANGED
|
@@ -25,6 +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: false # a chat of its own every finished pulse goes to, which tests the release and tells you what is wrong (default: true)
|
|
28
29
|
* rules-off: [integrations] # ***plain rules to leave out of the prompts, by name (default: none)
|
|
29
30
|
* unittests-script: test_scripts/run_unittests.sh # the testing scripts - see test-scripts.ts
|
|
30
31
|
* conformance-tests-script: test_scripts/run_conformance_tests.sh
|
|
@@ -215,7 +216,7 @@ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhig
|
|
|
215
216
|
* The code fixer of a conformance run and the repair agent are coding agents; the unit-test and conformance arbiters
|
|
216
217
|
* are both `arbiter`.
|
|
217
218
|
*/
|
|
218
|
-
export const AGENT_ROLES = ["chat", "chooser", "planner", "diagnosis", "release", "coding", "conformance", "explorer", "reviewer", "test-reviewer", "judge", "arbiter", "debugger"];
|
|
219
|
+
export const AGENT_ROLES = ["chat", "sentinel", "chooser", "planner", "diagnosis", "release", "coding", "conformance", "explorer", "reviewer", "test-reviewer", "judge", "arbiter", "debugger"];
|
|
219
220
|
/**
|
|
220
221
|
* How hard each role thinks when nothing names a level - not the config's `models:`, nor a `:level` on the model it is
|
|
221
222
|
* given. The agents that do the work - write the code and the tests, explore, release - think little: they work from a
|
|
@@ -239,7 +240,7 @@ export const ROLE_THINKING = {
|
|
|
239
240
|
/** The roles on the conversation's side: without their own entry they take the chat's model, not `default`. */
|
|
240
241
|
// The release is not: it follows steps - a script, a start command - and runs on the coding model, thinking low. Nor
|
|
241
242
|
// is the diagnosis: a post-mortem of a job that failed, read by the person - it runs on the coding model, thinking medium.
|
|
242
|
-
const CHAT_SIDE = ["chat", "chooser", "planner"];
|
|
243
|
+
const CHAT_SIDE = ["chat", "sentinel", "chooser", "planner"];
|
|
243
244
|
function isThinkingLevel(value) {
|
|
244
245
|
return typeof value === "string" && THINKING_LEVELS.includes(value);
|
|
245
246
|
}
|
|
@@ -382,6 +383,7 @@ export const CONFIG_KEYS = {
|
|
|
382
383
|
mutationTesting: "mutation-testing",
|
|
383
384
|
allowToStopJobs: "allow-to-stop-jobs",
|
|
384
385
|
releaseAfterPulse: "release-after-pulse",
|
|
386
|
+
sentinel: "sentinel",
|
|
385
387
|
unittestsScript: "unittests-script",
|
|
386
388
|
conformanceTestsScript: "conformance-tests-script",
|
|
387
389
|
prepareEnvironmentScript: "prepare-environment-script",
|
|
@@ -559,6 +561,7 @@ function readConfigFile(path) {
|
|
|
559
561
|
mutationTesting: typeof raw[CONFIG_KEYS.mutationTesting] === "boolean" ? raw[CONFIG_KEYS.mutationTesting] : undefined,
|
|
560
562
|
allowToStopJobs: typeof raw[CONFIG_KEYS.allowToStopJobs] === "boolean" ? raw[CONFIG_KEYS.allowToStopJobs] : undefined,
|
|
561
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,
|
|
562
565
|
unittestsScript: text(raw[CONFIG_KEYS.unittestsScript]),
|
|
563
566
|
conformanceTestsScript: text(raw[CONFIG_KEYS.conformanceTestsScript]),
|
|
564
567
|
prepareEnvironmentScript: text(raw[CONFIG_KEYS.prepareEnvironmentScript]),
|