@turri-dev/cli 0.1.17-next.63f454c → 0.1.18-next.85984f7

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 CHANGED
@@ -36,8 +36,13 @@ export type AskUser = (request: AskUserRequest) => Promise<string>;
36
36
  */
37
37
  export interface AskUserIO {
38
38
  /** `at`: the option the cursor starts on - the one just ticked, in a question of several. */
39
- select(title: string, options: string[], context?: string, at?: number): Promise<string | undefined>;
40
- input(title: string, context?: string): Promise<string | undefined>;
39
+ select(title: string, options: string[], context?: string, at?: number, asked?: DialogAsked): Promise<string | undefined>;
40
+ input(title: string, context?: string, asked?: DialogAsked): Promise<string | undefined>;
41
+ }
42
+ /** What a host's dialog is told beside its question. */
43
+ export interface DialogAsked {
44
+ /** Aborted when the question is over without the dialog's answer - declined, or its turn stopped: the dialog is closed. */
45
+ signal: AbortSignal;
41
46
  }
42
47
  export declare const OTHER_OPTION = "Something else\u2026";
43
48
  export declare const CHAT_OPTION = "Chat about this";
@@ -7,6 +7,8 @@ export interface ChatScope {
7
7
  id: string;
8
8
  /** The stack this chat chose with /stack or the stack tool - none: the project's config alone. */
9
9
  stack: string | undefined;
10
+ /** The questions it asks now, each with what declines it: a host declines them with `Turri.decline`. */
11
+ readonly asking: Set<() => void>;
10
12
  }
11
13
  /** The chat this code runs for - undefined outside every chat: the command line, a test. */
12
14
  export declare function currentChat(): ChatScope | undefined;
package/dist/chats.d.ts CHANGED
@@ -113,6 +113,8 @@ export interface TurriCore {
113
113
  chats(): ChatInfo[];
114
114
  /** The chat's pi session. */
115
115
  session(id: string): AgentSession | undefined;
116
+ /** The questions the chat asks now declined, as ctrl+c at one declines it - `none` when it asks nothing. */
117
+ decline(id: string): "declined" | "none" | "missing";
116
118
  /** The chat shown now. */
117
119
  shown(): string | undefined;
118
120
  /** Open a chat - fresh, or the saved one `sessionFile` is - shown at once unless `hidden`. */
package/dist/chats.js CHANGED
@@ -109,7 +109,7 @@ export function createChats(options) {
109
109
  const id = randomUUID().slice(0, 8);
110
110
  const chat = {
111
111
  id,
112
- scope: { id, stack: process.env.TURRI_STACK?.trim() || undefined },
112
+ scope: { id, stack: process.env.TURRI_STACK?.trim() || undefined, asking: new Set() },
113
113
  ...(title !== undefined ? { recordedTitle: title } : {}),
114
114
  working: false,
115
115
  unread: false,
@@ -155,6 +155,15 @@ export function createChats(options) {
155
155
  },
156
156
  chats: () => chats.map((chat) => ({ id: chat.id, title: titleFor(chat), status: statusOf(chat), shown: chat.id === shownId })),
157
157
  session: (id) => chats.find((chat) => chat.id === id)?.session,
158
+ decline(id) {
159
+ const chat = chats.find((candidate) => candidate.id === id);
160
+ if (chat === undefined)
161
+ return "missing";
162
+ const asking = [...chat.scope.asking];
163
+ for (const decline of asking)
164
+ decline();
165
+ return asking.length > 0 ? "declined" : "none";
166
+ },
158
167
  shown: () => shownId,
159
168
  shownChat: () => chats.find((chat) => chat.id === shownId),
160
169
  chatOfSession: (session) => chats.find((chat) => chat.session === session),
package/dist/extension.js CHANGED
@@ -63,6 +63,7 @@ import { supersededByTurri } from "./chat-skills.js";
63
63
  import { conversationIdentity } from "./identity.js";
64
64
  import { setSurface, surfaceOf } from "./surface.js";
65
65
  import { createChats, scopedToChat } from "./chats.js";
66
+ import { currentChat } from "./chat-scope.js";
66
67
  import { registerChats } from "./chat-commands.js";
67
68
  import { turriIdentity } from "./environment.js";
68
69
  import { conformanceChooser } from "./regenerate/choose-conformance.js";
@@ -95,15 +96,9 @@ dialogs) {
95
96
  if (dialogs === undefined && !ctx.hasUI) {
96
97
  return "No user is available to answer. Decide yourself, and say in your report what you assumed.";
97
98
  }
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 });
99
+ // Declined - ctrl+c at it, the host's decline, or the turn that asked it stopped: the dialog open is closed, no other is
100
+ // opened, and it comes back as DECLINED.
101
+ const { declining, decline, done } = asking(ctx.signal);
107
102
  // The question has the keys: ctrl+h is caught here, or it would never reach the shortcut, which only the chat's editor sees.
108
103
  const unhook = dialogs === undefined ? hookPeekKey(ctx) : () => { };
109
104
  if (dialogs === undefined)
@@ -119,19 +114,55 @@ dialogs) {
119
114
  return answer;
120
115
  }
121
116
  finally {
122
- turn?.removeEventListener("abort", decline);
117
+ done();
123
118
  unhook();
124
119
  if (dialogs === undefined)
125
120
  ctx.ui.setWorkingMessage();
126
121
  }
127
122
  };
128
123
  }
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. */
124
+ /**
125
+ * A question being asked, until `done`: declined by `decline` - ctrl+c at it - by the host (`Turri.decline`, through the
126
+ * chat's `asking`) or by `turn` being stopped.
127
+ */
128
+ function asking(turn) {
129
+ const declining = { declined: turn?.aborted === true, close: undefined };
130
+ const decline = () => {
131
+ declining.declined = true;
132
+ declining.close?.();
133
+ };
134
+ turn?.addEventListener("abort", decline, { once: true });
135
+ const open = currentChat()?.asking;
136
+ open?.add(decline);
137
+ return {
138
+ declining,
139
+ decline,
140
+ done: () => {
141
+ turn?.removeEventListener("abort", decline);
142
+ open?.delete(decline);
143
+ },
144
+ };
145
+ }
146
+ /**
147
+ * A host's dialogs, for a question that can be declined: the question over at once, its dialog told through its
148
+ * `signal` - the host's to close.
149
+ */
130
150
  function hostDialogs(dialogs, declining) {
131
- const until = (asked) => declining.declined ? Promise.resolve(undefined) : Promise.race([asked(), new Promise((resolve) => (declining.close = () => resolve(undefined)))]);
151
+ const until = (asked) => {
152
+ if (declining.declined)
153
+ return Promise.resolve(undefined);
154
+ const over = new AbortController();
155
+ return Promise.race([
156
+ asked(over.signal),
157
+ new Promise((resolve) => (declining.close = () => {
158
+ over.abort();
159
+ resolve(undefined);
160
+ })),
161
+ ]);
162
+ };
132
163
  return {
133
- select: (...args) => until(() => dialogs.select(...args)),
134
- input: (...args) => until(() => dialogs.input(...args)),
164
+ select: (title, options, context, at) => until((signal) => dialogs.select(title, options, context, at, { signal })),
165
+ input: (title, context) => until((signal) => dialogs.input(title, context, { signal })),
135
166
  };
136
167
  }
137
168
  /**
@@ -753,8 +784,15 @@ function turriExtension(api, session, link) {
753
784
  escalate: async ({ question, critique }) => {
754
785
  if (dialogs !== undefined) {
755
786
  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;
787
+ // Declined, as any question in the host's dialogs can be: the gate stands.
788
+ const asked = asking(signal);
789
+ try {
790
+ const answer = await hostDialogs(dialogs, asked.declining).select(`Review gate: ${skill.name} - ${question}`, [through, "Keep the gate"], critique);
791
+ return !asked.declining.declined && answer === through;
792
+ }
793
+ finally {
794
+ asked.done();
795
+ }
758
796
  }
759
797
  if (!ctx.hasUI) {
760
798
  push(" no UI available for human review - gate stands");
@@ -16,7 +16,11 @@ export interface TurriOptions extends Omit<TurriHost, "dialogs"> {
16
16
  sessionDir?: string;
17
17
  /** The host's own extensions, built into every chat after turri's. */
18
18
  extensions?: ExtensionFactory[];
19
- /** The dialogs a chat's questions are put in - ask_user's, a skill's interview, a review gate's - by the chat's id. */
19
+ /**
20
+ * The dialogs a chat's questions are put in - ask_user's, a skill's interview, a review gate's - by the chat's id. Each
21
+ * is given a `signal`, aborted when its question is over without its answer - declined, or its turn stopped - for the
22
+ * host to close it.
23
+ */
20
24
  dialogs?: (chat: string) => AskUserIO | undefined;
21
25
  /** What a chat's other UI calls - its notices, its working line - come to, by the chat's id. */
22
26
  uiContext?: (chat: string) => ExtensionUIContext | undefined;
@@ -49,6 +53,13 @@ export interface Turri {
49
53
  * A second interrupt straight after one that stopped something does nothing (`ignored`); `missing` for a chat not open.
50
54
  */
51
55
  interrupt(id: string): Promise<"stopped" | "ignored" | "ended" | "missing">;
56
+ /**
57
+ * ctrl+c at a question, as the terminal has it: the question the chat asks now - its ask_user, a skill's interview, a
58
+ * review gate's escalation - declined. The asker is told the person declined to answer and the turn goes on; the
59
+ * host's dialog for it is told through its `signal`. Each question it is called at, however many in a row - it never
60
+ * stops the turn. `none` when the chat asks nothing now, `missing` for a chat not open.
61
+ */
62
+ decline(id: string): Promise<"declined" | "none" | "missing">;
52
63
  /** Told when a chat is opened, closed, renamed, or starts or stops working. */
53
64
  onChats(listener: (chats: ChatInfo[]) => void): () => void;
54
65
  /** Every chat let go, and what activate started stopped. */
@@ -66,6 +66,7 @@ export async function createTurri(options = {}) {
66
66
  await dispose();
67
67
  return did;
68
68
  },
69
+ decline: async (id) => core.decline(id),
69
70
  onChats: (listener) => core.onChats(() => listener(core.chats())),
70
71
  dispose,
71
72
  };
package/docs/chats.md CHANGED
@@ -42,7 +42,7 @@ On macOS, `alt` is Option (⌥), and reaches turri only when the terminal sends
42
42
  - **A hidden chat's questions** wait until you show it, then come up one after another. What it says in passing is said as you show it; what it keeps on the screen - a skill's progress, the job panel - is put back each time; what you had typed in its editor and not sent is still there.
43
43
  - **The side panel** (`ctrl+h`) is the session's, not a chat's: docked beside the chat or over it, it stays as another chat is shown, and opening it from any chat finds it as it was left.
44
44
  - **A long command** - `/regenerate`, planning a module from scratch - does its work in the background once it is under way: the chat, and switching to another, stay free while the planner plans.
45
- - **`ctrl+c`** reaches the chat shown and nothing else: another chat's turn and skill go on, and so do the pulses - `/halt` stops one. A skill stopped keeps its run, for the model to continue with `resume`. At a question - the chat's `ask_user`, a skill's interview - `ctrl+c` declines it, and the asker is told so and goes on - each question it is pressed at, however many in a row. A skill is stopped by `ctrl+c` with no question open. A second press straight after one that stopped something does nothing, so pressing twice to be sure does not leave turri; once the chat is doing nothing, the next one does - with what every other chat is doing. A host has the same as `interrupt(id)` ([hosting](hosting.md)).
45
+ - **`ctrl+c`** reaches the chat shown and nothing else: another chat's turn and skill go on, and so do the pulses - `/halt` stops one. A skill stopped keeps its run, for the model to continue with `resume`. At a question - the chat's `ask_user`, a skill's interview - `ctrl+c` declines it, and the asker is told so and goes on - each question it is pressed at, however many in a row. A skill is stopped by `ctrl+c` with no question open. A second press straight after one that stopped something does nothing, so pressing twice to be sure does not leave turri; once the chat is doing nothing, the next one does - with what every other chat is doing. A host has the same as `interrupt(id)`, and `decline(id)` for a question ([hosting](hosting.md)).
46
46
  - **The last chat** is not closed: `/quit` leaves turri.
47
47
  - **`/fork`** and **`/reload`** work on the chat shown, as [pi](https://pi.dev)'s do: the chat's history forked, or turri's extension rebuilt in it.
48
48
  - **A chat shown mid-turn** draws its reply as far as it is written, and follows it from there.
package/docs/hosting.md CHANGED
@@ -77,7 +77,7 @@ process.chdir(project); // the session is the project's in the pr
77
77
  const turri = await createTurri({
78
78
  onJobChange: (job) => {}, // a job started, moved on, did something, or ended - any chat's
79
79
  onPlanChange: () => {}, // what is planned changed with no job
80
- dialogs: (chat) => ({ select, input }), // a chat's questions, put in the host's own dialogs
80
+ dialogs: (chat) => ({ select, input }), // a chat's questions, put in the host's own dialogs - each with a signal to close it
81
81
  uiContext: (chat) => uiContext, // a chat's other UI calls - notices, the working line
82
82
  surface: SURFACE, // the host's UI, in its own words, told to the model in place of the terminal's
83
83
  systemPrompt: (prompt) => prompt, // the last word on the whole system prompt, each turn
@@ -90,6 +90,7 @@ chat.session.subscribe((event) => {}); // pi's session: its events, prompt(), ab
90
90
  await chat.session.prompt("add a cart to the shop");
91
91
  turri.focus(other.id); // the chat the person looks at now
92
92
  await turri.interrupt(chat.id); // ctrl+c: "stopped" what it was doing, else "ended" the session - "ignored", "missing"
93
+ await turri.decline(chat.id); // ctrl+c at a question: "declined", the turn going on - "none", "missing"
93
94
  turri.chats(); // [{ id, title, status: "idle" | "working" | "question" | "unread", shown }]
94
95
  turri.onChats((chats) => {}); // a chat opened, closed, renamed, working or done
95
96
  await turri.closeChat(chat.id); // its turn stopped, its conversation deleted - its pulses run on
@@ -99,10 +100,11 @@ await turri.dispose(); // every chat let go, what activate start
99
100
  ```
100
101
 
101
102
  - **The chats.** Up to nine, each its own history, model, thinking level and stack, all on the session's one job registry and work loop - see [chats](chats.md). `openChat({ sessionFile })` reopens a saved one. A pulse's end is told to the chat that asked for it, its agent reacting there; to the chat `focus` last named when that one is closed. A chat is never hidden from turri's side: what the host does not show, it still runs, and its questions go to the host's dialogs at once - the host decides where they wait. `status` says a turn is running (`working`) or that it said something since it was last focused (`unread`).
102
- - **Interrupting.** `interrupt(id)` is the terminal's ctrl+c for a host's key or button: it stops what that chat is doing - its turn, a skill or a tool in it, a `!` command, a compaction - and nothing in the other chats (`stopped`). A skill stopped keeps its run, to be continued. A question of the chat's open in the host's dialogs when its turn is stopped is over - declined - though its dialog is the host's to close. A chat doing nothing ends the session, every chat with it, as `dispose` does (`ended`): turri never exits the host's process, so the host closes what showed it. An interrupt straight after one that stopped something is `ignored`, so pressing twice to be sure ends nothing. `session.abort()` is the chat's turn alone, and never ends the session.
103
+ - **Interrupting.** `interrupt(id)` is the terminal's ctrl+c for a host's key or button: it stops what that chat is doing - its turn, a skill or a tool in it, a `!` command, a compaction - and nothing in the other chats (`stopped`). A skill stopped keeps its run, to be continued. A question of the chat's open in the host's dialogs when its turn is stopped is over - declined - and its dialog's `signal` aborted, for the host to close it. A chat doing nothing ends the session, every chat with it, as `dispose` does (`ended`): turri never exits the host's process, so the host closes what showed it. An interrupt straight after one that stopped something is `ignored`, so pressing twice to be sure ends nothing. `session.abort()` is the chat's turn alone, and never ends the session.
104
+ - **Declining a question.** `decline(id)` is ctrl+c at a question in turri's terminal: the question the chat asks now - its ask_user, a skill's interview, a review gate's escalation, whatever goes through the host's dialogs - declined. The asker is told "The user declined to answer the question." and the turn goes on; a review gate declined stands. It declines each question it is called at, however many in a row, and never stops the turn - that is `interrupt`. `none` when the chat asks nothing now, `missing` for a chat not open. A dialog that resolves with `undefined` is something else - dismissed, and in a question of several answers "done" with the boxes ticked so far.
103
105
  - **The record.** The chats are recorded in `.turri/chats.json` as the terminal records them, and `restore: true` reopens the last ended session's - without it, a host starts with none and deletes nothing. A chat closed is deleted; a session's chats are kept until five newer sessions have recorded theirs.
104
106
  - **The jobs.** The host is told of each job as it changes, and has the registry and the loop themselves, so it shows the very jobs the chats run. Its buttons are best a chat's commands - `session.prompt("/regenerate app.plain")` - so the plan is the chats'.
105
- - **The questions.** `dialogs(chat)` gives a chat's two dialogs: `select(title, options, context, at)` and `input(title, context)`, each resolving with the answer, or undefined when the person dismissed it. The chat's questions, a skill's interview and a review gate's escalation are put in them; without them, in the `uiContext`'s own dialogs.
107
+ - **The questions.** `dialogs(chat)` gives a chat's two dialogs: `select(title, options, context, at, { signal })` and `input(title, context, { signal })`, each resolving with the answer, or undefined when the person dismissed it. `signal` is aborted when the question is over without the dialog's answer - declined by `decline`, or its turn stopped (`interrupt`, `session.abort()`): the host closes the dialog it opened for it, whatever it resolves with then being ignored. A host may leave it unread. The chat's questions, a skill's interview and a review gate's escalation are put in them; without them, in the `uiContext`'s own dialogs.
106
108
  - **The surface.** What turri tells the model of where the person watches the work is the terminal's by default: regen, the side panel on `ctrl+h`, `/pulses`. In a host none of that is there, and the model would send the person to keys and panels that do not exist. `surface` is the host's own description of its UI - the views it has in place of the terminal's, what its marks and buttons mean, where questions appear - in its own words. Given, turri leaves every terminal instruction out of what the model is told and puts the surface in turri's identity, as a `<surface>` block, where the identity speaks of regen. It reaches the side questions and the tools' own words too: a pulse not found says the `pulses` tool lists them, not `/pulses`. Text only the person sees in the terminal - the job panel, the side panel - is the terminal's and stays as it is. Without `surface`, the prompt is the terminal's, byte for byte. What the conversation is to do once something is released belongs there too - open it in the host's preview, say what to test: after a pulse released under `release-after-pulse`, the conversation does what it was told to do once something is released.
107
109
  - **The system prompt.** `systemPrompt(prompt)` gets the whole system prompt for the turn - [pi](https://pi.dev)'s, turri's identity and blocks, the rules - after turri's own `before_agent_start` work, and returns what is sent. It is for what `surface` does not cover; to describe the UI, use `surface`, rather than words appended after turri's that contradict them.
108
110
  - **The mode.** Each chat is bound in [pi](https://pi.dev)'s `tui` mode: the work runs in the background and keeps the chat free, as the terminal does. Its other UI calls - its notices, its working line, a confirm - come to `uiContext(chat)`, as [pi](https://pi.dev)'s [SDK](https://github.com/earendil-works/pi) describes it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turri-dev/cli",
3
- "version": "0.1.17-next.63f454c",
3
+ "version": "0.1.18-next.85984f7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {