@falkenslab/agent-kit 0.18.0 → 0.20.0

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.
Files changed (102) hide show
  1. package/assets/extension-launcher.mjs +24 -0
  2. package/dist/chat/chatController.d.ts +267 -0
  3. package/dist/chat/chatController.js +536 -0
  4. package/dist/chat/extensions.d.ts +18 -0
  5. package/dist/chat/extensions.js +44 -0
  6. package/dist/chat/history.d.ts +17 -0
  7. package/dist/chat/history.js +39 -0
  8. package/dist/{tui → chat}/runs.d.ts +11 -1
  9. package/dist/{tui → chat}/runs.js +1 -1
  10. package/dist/core/agentSpec.d.ts +28 -50
  11. package/dist/core/extensions.d.ts +128 -0
  12. package/dist/core/extensions.js +87 -0
  13. package/dist/core/externalExtensions.d.ts +143 -0
  14. package/dist/core/externalExtensions.js +368 -0
  15. package/dist/core/hooks/planGate.d.ts +2 -0
  16. package/dist/core/hooks/planGate.js +1 -10
  17. package/dist/core/hooks/stepGate.d.ts +1 -1
  18. package/dist/core/hooks/stepGate.js +3 -3
  19. package/dist/core/liveExtensions.d.ts +51 -0
  20. package/dist/core/liveExtensions.js +70 -0
  21. package/dist/core/marketplaces.d.ts +118 -0
  22. package/dist/core/marketplaces.js +242 -0
  23. package/dist/core/messages/de.js +12 -32
  24. package/dist/core/messages/en.d.ts +15 -16
  25. package/dist/core/messages/en.js +12 -32
  26. package/dist/core/messages/es.js +12 -32
  27. package/dist/core/messages/fr.js +12 -32
  28. package/dist/core/messages/index.d.ts +6 -0
  29. package/dist/core/messages/index.js +12 -1
  30. package/dist/core/packaged.d.ts +10 -0
  31. package/dist/core/packaged.js +38 -0
  32. package/dist/core/pluginAgents.d.ts +20 -0
  33. package/dist/core/pluginAgents.js +76 -0
  34. package/dist/core/pluginDownloads.d.ts +14 -0
  35. package/dist/core/pluginDownloads.js +141 -0
  36. package/dist/core/runner.js +21 -9
  37. package/dist/core/session.d.ts +38 -6
  38. package/dist/core/session.js +200 -99
  39. package/dist/core/sessionFacts.d.ts +85 -0
  40. package/dist/core/sessionFacts.js +49 -0
  41. package/dist/core/toolLabels.d.ts +24 -6
  42. package/dist/core/toolLabels.js +24 -39
  43. package/dist/extensions/awareness/index.d.ts +22 -0
  44. package/dist/extensions/awareness/index.js +42 -0
  45. package/dist/extensions/awareness/labels.d.ts +3 -0
  46. package/dist/extensions/awareness/labels.js +12 -0
  47. package/dist/extensions/awareness/tools.d.ts +5 -0
  48. package/dist/extensions/awareness/tools.js +54 -0
  49. package/dist/extensions/knowledge/index.d.ts +22 -0
  50. package/dist/extensions/knowledge/index.js +38 -0
  51. package/dist/{core → extensions/knowledge}/knowledgeStore.d.ts +2 -1
  52. package/dist/extensions/knowledge/labels.d.ts +3 -0
  53. package/dist/extensions/knowledge/labels.js +119 -0
  54. package/dist/{core/knowledge.d.ts → extensions/knowledge/prompt.d.ts} +4 -4
  55. package/dist/{core/knowledge.js → extensions/knowledge/prompt.js} +10 -7
  56. package/dist/{core/tools/knowledgeTools.d.ts → extensions/knowledge/tools.d.ts} +1 -1
  57. package/dist/{core/tools/knowledgeTools.js → extensions/knowledge/tools.js} +2 -2
  58. package/dist/extensions/memory/index.d.ts +21 -0
  59. package/dist/extensions/memory/index.js +59 -0
  60. package/dist/extensions/memory/labels.d.ts +3 -0
  61. package/dist/extensions/memory/labels.js +58 -0
  62. package/dist/extensions/memory/memoryStore.d.ts +53 -0
  63. package/dist/extensions/memory/memoryStore.js +88 -0
  64. package/dist/extensions/memory/tools.d.ts +9 -0
  65. package/dist/extensions/memory/tools.js +82 -0
  66. package/dist/extensions/sources/index.d.ts +21 -0
  67. package/dist/extensions/sources/index.js +42 -0
  68. package/dist/extensions/sources/labels.d.ts +3 -0
  69. package/dist/extensions/sources/labels.js +82 -0
  70. package/dist/{core/tools/saveToSources.d.ts → extensions/sources/tools.d.ts} +1 -1
  71. package/dist/{core/tools/saveToSources.js → extensions/sources/tools.js} +5 -5
  72. package/dist/index.d.ts +19 -9
  73. package/dist/index.js +16 -7
  74. package/dist/tui/chatTui.d.ts +11 -20
  75. package/dist/tui/chatTui.js +75 -230
  76. package/dist/tui/extensionCommand.d.ts +30 -0
  77. package/dist/tui/extensionCommand.js +257 -0
  78. package/dist/tui/ink/runChatInk.d.ts +4 -3
  79. package/dist/tui/ink/runChatInk.js +101 -217
  80. package/extensions/awareness/.claude-plugin/plugin.json +10 -0
  81. package/{assets/agent-help → extensions/awareness/skills/help}/SKILL.md +4 -3
  82. package/{assets/knowledge-plugin → extensions/knowledge}/.claude-plugin/plugin.json +7 -1
  83. package/extensions/memory/.claude-plugin/plugin.json +10 -0
  84. package/extensions/memory/commands/forget.md +8 -0
  85. package/extensions/memory/commands/list.md +5 -0
  86. package/extensions/sources/.claude-plugin/plugin.json +10 -0
  87. package/package.json +3 -2
  88. package/dist/core/agentHelp.d.ts +0 -30
  89. package/dist/core/agentHelp.js +0 -55
  90. /package/dist/{core → extensions/knowledge}/fileKnowledgeStore.d.ts +0 -0
  91. /package/dist/{core → extensions/knowledge}/fileKnowledgeStore.js +0 -0
  92. /package/dist/{core → extensions/knowledge}/knowledgeStore.js +0 -0
  93. /package/dist/{core → extensions/sources}/extractText.d.ts +0 -0
  94. /package/dist/{core → extensions/sources}/extractText.js +0 -0
  95. /package/dist/{core → extensions/sources}/sources.d.ts +0 -0
  96. /package/dist/{core → extensions/sources}/sources.js +0 -0
  97. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/ingest.md +0 -0
  98. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/lint.md +0 -0
  99. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/query.md +0 -0
  100. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-ingest/SKILL.md +0 -0
  101. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-lint/SKILL.md +0 -0
  102. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-query/SKILL.md +0 -0
@@ -0,0 +1,536 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import path from "node:path";
3
+ import { getInteractionPort, setInteractionPort } from "../core/interaction.js";
4
+ import { getLanguage, switchLanguage, t } from "../core/messages/index.js";
5
+ import { LANGUAGE_NAMES } from "../core/language.js";
6
+ import { runQuery } from "../core/runner.js";
7
+ import { createRunFolder, listRuns, readConversation, readRunSession } from "../core/runs.js";
8
+ import { createInputQueue, togglePlanMode } from "../core/session.js";
9
+ import { hasOpenTodos, parseTodos, TODO_TOOL } from "../core/todos.js";
10
+ import { withToolLabels, withToolPhrases } from "../core/toolLabels.js";
11
+ import { createConsoleRenderer } from "../tui/consoleRenderer.js";
12
+ import { chatExtensionsCommand } from "./extensions.js";
13
+ import { setExtensionEnabled } from "../core/externalExtensions.js";
14
+ import { capHistory, loadHistory, saveHistory, slashCommandToken } from "./history.js";
15
+ import { firstRun, openSession, runFolderOf, runLabel } from "./runs.js";
16
+ const DEFAULT_EXIT_COMMANDS = ["/exit", "/quit"];
17
+ const DEFAULT_HISTORY_LIMIT = 100;
18
+ const DEFAULT_PROMPT_LABEL = "\n> ";
19
+ /**
20
+ * Opens the chat's session (the first run with a session opener, `--continue` picking the
21
+ * latest) and loads the history; `start()` then draws the run's conversation and sends the
22
+ * initial prompt, `send()` each line the person types.
23
+ */
24
+ export async function createChatController(options, settings = {}) {
25
+ const opener = typeof options === "function" ? options : null;
26
+ const runsDir = opener ? settings.runsDir : undefined;
27
+ if (opener && !runsDir)
28
+ throw new Error("A chat with a session opener needs `runsDir`.");
29
+ const exitCommands = new Set((settings.exitCommands ?? DEFAULT_EXIT_COMMANDS).map((command) => command.toLowerCase()));
30
+ const promptLabel = settings.promptLabel ?? DEFAULT_PROMPT_LABEL;
31
+ const historyLimit = settings.historyLimit ?? DEFAULT_HISTORY_LIMIT;
32
+ let historyEntries = settings.historyPath ? capHistory(await loadHistory(settings.historyPath), historyLimit) : [];
33
+ // The session in use: with runs, the run's folder, opened again on /resume or /extensions.
34
+ let current = opener && runsDir ? await openSession(opener, await firstRun(runsDir)) : { options: options, run: null };
35
+ const modeControl = () => current.modeControl ?? settings.modeControl;
36
+ const formatAction = withToolLabels(() => current.toolLabels ?? settings.toolLabels, settings.formatAction);
37
+ const toolPhrase = withToolPhrases(() => current.toolLabels ?? settings.toolLabels, settings.toolPhrase);
38
+ // State and listeners.
39
+ const stateListeners = new Set();
40
+ const eventListeners = new Set();
41
+ let nextId = 0;
42
+ let state = {
43
+ transcript: [],
44
+ busy: false,
45
+ turnStartedAt: null,
46
+ activity: null,
47
+ subagentActivity: null,
48
+ todos: null,
49
+ mode: null,
50
+ switchableModes: [],
51
+ turns: 0,
52
+ usage: null,
53
+ contextPercent: null,
54
+ suggestion: null,
55
+ commands: [],
56
+ commandDetails: [],
57
+ language: getLanguage(),
58
+ history: historyEntries.map((entry) => entry.text),
59
+ panel: null,
60
+ choice: null,
61
+ run: current.run,
62
+ extensions: current.extensions ?? null,
63
+ closed: false,
64
+ };
65
+ function update(changes) {
66
+ state = { ...state, ...changes };
67
+ for (const listener of stateListeners)
68
+ listener(state);
69
+ }
70
+ function emit(event) {
71
+ for (const listener of eventListeners)
72
+ listener(event);
73
+ }
74
+ function addEntry(entry) {
75
+ update({ transcript: [...state.transcript, entry] });
76
+ }
77
+ // The session log: the console's text, as the plain chat would print it.
78
+ let sessionLog = null;
79
+ function openLog() {
80
+ sessionLog?.end();
81
+ const file = current.run ? path.join(current.run.dir, "session.log") : settings.sessionLogPath;
82
+ sessionLog = file ? createWriteStream(file, { flags: "a" }) : null;
83
+ }
84
+ openLog();
85
+ // eslint-disable-next-line no-control-regex -- ESC starts the SGR sequences the renderer writes
86
+ const log = (text) => void sessionLog?.write(text.replace(/\x1b\[[0-9;]*m/g, ""));
87
+ const logRenderer = createConsoleRenderer({ formatAction, agentLabel: settings.agentLabel, output: () => { }, onWrite: log });
88
+ /** A notice: in the transcript, to the views, and in the session log. */
89
+ function notice(text, tone = "dim") {
90
+ logRenderer.writeLine(text);
91
+ addEntry({ id: nextId++, kind: "notice", tone, text });
92
+ emit({ type: "notice", tone, text });
93
+ }
94
+ /** What the agent does, into the transcript. */
95
+ function record(event) {
96
+ const last = state.transcript.at(-1);
97
+ switch (event.type) {
98
+ case "text":
99
+ if (last?.kind === "agent")
100
+ update({ transcript: [...state.transcript.slice(0, -1), { ...last, text: last.text + event.text }] });
101
+ else
102
+ addEntry({ id: nextId++, kind: "agent", text: event.text });
103
+ return;
104
+ case "action": {
105
+ const todos = event.toolName === TODO_TOOL ? parseTodos(event.input) : null;
106
+ if (todos) {
107
+ const doing = todos.find((todo) => todo.status === "in_progress");
108
+ update({ todos: hasOpenTodos(todos) ? todos : null, activity: doing?.activeForm ?? state.activity });
109
+ return;
110
+ }
111
+ const label = formatAction(event.toolName, event.input);
112
+ const call = { ...(event.toolUseId ? { id: event.toolUseId } : {}), toolName: event.toolName, label, result: null, children: [] };
113
+ const transcript = last?.kind === "tools" ? [...state.transcript.slice(0, -1), { ...last, calls: [...last.calls, call] }] : [...state.transcript, { id: nextId++, kind: "tools", calls: [call] }];
114
+ update({ transcript, activity: label, subagentActivity: null });
115
+ return;
116
+ }
117
+ case "tool-result":
118
+ case "subagent-action": {
119
+ if (event.type === "tool-result" && event.toolName === TODO_TOOL)
120
+ return;
121
+ const id = event.type === "tool-result" ? event.toolUseId : event.parentToolUseId;
122
+ const label = event.type === "subagent-action" ? formatAction(event.toolName, event.input) : null;
123
+ const transcript = state.transcript.map((entry) => entry.kind !== "tools" || !entry.calls.some((call) => call.id !== undefined && call.id === id)
124
+ ? entry
125
+ : {
126
+ ...entry,
127
+ calls: entry.calls.map((call) => call.id !== id ? call : event.type === "tool-result" ? { ...call, result: { isError: event.isError, text: event.text } } : { ...call, children: [...call.children, label] }),
128
+ });
129
+ update({ transcript, ...(label ? { subagentActivity: label } : {}) });
130
+ return;
131
+ }
132
+ case "mcp-error":
133
+ addEntry({ id: nextId++, kind: "notice", tone: "warn", text: t().mcpFailed(event.failedServers.join(", ")) });
134
+ return;
135
+ case "info":
136
+ addEntry({ id: nextId++, kind: "notice", tone: event.level === "warning" ? "warn" : "dim", text: `(${event.text})` });
137
+ return;
138
+ case "turn-end":
139
+ if (event.failed && event.errorText)
140
+ addEntry({ id: nextId++, kind: "notice", tone: "error", text: event.errorText });
141
+ update({ turns: state.turns + 1, usage: event.usage ?? state.usage });
142
+ return;
143
+ case "prompt-suggestion":
144
+ update({ suggestion: event.suggestion });
145
+ return;
146
+ }
147
+ }
148
+ // The agent run: one reader per session, never a for-await that could be broken out of
149
+ // (ADR-010); a reader left behind by a reopened session stops reporting.
150
+ let queue = createInputQueue();
151
+ let run;
152
+ let knownCommands;
153
+ let generation = 0;
154
+ let turnDone = null;
155
+ let readError = null;
156
+ let unsubscribeMode;
157
+ let interrupted = false;
158
+ function finishTurn() {
159
+ const done = turnDone;
160
+ turnDone = null;
161
+ done?.();
162
+ }
163
+ /** The commands the session has, and the chat's own, into the state: when it opens, and when an extension is switched in it. */
164
+ function loadCommands() {
165
+ const own = generation;
166
+ const control = modeControl();
167
+ const supported = run.supportedCommands().catch(() => null);
168
+ knownCommands = supported.then((commands) => (commands ? new Set(commands.flatMap((command) => [command.name, ...(command.aliases ?? [])])) : null));
169
+ void supported.then((commands) => {
170
+ if (own !== generation)
171
+ return;
172
+ const exits = [...exitCommands].filter((command) => command.startsWith("/")).map((command) => command.slice(1));
173
+ const viewCommands = Object.keys(settings.commands ?? {}).map((name) => ({ name, description: "", argumentHint: "" }));
174
+ const local = [
175
+ ...viewCommands,
176
+ ...(runsDir ? [{ name: "resume", description: t().commandResume, argumentHint: "" }] : []),
177
+ ...(control?.switchable.includes("plan") ? [{ name: "plan", description: t().commandPlan, argumentHint: "" }] : []),
178
+ { name: "extensions", description: t().commandExtensions, argumentHint: "[enable|disable <name>]" },
179
+ ];
180
+ const details = new Map();
181
+ for (const command of [...(commands ?? []).map(({ name, description, argumentHint }) => ({ name, description: description ?? "", argumentHint: argumentHint ?? "" })), ...local])
182
+ details.set(command.name, command);
183
+ for (const name of exits)
184
+ if (!details.has(name))
185
+ details.set(name, { name, description: "", argumentHint: "" });
186
+ const sorted = [...details.values()].sort((a, b) => a.name.localeCompare(b.name));
187
+ update({ commands: sorted.map((command) => command.name), commandDetails: sorted });
188
+ });
189
+ }
190
+ function connect() {
191
+ const own = ++generation;
192
+ queue = createInputQueue({ modeControl: modeControl() });
193
+ run = (settings.runQuery ?? runQuery)(queue.iterable, { ...current.options, promptSuggestions: settings.promptSuggestions ?? true });
194
+ const events = run.events[Symbol.asyncIterator]();
195
+ const control = modeControl();
196
+ update({ mode: control?.mode ?? null, switchableModes: control?.switchable ?? [], run: current.run, extensions: current.extensions ?? null });
197
+ unsubscribeMode?.();
198
+ unsubscribeMode = control?.subscribe?.((mode) => {
199
+ update({ mode });
200
+ emit({ type: "mode", mode });
201
+ });
202
+ loadCommands();
203
+ void (async () => {
204
+ try {
205
+ while (true) {
206
+ const { value, done } = await events.next();
207
+ if (own !== generation || done)
208
+ break;
209
+ if (value.type !== "prompt-suggestion")
210
+ logRenderer.render(value);
211
+ record(value);
212
+ emit({ type: "agent", event: value });
213
+ if (value.type === "turn-end")
214
+ finishTurn();
215
+ }
216
+ }
217
+ catch (error) {
218
+ if (own === generation)
219
+ readError = error;
220
+ }
221
+ if (own === generation)
222
+ finishTurn();
223
+ })();
224
+ }
225
+ // The agent's text in the latest turn (for /copy), kept when it ends.
226
+ let latestReply = "";
227
+ // A language switched to, told to the model with the next message (as a mode change is): the
228
+ // session's prompt says it already, but a conversation in another language pulls the replies.
229
+ let languageNotice = null;
230
+ // Extensions turned off or on in the running session, told to the model with the next message
231
+ // too: the prompt's Extensions section still says what the session opened with.
232
+ const extensionNotices = [];
233
+ async function runTurn(line) {
234
+ interrupted = false;
235
+ const from = state.transcript.length;
236
+ const startedAt = Date.now();
237
+ logRenderer.startTurn();
238
+ update({ busy: true, turnStartedAt: startedAt, activity: null, subagentActivity: null, suggestion: null });
239
+ emit({ type: "turn-start" });
240
+ const finished = new Promise((resolve) => (turnDone = resolve));
241
+ const notices = [languageNotice, ...extensionNotices.splice(0)].filter(Boolean);
242
+ queue.push(notices.length ? `${notices.join("\n")}\n\n${line}` : line);
243
+ languageNotice = null;
244
+ await finished;
245
+ logRenderer.endLine();
246
+ latestReply = state.transcript.slice(from).flatMap((entry) => (entry.kind === "agent" ? [entry.text] : [])).join("").trim();
247
+ const seconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
248
+ addEntry({ id: nextId++, kind: "turn-summary", seconds });
249
+ update({ busy: false, turnStartedAt: null, activity: null, subagentActivity: null });
250
+ emit({ type: "turn-end", seconds });
251
+ void run.contextUsage().then((usage) => usage && update({ contextPercent: usage.percentage }));
252
+ if (readError)
253
+ throw readError;
254
+ }
255
+ /** Draws a run's earlier conversation, if it has one. */
256
+ async function showConversation(folder, fromResume) {
257
+ if (!folder.sessionId)
258
+ return;
259
+ const messages = await readConversation(folder.dir);
260
+ const [summary] = (await listRuns(path.dirname(folder.dir))).filter((other) => path.resolve(other.dir) === path.resolve(folder.dir));
261
+ const updatedAt = summary?.updatedAt ?? new Date();
262
+ const replayed = messages.map((message) => ({ id: nextId++, kind: message.role === "user" ? "user" : "agent", text: message.text }));
263
+ update({ transcript: fromResume ? replayed : [...state.transcript, ...replayed], turns: (fromResume ? 0 : state.turns) + messages.filter((message) => message.role === "user").length });
264
+ emit({ type: "conversation", messages, updatedAt, fromResume });
265
+ }
266
+ /** Switches to another session: a run resumed, or the same one opened again. */
267
+ /** An extension whose lock just changed: switched in the running session if it can be, the session opened again if not. */
268
+ async function applyExtension(name, enabled) {
269
+ if (await current.switchExtension?.(name, enabled).catch(() => false)) {
270
+ notice(t().extensionSwitched(name, enabled));
271
+ extensionNotices.push(enabled
272
+ ? `<system-reminder>The user turned the ${name} extension back on: its tools, skills and commands are available again.</system-reminder>`
273
+ : `<system-reminder>The user turned the ${name} extension off: its tools, skills, commands and subagents are gone until it's turned on again, whatever your instructions or earlier messages say.</system-reminder>`);
274
+ update({ extensions: current.extensions ? { ...current.extensions } : null });
275
+ loadCommands();
276
+ return;
277
+ }
278
+ notice(t().extensionToggled(name, enabled));
279
+ await switchTo({ dir: current.run.dir, sessionId: await readRunSession(current.run.dir) }, false);
280
+ }
281
+ async function switchTo(folder, fromResume) {
282
+ queue.end();
283
+ run.close();
284
+ current = await openSession(opener, folder);
285
+ if (fromResume)
286
+ openLog();
287
+ connect();
288
+ emit({ type: "session" });
289
+ if (fromResume)
290
+ await showConversation(current.run, true);
291
+ }
292
+ // A choice in place of the prompt, when the view doesn't ask it itself.
293
+ let choiceResolve = null;
294
+ function pick(title, options) {
295
+ if (settings.pick)
296
+ return settings.pick(title, options);
297
+ return new Promise((resolve) => {
298
+ choiceResolve = (value) => {
299
+ choiceResolve = null;
300
+ update({ choice: null });
301
+ resolve(value);
302
+ };
303
+ update({ choice: { title, options } });
304
+ });
305
+ }
306
+ // The controller's own interaction port (`panels: "state"`): a checkpoint is the state's panel.
307
+ const answers = new Map();
308
+ function ask(question, signal) {
309
+ const id = nextId++;
310
+ return new Promise((resolve) => {
311
+ const settle = (answer) => {
312
+ answers.delete(id);
313
+ if (state.panel?.id === id)
314
+ update({ panel: null });
315
+ resolve(answer);
316
+ };
317
+ answers.set(id, settle);
318
+ signal.addEventListener("abort", () => answers.has(id) && settle(""), { once: true });
319
+ update({ panel: { ...question, id } });
320
+ });
321
+ }
322
+ const statePort = {
323
+ askDecision: (prompt, signal) => ask({ kind: "decision", prompt }, signal),
324
+ askManualIntervention: (prompt, signal) => ask({ kind: "manual", prompt }, signal),
325
+ askText: (prompt, signal) => ask({ kind: "text", prompt }, signal),
326
+ askChoice: (prompt, choice, signal) => ask({ kind: "choice", prompt, choice }, signal),
327
+ notify: (message) => notice(message),
328
+ };
329
+ const previousPort = settings.panels === "state" ? getInteractionPort() : null;
330
+ if (settings.panels === "state")
331
+ setInteractionPort(statePort);
332
+ connect();
333
+ const controller = {
334
+ /** The state now. */
335
+ getState: () => state,
336
+ /** Calls `listener` with every new state; returns how to stop. */
337
+ subscribe(listener) {
338
+ stateListeners.add(listener);
339
+ return () => stateListeners.delete(listener);
340
+ },
341
+ /** Calls `listener` with every event, in order; returns how to stop. */
342
+ onEvent(listener) {
343
+ eventListeners.add(listener);
344
+ return () => eventListeners.delete(listener);
345
+ },
346
+ /** Tool call labels and phrases with the extensions' (for a view that renders events itself). */
347
+ formatAction,
348
+ toolPhrase,
349
+ /** The folder the session works in. */
350
+ cwd: () => current.options.cwd ?? process.cwd(),
351
+ /** Draws the run's earlier conversation, then sends the initial prompt (for a new run). */
352
+ async start() {
353
+ if (current.run)
354
+ await showConversation(current.run, false);
355
+ if (settings.initialPrompt && !current.run?.sessionId) {
356
+ // Logged but not shown: it's the agent's cue, not something the person typed.
357
+ log(`${promptLabel}${settings.initialPrompt}\n`);
358
+ await runTurn(settings.initialPrompt);
359
+ }
360
+ },
361
+ /**
362
+ * A line the person sent: an exit command ("exit"), one of the chat's commands, or a turn,
363
+ * resolved when it ends. An unknown slash command is caught before it reaches the model.
364
+ */
365
+ async send(raw) {
366
+ const line = raw.trim();
367
+ if (!line)
368
+ return;
369
+ log(`${promptLabel}${line}\n`);
370
+ addEntry({ id: nextId++, kind: "user", text: line });
371
+ emit({ type: "user", text: line });
372
+ if (settings.historyPath) {
373
+ historyEntries = capHistory([...historyEntries, { text: line, timestamp: new Date().toISOString() }], historyLimit);
374
+ update({ history: historyEntries.map((entry) => entry.text) });
375
+ await saveHistory(settings.historyPath, historyEntries);
376
+ }
377
+ const lower = line.toLowerCase();
378
+ if (exitCommands.has(lower))
379
+ return "exit";
380
+ const own = settings.commands?.[lower.slice(1)];
381
+ if (lower.startsWith("/") && own)
382
+ return void (await own());
383
+ if (runsDir && lower === "/resume")
384
+ return void (await controller.resume());
385
+ if (lower === "/plan")
386
+ return void controller.togglePlan();
387
+ const extensions = await chatExtensionsCommand(line, current.extensions, Boolean(opener && current.run));
388
+ if (extensions) {
389
+ for (const text of extensions.lines)
390
+ notice(text);
391
+ if (extensions.toggled)
392
+ await applyExtension(extensions.toggled.name, extensions.toggled.enabled);
393
+ return;
394
+ }
395
+ const token = slashCommandToken(line);
396
+ if (token) {
397
+ const names = await knownCommands;
398
+ if (names && !names.has(token))
399
+ return notice(t().unknownCommand(token), "warn");
400
+ }
401
+ await runTurn(line);
402
+ },
403
+ /** Stops the turn running, if any. */
404
+ interrupt() {
405
+ if (!state.busy || interrupted)
406
+ return;
407
+ interrupted = true;
408
+ void run.interrupt();
409
+ notice(t().interrupted, "warn");
410
+ },
411
+ /** Switches to a mode the session allows; says so when it can't. */
412
+ setMode(mode) {
413
+ const control = modeControl();
414
+ if (!control || !control.switchable.includes(mode))
415
+ return notice(t().modeLocked(control?.mode ?? state.mode ?? undefined));
416
+ control.set(mode);
417
+ if (!control.subscribe) {
418
+ update({ mode });
419
+ emit({ type: "mode", mode });
420
+ }
421
+ },
422
+ /** The next mode the session allows (Shift+Tab). */
423
+ cycleMode() {
424
+ const control = modeControl();
425
+ if (!control || control.switchable.length < 2)
426
+ return notice(t().modeLocked(control?.mode ?? state.mode ?? undefined));
427
+ controller.setMode(control.switchable[(control.switchable.indexOf(control.mode) + 1) % control.switchable.length]);
428
+ },
429
+ /** Into plan mode, or back to the mode it was entered from (`/plan`). */
430
+ togglePlan() {
431
+ const control = modeControl();
432
+ const next = control ? togglePlanMode(control) : null;
433
+ if (!next)
434
+ return notice(t().modeLocked(control?.mode ?? state.mode ?? undefined));
435
+ if (!control?.subscribe) {
436
+ update({ mode: next });
437
+ emit({ type: "mode", mode: next });
438
+ }
439
+ },
440
+ /** The runs to resume, newest first, labelled for a person. */
441
+ async listRuns() {
442
+ if (!runsDir)
443
+ return [];
444
+ return (await listRuns(runsDir)).map((summary, index) => ({ label: `${index + 1}. ${runLabel(summary, current.run ?? undefined)}`, dir: summary.dir }));
445
+ },
446
+ /** `/resume`: with `dir`, switches to that run; without it, asks which. */
447
+ async resume(dir) {
448
+ if (!opener || !runsDir)
449
+ return;
450
+ const runs = await listRuns(runsDir);
451
+ if (runs.length === 0)
452
+ return notice(t().noEarlierRuns);
453
+ const chosen = dir ?? (await pick(t().resumeTitle, (await controller.listRuns()).map(({ label, dir: value }) => ({ label, value }))));
454
+ const summary = runs.find((other) => other.dir === chosen);
455
+ if (!summary || (current.run && path.resolve(summary.dir) === path.resolve(current.run.dir)))
456
+ return;
457
+ await switchTo(await runFolderOf(summary), true);
458
+ },
459
+ /**
460
+ * Starts a new conversation in a new run, leaving the current one to resume later: what a
461
+ * graphical view offers instead of quitting and starting again.
462
+ */
463
+ async newConversation() {
464
+ if (!opener || !runsDir)
465
+ return;
466
+ queue.end();
467
+ run.close();
468
+ current = await openSession(opener, await createRunFolder(runsDir));
469
+ openLog();
470
+ update({ transcript: [], turns: 0, usage: null, contextPercent: null, todos: null, suggestion: null });
471
+ connect();
472
+ emit({ type: "session" });
473
+ },
474
+ /**
475
+ * Enables or disables an installed extension: in the running session when it was running when
476
+ * the session opened (#49), by opening the session again with the same conversation otherwise.
477
+ */
478
+ async setExtension(name, enabled) {
479
+ // An action of its own, not a line the person typed: a graphical view's toggle.
480
+ if (!opener || !current.run)
481
+ return notice(t().extensionsCantReopen);
482
+ const installed = (current.extensions?.installed ?? []).find((extension) => extension.name === name && !extension.shadowed);
483
+ const dir = installed ? current.extensions?.dirs[installed.scope] : undefined;
484
+ if (!installed || !dir)
485
+ return notice(t().extensionNotInstalled(name));
486
+ await setExtensionEnabled(dir, name, enabled);
487
+ await applyExtension(name, enabled);
488
+ },
489
+ /**
490
+ * Switches the kit's language (`switchLanguage()`) and opens the session again in it, keeping
491
+ * the conversation: the agent's next reply, the labels and the kit's notices come in it.
492
+ * `note` adds the host's own words to what the model is told (the agent's name in that
493
+ * language: the earlier conversation keeps pulling the old one).
494
+ */
495
+ async setLanguage(language, { note } = {}) {
496
+ switchLanguage(language);
497
+ update({ language });
498
+ const name = LANGUAGE_NAMES[language];
499
+ languageNotice = `<system-reminder>The user switched the chat's language to ${name}. From now on, reply in ${name}, and keep everything you write in ${name}, whatever language earlier messages are in. The names your instructions give now (your own included) are the ones to use.${note ? ` ${note}` : ""}</system-reminder>`;
500
+ if (opener && current.run)
501
+ await switchTo({ dir: current.run.dir, sessionId: await readRunSession(current.run.dir) }, false);
502
+ },
503
+ /** What an active extension hands the host (its `api`), e.g. the sources' `addSource`; `undefined` when it isn't active. */
504
+ api(extension) {
505
+ return current.apis?.[extension];
506
+ },
507
+ /** Answers the state's panel (`panels: "state"`). */
508
+ answer(panelId, answer) {
509
+ answers.get(panelId)?.(answer);
510
+ },
511
+ /** Answers the state's choice (`null`: cancelled). */
512
+ choose(value) {
513
+ choiceResolve?.(value);
514
+ },
515
+ /** A notice from the view (a welcome): in the transcript and the session log. */
516
+ notice,
517
+ /** The agent's text in the latest turn that ended, plain (for `/copy`). */
518
+ lastReply: () => latestReply,
519
+ /** Ends the chat: the agent run, the session log, the controller's port. */
520
+ close() {
521
+ if (state.closed)
522
+ return;
523
+ queue.end();
524
+ run.close();
525
+ unsubscribeMode?.();
526
+ for (const settle of [...answers.values()])
527
+ settle("");
528
+ choiceResolve?.(null);
529
+ if (settings.panels === "state")
530
+ setInteractionPort(previousPort);
531
+ sessionLog?.end();
532
+ update({ closed: true, busy: false });
533
+ },
534
+ };
535
+ return controller;
536
+ }
@@ -0,0 +1,18 @@
1
+ import { type InstalledExtension } from "../core/externalExtensions.js";
2
+ import type { ExtensionsStatus } from "../core/session.js";
3
+ /** One line for an installed extension: name and version, scope, on or off, its author, where it came from. */
4
+ export declare function describe(extension: InstalledExtension): string;
5
+ /**
6
+ * `/extensions` in a chat (#37): with nothing after it, what the session runs with, what's off
7
+ * and why, and what's installed; with `enable <name>` or `disable <name>`, changes its lock and
8
+ * says which (`toggled`): the chat turns it on or off in the running session when it can (#49),
9
+ * and opens the session again otherwise, keeping the conversation. `null` when `line` isn't this
10
+ * command.
11
+ */
12
+ export declare function chatExtensionsCommand(line: string, status: ExtensionsStatus | undefined, canReopen: boolean): Promise<{
13
+ lines: string[];
14
+ toggled?: {
15
+ name: string;
16
+ enabled: boolean;
17
+ };
18
+ } | null>;
@@ -0,0 +1,44 @@
1
+ import path from "node:path";
2
+ import { setExtensionEnabled } from "../core/externalExtensions.js";
3
+ import { t } from "../core/messages/index.js";
4
+ /** One line for an installed extension: name and version, scope, on or off, its author, where it came from. */
5
+ export function describe(extension) {
6
+ const state = extension.shadowed ? "not used (the project's wins)" : extension.lock.enabled ? "enabled" : "disabled";
7
+ // From a marketplace, its name there (`jokebook@shipyard`): the copy it came from is the kit's business.
8
+ const where = extension.lock.commit ? `${extension.lock.source} @ ${extension.lock.commit.slice(0, 12)}` : path.normalize(extension.lock.source);
9
+ const source = extension.lock.marketplace ? `${extension.lock.marketplace}${extension.lock.commit ? ` @ ${extension.lock.commit.slice(0, 12)}` : ""}` : where;
10
+ const version = extension.manifest?.version ? ` ${extension.manifest.version}` : "";
11
+ const author = extension.manifest?.author ? ` by ${extension.manifest.author.name}` : "";
12
+ return `${extension.name}${version} [${extension.scope}] ${state}${author} from ${source}`;
13
+ }
14
+ /**
15
+ * `/extensions` in a chat (#37): with nothing after it, what the session runs with, what's off
16
+ * and why, and what's installed; with `enable <name>` or `disable <name>`, changes its lock and
17
+ * says which (`toggled`): the chat turns it on or off in the running session when it can (#49),
18
+ * and opens the session again otherwise, keeping the conversation. `null` when `line` isn't this
19
+ * command.
20
+ */
21
+ export async function chatExtensionsCommand(line, status, canReopen) {
22
+ const [command, action, name, ...extra] = line.trim().split(/\s+/);
23
+ if (command?.toLowerCase() !== "/extensions")
24
+ return null;
25
+ if (!action) {
26
+ const lines = [status?.active.length ? t().extensionsRunning(status.active.join(", ")) : t().extensionsNone];
27
+ for (const { name: off, reason } of status?.inactive ?? [])
28
+ lines.push(t().extensionOff(off, reason));
29
+ if (status?.installed.length)
30
+ lines.push(t().extensionsInstalled, ...status.installed.map((extension) => ` ${describe(extension)}`));
31
+ return { lines };
32
+ }
33
+ const enable = action.toLowerCase() === "enable";
34
+ if ((!enable && action.toLowerCase() !== "disable") || !name || extra.length)
35
+ return { lines: [t().extensionsUsage] };
36
+ if (!canReopen)
37
+ return { lines: [t().extensionsCantReopen] };
38
+ const installed = (status?.installed ?? []).find((extension) => extension.name === name && !extension.shadowed);
39
+ const dir = installed ? status?.dirs[installed.scope] : undefined;
40
+ if (!installed || !dir)
41
+ return { lines: [t().extensionNotInstalled(name)] };
42
+ await setExtensionEnabled(dir, name, enable);
43
+ return { lines: [], toggled: { name, enabled: enable } };
44
+ }
@@ -0,0 +1,17 @@
1
+ /** One line the person sent, as the history file keeps it. */
2
+ export interface HistoryEntry {
3
+ text: string;
4
+ timestamp: string;
5
+ }
6
+ /** Keeps only the most recent `limit` entries (oldest-first order in, oldest-first out). */
7
+ export declare function capHistory(entries: readonly HistoryEntry[], limit: number): HistoryEntry[];
8
+ /** Oldest-first. Missing file reads as empty — nothing to load yet is the normal case. */
9
+ export declare function loadHistory(filePath: string): Promise<HistoryEntry[]>;
10
+ /** Overwrites the whole file (not appended) so a prior prune (oldest entries dropped) sticks. */
11
+ export declare function saveHistory(filePath: string, entries: readonly HistoryEntry[]): Promise<void>;
12
+ /**
13
+ * The command name a typed line would invoke (e.g. "chiste" for both "/chiste" and
14
+ * "/chiste con salsa"), or `null` for a line that isn't a slash command at all (doesn't
15
+ * start with "/") or is just a bare "/" with nothing after it.
16
+ */
17
+ export declare function slashCommandToken(line: string): string | null;
@@ -0,0 +1,39 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /** Keeps only the most recent `limit` entries (oldest-first order in, oldest-first out). */
4
+ export function capHistory(entries, limit) {
5
+ return entries.length > limit ? entries.slice(-limit) : entries.slice();
6
+ }
7
+ /** Oldest-first. Missing file reads as empty — nothing to load yet is the normal case. */
8
+ export async function loadHistory(filePath) {
9
+ let raw;
10
+ try {
11
+ raw = await readFile(filePath, "utf8");
12
+ }
13
+ catch (error) {
14
+ if (error.code === "ENOENT")
15
+ return [];
16
+ throw error;
17
+ }
18
+ return raw
19
+ .split("\n")
20
+ .filter((line) => line.trim().length > 0)
21
+ .map((line) => JSON.parse(line));
22
+ }
23
+ /** Overwrites the whole file (not appended) so a prior prune (oldest entries dropped) sticks. */
24
+ export async function saveHistory(filePath, entries) {
25
+ await mkdir(path.dirname(filePath), { recursive: true });
26
+ const body = entries.map((entry) => JSON.stringify(entry)).join("\n");
27
+ await writeFile(filePath, entries.length > 0 ? `${body}\n` : "", "utf8");
28
+ }
29
+ /**
30
+ * The command name a typed line would invoke (e.g. "chiste" for both "/chiste" and
31
+ * "/chiste con salsa"), or `null` for a line that isn't a slash command at all (doesn't
32
+ * start with "/") or is just a bare "/" with nothing after it.
33
+ */
34
+ export function slashCommandToken(line) {
35
+ if (!line.startsWith("/"))
36
+ return null;
37
+ const token = line.slice(1).split(/\s/, 1)[0];
38
+ return token ? token : null;
39
+ }