tinker-agent 1.0.65 → 1.2.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 (208) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +90 -9
  5. package/bin/tinker.js +39 -0
  6. package/node_modules/ansi-regex/index.d.ts +33 -0
  7. package/node_modules/ansi-regex/index.js +14 -0
  8. package/node_modules/ansi-regex/license +9 -0
  9. package/node_modules/ansi-regex/package.json +61 -0
  10. package/node_modules/ansi-regex/readme.md +66 -0
  11. package/node_modules/ansi-styles/index.d.ts +236 -0
  12. package/node_modules/ansi-styles/index.js +223 -0
  13. package/node_modules/ansi-styles/license +9 -0
  14. package/node_modules/ansi-styles/package.json +54 -0
  15. package/node_modules/ansi-styles/readme.md +173 -0
  16. package/node_modules/chalk/license +9 -0
  17. package/node_modules/chalk/package.json +83 -0
  18. package/node_modules/chalk/readme.md +297 -0
  19. package/node_modules/chalk/source/index.d.ts +325 -0
  20. package/node_modules/chalk/source/index.js +225 -0
  21. package/node_modules/chalk/source/utilities.js +33 -0
  22. package/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
  23. package/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
  24. package/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
  25. package/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
  26. package/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
  27. package/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
  28. package/node_modules/character-entities/index.d.ts +6 -0
  29. package/node_modules/character-entities/index.js +2132 -0
  30. package/node_modules/character-entities/license +22 -0
  31. package/node_modules/character-entities/package.json +78 -0
  32. package/node_modules/character-entities/readme.md +152 -0
  33. package/node_modules/decode-named-character-reference/index.d.ts +13 -0
  34. package/node_modules/decode-named-character-reference/index.d.ts.map +1 -0
  35. package/node_modules/decode-named-character-reference/index.dom.d.ts +6 -0
  36. package/node_modules/decode-named-character-reference/index.dom.d.ts.map +1 -0
  37. package/node_modules/decode-named-character-reference/index.dom.js +32 -0
  38. package/node_modules/decode-named-character-reference/index.js +19 -0
  39. package/node_modules/decode-named-character-reference/license +22 -0
  40. package/node_modules/decode-named-character-reference/package.json +90 -0
  41. package/node_modules/decode-named-character-reference/readme.md +136 -0
  42. package/node_modules/get-east-asian-width/index.d.ts +60 -0
  43. package/node_modules/get-east-asian-width/index.js +30 -0
  44. package/node_modules/get-east-asian-width/license +9 -0
  45. package/node_modules/get-east-asian-width/lookup-data.js +21 -0
  46. package/node_modules/get-east-asian-width/lookup.js +138 -0
  47. package/node_modules/get-east-asian-width/package.json +71 -0
  48. package/node_modules/get-east-asian-width/readme.md +65 -0
  49. package/node_modules/get-east-asian-width/utilities.js +24 -0
  50. package/node_modules/has-flag/index.d.ts +39 -0
  51. package/node_modules/has-flag/index.js +8 -0
  52. package/node_modules/has-flag/license +9 -0
  53. package/node_modules/has-flag/package.json +49 -0
  54. package/node_modules/has-flag/readme.md +74 -0
  55. package/node_modules/is-fullwidth-code-point/index.d.ts +17 -0
  56. package/node_modules/is-fullwidth-code-point/index.js +12 -0
  57. package/node_modules/is-fullwidth-code-point/license +9 -0
  58. package/node_modules/is-fullwidth-code-point/package.json +53 -0
  59. package/node_modules/is-fullwidth-code-point/readme.md +31 -0
  60. package/node_modules/markdansi/LICENSE +21 -0
  61. package/node_modules/markdansi/README.md +167 -0
  62. package/node_modules/markdansi/dist/ast.d.ts +99 -0
  63. package/node_modules/markdansi/dist/ast.js +1 -0
  64. package/node_modules/markdansi/dist/cli.d.ts +17 -0
  65. package/node_modules/markdansi/dist/cli.js +208 -0
  66. package/node_modules/markdansi/dist/hyperlink.d.ts +9 -0
  67. package/node_modules/markdansi/dist/hyperlink.js +26 -0
  68. package/node_modules/markdansi/dist/index.d.ts +10 -0
  69. package/node_modules/markdansi/dist/index.js +16 -0
  70. package/node_modules/markdansi/dist/parser.d.ts +2 -0
  71. package/node_modules/markdansi/dist/parser.js +205 -0
  72. package/node_modules/markdansi/dist/render.d.ts +9 -0
  73. package/node_modules/markdansi/dist/render.js +680 -0
  74. package/node_modules/markdansi/dist/stream.d.ts +39 -0
  75. package/node_modules/markdansi/dist/stream.js +192 -0
  76. package/node_modules/markdansi/dist/theme.d.ts +18 -0
  77. package/node_modules/markdansi/dist/theme.js +105 -0
  78. package/node_modules/markdansi/dist/types.d.ts +58 -0
  79. package/node_modules/markdansi/dist/types.js +1 -0
  80. package/node_modules/markdansi/dist/wrap.d.ts +10 -0
  81. package/node_modules/markdansi/dist/wrap.js +73 -0
  82. package/node_modules/markdansi/docs/spec.md +110 -0
  83. package/node_modules/markdansi/package.json +78 -0
  84. package/node_modules/markdansi/tsconfig.json +18 -0
  85. package/node_modules/marked/LICENSE +44 -0
  86. package/node_modules/marked/README.md +115 -0
  87. package/node_modules/marked/bin/main.js +284 -0
  88. package/node_modules/marked/bin/marked.js +16 -0
  89. package/node_modules/marked/lib/marked.d.ts +760 -0
  90. package/node_modules/marked/lib/marked.esm.js +77 -0
  91. package/node_modules/marked/lib/marked.esm.js.map +7 -0
  92. package/node_modules/marked/lib/marked.umd.js +79 -0
  93. package/node_modules/marked/lib/marked.umd.js.map +7 -0
  94. package/node_modules/marked/man/marked.1 +113 -0
  95. package/node_modules/marked/man/marked.1.md +93 -0
  96. package/node_modules/marked/package.json +103 -0
  97. package/node_modules/slice-ansi/index.d.ts +19 -0
  98. package/node_modules/slice-ansi/index.js +317 -0
  99. package/node_modules/slice-ansi/license +10 -0
  100. package/node_modules/slice-ansi/package.json +59 -0
  101. package/node_modules/slice-ansi/readme.md +55 -0
  102. package/node_modules/slice-ansi/tokenize-ansi.js +752 -0
  103. package/node_modules/string-width/index.d.ts +39 -0
  104. package/node_modules/string-width/index.js +203 -0
  105. package/node_modules/string-width/license +9 -0
  106. package/node_modules/string-width/package.json +65 -0
  107. package/node_modules/string-width/readme.md +66 -0
  108. package/node_modules/strip-ansi/index.d.ts +15 -0
  109. package/node_modules/strip-ansi/index.js +19 -0
  110. package/node_modules/strip-ansi/license +9 -0
  111. package/node_modules/strip-ansi/package.json +59 -0
  112. package/node_modules/strip-ansi/readme.md +37 -0
  113. package/node_modules/supports-color/browser.d.ts +1 -0
  114. package/node_modules/supports-color/browser.js +35 -0
  115. package/node_modules/supports-color/index.d.ts +55 -0
  116. package/node_modules/supports-color/index.js +202 -0
  117. package/node_modules/supports-color/license +9 -0
  118. package/node_modules/supports-color/package.json +64 -0
  119. package/node_modules/supports-color/readme.md +75 -0
  120. package/node_modules/supports-hyperlinks/browser.js +10 -0
  121. package/node_modules/supports-hyperlinks/index.d.ts +25 -0
  122. package/node_modules/supports-hyperlinks/index.js +163 -0
  123. package/node_modules/supports-hyperlinks/license +10 -0
  124. package/node_modules/supports-hyperlinks/package.json +47 -0
  125. package/node_modules/supports-hyperlinks/readme.md +45 -0
  126. package/package.json +41 -10
  127. package/patches/markdansi@0.3.2.patch +22 -2
  128. package/src/agent/context-builder.ts +24 -10
  129. package/src/agent/context-meter.ts +89 -5
  130. package/src/agent/loop.ts +107 -10
  131. package/src/agent/runtime-session.ts +1437 -82
  132. package/src/agent/session-ledger.ts +169 -29
  133. package/src/agent/types.ts +12 -1
  134. package/src/agent/user-prompt-projection.ts +62 -0
  135. package/src/cli/config.ts +57 -14
  136. package/src/cli/model-profiles.ts +124 -0
  137. package/src/cli/run-runner.ts +4 -1
  138. package/src/cli/tui-runner.tsx +68 -30
  139. package/src/context/compiled-context-hash.ts +1 -1
  140. package/src/context/compiled-context-validator.ts +92 -15
  141. package/src/context/context-automation-policy.ts +121 -0
  142. package/src/context/context-manager.ts +390 -11
  143. package/src/context/context-policy.ts +8 -0
  144. package/src/context/context-protocol-validator.ts +11 -1
  145. package/src/context/context-revision-compiler.ts +190 -27
  146. package/src/context/context-revision.ts +105 -19
  147. package/src/context/context-surface.ts +329 -0
  148. package/src/context/prefix-retirement-planner.ts +570 -0
  149. package/src/context/protocol-frame.ts +30 -3
  150. package/src/context/recall-retirement-contract.ts +27 -0
  151. package/src/context/swap-planner.ts +34 -10
  152. package/src/events/observation-text-log.ts +3 -3
  153. package/src/events/stdout-event-printer.ts +39 -3
  154. package/src/events/types.ts +172 -35
  155. package/src/ids/runtime-id.ts +6 -0
  156. package/src/ids/uuid-v7.ts +7 -0
  157. package/src/image/image-asset-store.ts +319 -0
  158. package/src/image/image-input-policy.ts +14 -0
  159. package/src/image/image-probe.ts +233 -0
  160. package/src/image/image-types.ts +262 -0
  161. package/src/instructions/project-instructions.ts +4 -1
  162. package/src/mcp/mcp-manager.ts +83 -19
  163. package/src/model/fake-model-client.ts +9 -1
  164. package/src/model/input-token-estimator.ts +25 -0
  165. package/src/model/model-client.ts +60 -0
  166. package/src/model/model-request-preflight.ts +1 -0
  167. package/src/model/moonshot-input-token-estimator.ts +107 -0
  168. package/src/model/openai-chat-mapping.ts +77 -1
  169. package/src/model/openai-chat-model-client.ts +389 -59
  170. package/src/model/openai-chat-stream.ts +272 -0
  171. package/src/model/prompt-prefix-hash.ts +2 -4
  172. package/src/model/token-estimator.ts +4 -0
  173. package/src/observation/observation-builder.ts +29 -0
  174. package/src/session/resume-projection.ts +94 -34
  175. package/src/session/session-catalog.ts +4 -4
  176. package/src/session/session-errors.ts +1 -1
  177. package/src/session/session-history-reader.ts +151 -4
  178. package/src/session/session-last-response-reader.ts +189 -0
  179. package/src/session/session-schema.ts +349 -35
  180. package/src/session/session-store.ts +3266 -255
  181. package/src/session/sqlite-session-ledger.ts +9 -4
  182. package/src/skills/skill-catalog.ts +182 -0
  183. package/src/skills/skill-context.ts +364 -0
  184. package/src/skills/skill-loader.ts +779 -0
  185. package/src/skills/skill-tool.ts +297 -0
  186. package/src/tools/edit.ts +51 -64
  187. package/src/tools/ensure-parent-directory.ts +6 -0
  188. package/src/tools/read.ts +2 -2
  189. package/src/tools/recall.ts +43 -40
  190. package/src/tools/registry.ts +24 -3
  191. package/src/tools/ripgrep.ts +3 -2
  192. package/src/tools/types.ts +43 -4
  193. package/src/tools/write.ts +20 -31
  194. package/src/tui/app.tsx +333 -96
  195. package/src/tui/clipboard.ts +5 -0
  196. package/src/tui/components/mcp-panel.tsx +28 -0
  197. package/src/tui/components/prompt-input.tsx +790 -257
  198. package/src/tui/components/skills-panel.tsx +31 -0
  199. package/src/tui/components/timeline.tsx +30 -1
  200. package/src/tui/event-store.ts +85 -2
  201. package/src/tui/file-mention.ts +234 -0
  202. package/src/tui/line-editor.ts +1 -0
  203. package/src/tui/project-slash-commands.ts +229 -0
  204. package/src/tui/prompt-draft.ts +510 -0
  205. package/src/tui/prompt-history.ts +294 -22
  206. package/src/tui/slash-commands.ts +46 -2
  207. package/src/tui/tui-session-controller.ts +110 -48
  208. package/src/tui/workspace-file-search.ts +94 -0
@@ -1,23 +1,66 @@
1
1
  import { appendFile, mkdir, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import type { RuntimeIdFactory } from "../ids/runtime-id";
4
+ import {
5
+ parseImageAssetId,
6
+ parseImageAttachmentId,
7
+ type ImageAttachmentId,
8
+ } from "../image/image-types";
9
+ import { stableJsonStringify } from "../model/model-request-preflight";
10
+ import {
11
+ createPromptDraft,
12
+ validatePromptDraft,
13
+ type DraftImageAttachment,
14
+ type PromptDraft,
15
+ type PromptElement,
16
+ } from "./prompt-draft";
3
17
 
4
18
  const DEFAULT_MAX_ENTRIES = 200;
5
19
 
20
+ export type PromptHistoryEntry =
21
+ | { readonly version: 1; readonly text: string }
22
+ | {
23
+ readonly version: 2;
24
+ readonly text: string;
25
+ readonly elements: readonly PromptElement[];
26
+ readonly attachments: readonly DraftImageAttachment[];
27
+ };
28
+
29
+ export type LoadedPromptHistoryRecord =
30
+ | {
31
+ readonly kind: "valid";
32
+ readonly lineNumber: number;
33
+ readonly entry: PromptHistoryEntry;
34
+ }
35
+ | {
36
+ readonly kind: "invalid";
37
+ readonly lineNumber: number;
38
+ readonly errorCode: "INVALID_JSON" | "UNSUPPORTED_VERSION" | "INVALID_ENTRY";
39
+ };
40
+
6
41
  export type PromptHistoryOptions = {
7
42
  filePath?: string;
8
43
  entries?: string[];
44
+ records?: LoadedPromptHistoryRecord[];
9
45
  maxEntries?: number;
10
46
  };
11
47
 
12
48
  export class PromptHistory {
13
49
  readonly filePath?: string;
14
50
  private readonly maxEntries: number;
15
- private items: string[];
51
+ private items: LoadedPromptHistoryRecord[];
16
52
 
17
53
  constructor(options: PromptHistoryOptions = {}) {
18
54
  this.filePath = options.filePath;
19
55
  this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
20
- this.items = trimToMax(options.entries ?? [], this.maxEntries);
56
+ const records =
57
+ options.records ??
58
+ (options.entries ?? []).map((text, index) => ({
59
+ kind: "valid" as const,
60
+ lineNumber: index + 1,
61
+ entry: Object.freeze({ version: 1 as const, text }),
62
+ }));
63
+ this.items = trimToMax(records, this.maxEntries);
21
64
  }
22
65
 
23
66
  static async load(
@@ -34,56 +77,285 @@ export class PromptHistory {
34
77
  throw error;
35
78
  }
36
79
 
37
- const entries: string[] = [];
38
- for (const line of content.split("\n")) {
80
+ const records: LoadedPromptHistoryRecord[] = [];
81
+ for (const [index, line] of content.split("\n").entries()) {
39
82
  if (line.trim() === "") {
40
83
  continue;
41
84
  }
42
-
43
- const parsed = parsePromptLine(line);
44
- if (parsed !== undefined) {
45
- entries.push(parsed);
46
- }
85
+ records.push(parsePromptLine(line, index + 1));
47
86
  }
87
+ return new PromptHistory({
88
+ filePath,
89
+ records,
90
+ maxEntries: options.maxEntries,
91
+ });
92
+ }
48
93
 
49
- return new PromptHistory({ filePath, entries, maxEntries: options.maxEntries });
94
+ get records(): readonly LoadedPromptHistoryRecord[] {
95
+ return this.items;
50
96
  }
51
97
 
52
98
  get entries(): readonly string[] {
53
- return this.items;
99
+ return this.items.flatMap((record) =>
100
+ record.kind === "valid" ? [record.entry.text] : [],
101
+ );
54
102
  }
55
103
 
56
- async append(prompt: string): Promise<void> {
57
- if (prompt === "" || this.items.at(-1) === prompt) {
104
+ async append(prompt: string | PromptDraft): Promise<void> {
105
+ const entry =
106
+ typeof prompt === "string"
107
+ ? Object.freeze({ version: 1 as const, text: prompt })
108
+ : historyEntryFromDraft(prompt);
109
+ if (entry.text === "") {
58
110
  return;
59
111
  }
60
-
61
- this.items = trimToMax([...this.items, prompt], this.maxEntries);
112
+ const previous = this.items.at(-1);
113
+ if (
114
+ previous?.kind === "valid" &&
115
+ historySemanticKey(previous.entry) === historySemanticKey(entry)
116
+ ) {
117
+ return;
118
+ }
119
+ const lineNumber = (this.items.at(-1)?.lineNumber ?? 0) + 1;
120
+ this.items = trimToMax(
121
+ [...this.items, { kind: "valid", lineNumber, entry }],
122
+ this.maxEntries,
123
+ );
62
124
 
63
125
  if (this.filePath === undefined) {
64
126
  return;
65
127
  }
66
-
67
128
  await mkdir(path.dirname(this.filePath), { recursive: true });
68
- await appendFile(this.filePath, `${JSON.stringify(prompt)}\n`, "utf8");
129
+ const serialized =
130
+ entry.version === 1 ? JSON.stringify(entry.text) : stableJsonStringify(entry);
131
+ await appendFile(this.filePath, `${serialized}\n`, "utf8");
69
132
  }
70
133
  }
71
134
 
72
- function parsePromptLine(line: string): string | undefined {
135
+ export function restorePromptHistoryEntry(
136
+ entry: PromptHistoryEntry,
137
+ idFactory: Pick<RuntimeIdFactory, "createImageAttachmentId">,
138
+ ): PromptDraft {
139
+ if (entry.version === 1) {
140
+ return createPromptDraft(entry.text);
141
+ }
142
+ const remap = new Map<ImageAttachmentId, ImageAttachmentId>();
143
+ for (const attachment of entry.attachments) {
144
+ remap.set(attachment.attachmentId, idFactory.createImageAttachmentId());
145
+ }
146
+ const elements = entry.elements.map((element) =>
147
+ Object.freeze({
148
+ ...element,
149
+ attachmentId: requireRemapped(remap, element.attachmentId),
150
+ range: Object.freeze({ ...element.range }),
151
+ }),
152
+ );
153
+ const attachments = entry.attachments.map((attachment) =>
154
+ Object.freeze({
155
+ ...attachment,
156
+ attachmentId: requireRemapped(remap, attachment.attachmentId),
157
+ asset: Object.freeze({ ...attachment.asset }),
158
+ }),
159
+ );
160
+ const draft = Object.freeze({
161
+ editor: Object.freeze({
162
+ value: entry.text,
163
+ cursor: [...entry.text].length,
164
+ }),
165
+ elements: Object.freeze(elements),
166
+ attachments: Object.freeze(attachments),
167
+ });
168
+ validatePromptDraft(draft);
169
+ return draft;
170
+ }
171
+
172
+ function parsePromptLine(line: string, lineNumber: number): LoadedPromptHistoryRecord {
173
+ let parsed: unknown;
174
+ try {
175
+ parsed = JSON.parse(line);
176
+ } catch {
177
+ return { kind: "invalid", lineNumber, errorCode: "INVALID_JSON" };
178
+ }
179
+ if (typeof parsed === "string") {
180
+ return parsed === ""
181
+ ? { kind: "invalid", lineNumber, errorCode: "INVALID_ENTRY" }
182
+ : {
183
+ kind: "valid",
184
+ lineNumber,
185
+ entry: Object.freeze({ version: 1, text: parsed }),
186
+ };
187
+ }
188
+ if (!isRecord(parsed)) {
189
+ return { kind: "invalid", lineNumber, errorCode: "INVALID_ENTRY" };
190
+ }
191
+ if (parsed.version !== 2) {
192
+ return { kind: "invalid", lineNumber, errorCode: "UNSUPPORTED_VERSION" };
193
+ }
73
194
  try {
74
- const parsed: unknown = JSON.parse(line);
75
- return typeof parsed === "string" && parsed !== "" ? parsed : undefined;
195
+ const entry = decodeV2Entry(parsed);
196
+ return { kind: "valid", lineNumber, entry };
76
197
  } catch {
77
- return undefined;
198
+ return { kind: "invalid", lineNumber, errorCode: "INVALID_ENTRY" };
199
+ }
200
+ }
201
+
202
+ function decodeV2Entry(record: Record<string, unknown>): PromptHistoryEntry {
203
+ assertKeys(record, ["version", "text", "elements", "attachments"]);
204
+ if (
205
+ typeof record.text !== "string" ||
206
+ !Array.isArray(record.elements) ||
207
+ !Array.isArray(record.attachments) ||
208
+ record.elements.length === 0
209
+ ) {
210
+ throw new Error("Invalid Prompt history v2 entry.");
211
+ }
212
+ const elements = record.elements.map((value): PromptElement => {
213
+ const element = requireRecord(value);
214
+ assertKeys(element, ["kind", "attachmentId", "label", "range"]);
215
+ const range = requireRecord(element.range);
216
+ assertKeys(range, ["start", "end"]);
217
+ if (
218
+ element.kind !== "image" ||
219
+ typeof element.attachmentId !== "string" ||
220
+ typeof element.label !== "string" ||
221
+ !Number.isSafeInteger(range.start) ||
222
+ !Number.isSafeInteger(range.end)
223
+ ) {
224
+ throw new Error("Invalid Prompt history image element.");
225
+ }
226
+ return Object.freeze({
227
+ kind: "image",
228
+ attachmentId: parseImageAttachmentId(element.attachmentId),
229
+ label: element.label,
230
+ range: Object.freeze({ start: range.start as number, end: range.end as number }),
231
+ });
232
+ });
233
+ const attachments = record.attachments.map((value): DraftImageAttachment => {
234
+ const attachment = requireRecord(value);
235
+ assertKeys(attachment, ["attachmentId", "asset", "originalName"]);
236
+ const asset = requireRecord(attachment.asset);
237
+ assertKeys(asset, ["assetId", "mimeType", "byteLength", "width", "height"]);
238
+ if (
239
+ typeof attachment.attachmentId !== "string" ||
240
+ typeof attachment.originalName !== "string" ||
241
+ typeof asset.assetId !== "string" ||
242
+ (asset.mimeType !== "image/png" &&
243
+ asset.mimeType !== "image/jpeg" &&
244
+ asset.mimeType !== "image/webp") ||
245
+ !Number.isSafeInteger(asset.byteLength) ||
246
+ !Number.isSafeInteger(asset.width) ||
247
+ !Number.isSafeInteger(asset.height)
248
+ ) {
249
+ throw new Error("Invalid Prompt history image attachment.");
250
+ }
251
+ return Object.freeze({
252
+ attachmentId: parseImageAttachmentId(attachment.attachmentId),
253
+ asset: Object.freeze({
254
+ assetId: parseImageAssetId(asset.assetId),
255
+ mimeType: asset.mimeType,
256
+ byteLength: asset.byteLength as number,
257
+ width: asset.width as number,
258
+ height: asset.height as number,
259
+ }),
260
+ originalName: attachment.originalName,
261
+ });
262
+ });
263
+ const draft = Object.freeze({
264
+ editor: Object.freeze({
265
+ value: record.text,
266
+ cursor: [...record.text].length,
267
+ }),
268
+ elements: Object.freeze(elements),
269
+ attachments: Object.freeze(attachments),
270
+ });
271
+ validatePromptDraft(draft);
272
+ return historyEntryFromDraft(draft);
273
+ }
274
+
275
+ function historyEntryFromDraft(draft: PromptDraft): PromptHistoryEntry {
276
+ validatePromptDraft(draft);
277
+ if (draft.elements.length === 0) {
278
+ return Object.freeze({ version: 1, text: draft.editor.value });
78
279
  }
280
+ return Object.freeze({
281
+ version: 2,
282
+ text: draft.editor.value,
283
+ elements: Object.freeze(
284
+ draft.elements.map((element) =>
285
+ Object.freeze({
286
+ ...element,
287
+ range: Object.freeze({ ...element.range }),
288
+ }),
289
+ ),
290
+ ),
291
+ attachments: Object.freeze(
292
+ draft.attachments.map((attachment) =>
293
+ Object.freeze({
294
+ ...attachment,
295
+ asset: Object.freeze({ ...attachment.asset }),
296
+ }),
297
+ ),
298
+ ),
299
+ });
300
+ }
301
+
302
+ function historySemanticKey(entry: PromptHistoryEntry): string {
303
+ return stableJsonStringify(
304
+ entry.version === 1
305
+ ? entry
306
+ : {
307
+ version: 2,
308
+ text: entry.text,
309
+ elements: entry.elements.map((element) => {
310
+ const { attachmentId, ...semantic } = element;
311
+ void attachmentId;
312
+ return semantic;
313
+ }),
314
+ attachments: entry.attachments.map((attachment) => {
315
+ const { attachmentId, ...semantic } = attachment;
316
+ void attachmentId;
317
+ return semantic;
318
+ }),
319
+ },
320
+ );
79
321
  }
80
322
 
81
- function trimToMax(entries: string[], maxEntries: number): string[] {
323
+ function requireRemapped(
324
+ remap: ReadonlyMap<ImageAttachmentId, ImageAttachmentId>,
325
+ previous: ImageAttachmentId,
326
+ ): ImageAttachmentId {
327
+ const value = remap.get(previous);
328
+ if (value === undefined) {
329
+ throw new Error("Prompt history attachment remap is incomplete.");
330
+ }
331
+ return value;
332
+ }
333
+
334
+ function requireRecord(value: unknown): Record<string, unknown> {
335
+ if (!isRecord(value)) {
336
+ throw new Error("Expected an object.");
337
+ }
338
+ return value;
339
+ }
340
+
341
+ function assertKeys(value: Record<string, unknown>, expected: readonly string[]): void {
342
+ const actual = Object.keys(value).sort();
343
+ const wanted = [...expected].sort();
344
+ if (stableJsonStringify(actual) !== stableJsonStringify(wanted)) {
345
+ throw new Error("Prompt history object keys are invalid.");
346
+ }
347
+ }
348
+
349
+ function trimToMax<T>(entries: T[], maxEntries: number): T[] {
82
350
  return entries.length > maxEntries
83
351
  ? entries.slice(entries.length - maxEntries)
84
352
  : entries;
85
353
  }
86
354
 
355
+ function isRecord(value: unknown): value is Record<string, unknown> {
356
+ return typeof value === "object" && value !== null && !Array.isArray(value);
357
+ }
358
+
87
359
  function isFileNotFound(error: unknown): boolean {
88
360
  return (
89
361
  typeof error === "object" &&
@@ -5,8 +5,16 @@ export type SlashCommand = {
5
5
 
6
6
  export const SLASH_COMMANDS: readonly SlashCommand[] = [
7
7
  { name: "status", description: "Show session and context details" },
8
- { name: "compact", description: "Compact eligible historical tool output" },
8
+ { name: "skills", description: "Show available and active Agent Skills" },
9
+ { name: "mcp", description: "Show MCP servers and runtime tools" },
10
+ {
11
+ name: "compact",
12
+ description: "Swap tool output or retire a cold history prefix",
13
+ },
14
+ { name: "clear", description: "Start a new session and clear conversation" },
15
+ { name: "fork", description: "Clone the current session" },
9
16
  { name: "view", description: "View a local UTF-8 text file" },
17
+ { name: "copy", description: "Copy the last response as Markdown" },
10
18
  { name: "model", description: "Switch model profile (new session)" },
11
19
  { name: "resume", description: "Choose or resume a session" },
12
20
  { name: "session", description: "Manage stored sessions" },
@@ -15,8 +23,14 @@ export const SLASH_COMMANDS: readonly SlashCommand[] = [
15
23
 
16
24
  export type ParsedSlashCommand =
17
25
  | { type: "status" }
26
+ | { type: "skills" }
27
+ | { type: "mcp" }
18
28
  | { type: "compact" }
29
+ | { type: "compact_retire" }
30
+ | { type: "clear" }
31
+ | { type: "fork" }
19
32
  | { type: "view"; filePath: string }
33
+ | { type: "copy" }
20
34
  | { type: "quit" }
21
35
  | { type: "model" }
22
36
  | { type: "model_switch"; profileName: string }
@@ -49,11 +63,41 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
49
63
  if (command === "/status" && tokens.length === 1) {
50
64
  return { type: "status" };
51
65
  }
66
+ if (command === "/skills" && tokens.length === 1) {
67
+ return { type: "skills" };
68
+ }
69
+ if (command === "/mcp") {
70
+ if (tokens.length === 1) {
71
+ return { type: "mcp" };
72
+ }
73
+ throw new SlashCommandError("Usage: /mcp");
74
+ }
52
75
  if (command === "/compact") {
53
76
  if (tokens.length === 1) {
54
77
  return { type: "compact" };
55
78
  }
56
- throw new SlashCommandError("Usage: /compact");
79
+ if (tokens.length === 2 && tokens[1] === "retire") {
80
+ return { type: "compact_retire" };
81
+ }
82
+ throw new SlashCommandError("Usage: /compact [retire]");
83
+ }
84
+ if (command === "/clear") {
85
+ if (tokens.length === 1) {
86
+ return { type: "clear" };
87
+ }
88
+ throw new SlashCommandError("Usage: /clear");
89
+ }
90
+ if (command === "/fork") {
91
+ if (tokens.length === 1) {
92
+ return { type: "fork" };
93
+ }
94
+ throw new SlashCommandError("Usage: /fork");
95
+ }
96
+ if (command === "/copy") {
97
+ if (tokens.length === 1) {
98
+ return { type: "copy" };
99
+ }
100
+ throw new SlashCommandError("Usage: /copy");
57
101
  }
58
102
  if (command === "/quit" && tokens.length === 1) {
59
103
  return { type: "quit" };
@@ -1,12 +1,21 @@
1
- import type { ContextCompactionResult } from "../context/context-manager";
1
+ import type {
2
+ ContextCompactionResult,
3
+ ContextRetirementResult,
4
+ } from "../context/context-manager";
2
5
  import type {
3
6
  ExecuteTurnInput,
7
+ AcceptedTurn,
4
8
  RuntimeSession,
9
+ RuntimeSkillsSnapshot,
5
10
  SessionDisposeReason,
6
11
  } from "../agent/runtime-session";
7
- import type { RunAgentResult } from "../agent/types";
12
+ import type { RunAgentResult, UserMessage } from "../agent/types";
13
+ import type { ImageAssetRef } from "../image/image-types";
14
+ import type { ImportedImageAsset } from "../image/image-asset-store";
8
15
  import type { SessionId } from "../ids/runtime-id";
16
+ import { createUuidV7 } from "../ids/uuid-v7";
9
17
  import type { ModelProfile } from "../cli/model-profiles";
18
+ import type { McpInventorySnapshot } from "../mcp/mcp-manager";
10
19
  import { SessionCatalog, type SessionSummary } from "../session/session-catalog";
11
20
  import type { TuiProjectionStore } from "./tui-projection-store";
12
21
 
@@ -16,7 +25,20 @@ export type TuiSessionBinding = {
16
25
  workspaceRoot: string;
17
26
  profileName?: string;
18
27
  projectionStore: TuiProjectionStore;
19
- executeTurn(userPrompt: string, signal: AbortSignal): Promise<RunAgentResult>;
28
+ skills(): RuntimeSkillsSnapshot;
29
+ mcp(): McpInventorySnapshot;
30
+ supportsImageInput?: () => boolean;
31
+ importImage?: (
32
+ sourcePath: string,
33
+ signal: AbortSignal,
34
+ prospectiveMessageImageCount: number,
35
+ ) => Promise<ImportedImageAsset>;
36
+ verifyImageAssets?: (
37
+ assets: readonly ImageAssetRef[],
38
+ signal: AbortSignal,
39
+ ) => Promise<void>;
40
+ admitTurn?: (userMessage: UserMessage, signal: AbortSignal) => Promise<AcceptedTurn>;
41
+ executeTurn(userMessage: UserMessage, signal: AbortSignal): Promise<RunAgentResult>;
20
42
  };
21
43
 
22
44
  export type TuiSessionController = {
@@ -24,6 +46,9 @@ export type TuiSessionController = {
24
46
  subscribe: (listener: () => void) => () => void;
25
47
  listSessions: () => Promise<readonly SessionSummary[]>;
26
48
  compact: () => Promise<ContextCompactionResult>;
49
+ retire: () => Promise<ContextRetirementResult>;
50
+ fork: () => Promise<SessionId>;
51
+ clear: () => Promise<void>;
27
52
  resume: (sessionId: SessionId) => Promise<void>;
28
53
  delete: (sessionId: SessionId) => Promise<void>;
29
54
  switchModel: (profile: ModelProfile) => Promise<void>;
@@ -47,6 +72,9 @@ export class DefaultTuiSessionController implements TuiSessionController {
47
72
  private readonly createSessionWithProfile: (
48
73
  profile: ModelProfile,
49
74
  ) => Promise<ManagedTuiSessionBinding>,
75
+ private readonly createFreshSession: (
76
+ current: TuiSessionBinding,
77
+ ) => Promise<ManagedTuiSessionBinding>,
50
78
  ) {
51
79
  this.binding = initial;
52
80
  }
@@ -68,31 +96,42 @@ export class DefaultTuiSessionController implements TuiSessionController {
68
96
  return this.serialize(() => this.binding.runtimeSession.compactContext());
69
97
  }
70
98
 
99
+ retire(): Promise<ContextRetirementResult> {
100
+ return this.serialize(() => this.binding.runtimeSession.retireContext());
101
+ }
102
+
103
+ fork(): Promise<SessionId> {
104
+ return this.serialize(async () => {
105
+ const targetSessionId = createUuidV7() as SessionId;
106
+ await this.replaceSession(
107
+ "Cannot clone the session while a turn, context operation, or background task is active.",
108
+ async (current) => {
109
+ await current.runtimeSession.cloneSession(targetSessionId);
110
+ return this.openSession(targetSessionId);
111
+ },
112
+ );
113
+ return targetSessionId;
114
+ });
115
+ }
116
+
117
+ clear(): Promise<void> {
118
+ return this.serialize(() =>
119
+ this.replaceSession(
120
+ "Cannot clear the session while a turn, context operation, or background task is active.",
121
+ (current) => this.createFreshSession(current),
122
+ ),
123
+ );
124
+ }
125
+
71
126
  resume(sessionId: SessionId): Promise<void> {
72
127
  return this.serialize(async () => {
73
- const current = this.binding;
74
- if (sessionId === current.sessionId) {
128
+ if (sessionId === this.binding.sessionId) {
75
129
  throw new Error(`Session ${sessionId} is already current.`);
76
130
  }
77
- if (!current.runtimeSession.canSwitchSession()) {
78
- throw new Error(
79
- "Cannot switch sessions while a turn or background task is active.",
80
- );
81
- }
82
-
83
- const target = await this.openSession(sessionId);
84
- try {
85
- await current.runtimeSession.dispose({ type: "session_switch" });
86
- } catch (error) {
87
- await target.runtimeSession
88
- .dispose({ type: "runner_failed", error: errorMessage(error) })
89
- .catch(() => undefined);
90
- throw error;
91
- }
92
- this.binding = target;
93
- for (const listener of this.listeners) {
94
- listener();
95
- }
131
+ await this.replaceSession(
132
+ "Cannot switch sessions while a turn or background task is active.",
133
+ () => this.openSession(sessionId),
134
+ );
96
135
  });
97
136
  }
98
137
 
@@ -101,34 +140,44 @@ export class DefaultTuiSessionController implements TuiSessionController {
101
140
  }
102
141
 
103
142
  switchModel(profile: ModelProfile): Promise<void> {
104
- return this.serialize(async () => {
105
- const current = this.binding;
106
- if (!current.runtimeSession.canSwitchSession()) {
107
- throw new Error(
108
- "Cannot switch models while a turn or background task is active.",
109
- );
110
- }
111
-
112
- const target = await this.createSessionWithProfile(profile);
113
- try {
114
- await current.runtimeSession.dispose({ type: "session_switch" });
115
- } catch (error) {
116
- await target.runtimeSession
117
- .dispose({ type: "runner_failed", error: errorMessage(error) })
118
- .catch(() => undefined);
119
- throw error;
120
- }
121
- this.binding = target;
122
- for (const listener of this.listeners) {
123
- listener();
124
- }
125
- });
143
+ return this.serialize(() =>
144
+ this.replaceSession(
145
+ "Cannot switch models while a turn or background task is active.",
146
+ () => this.createSessionWithProfile(profile),
147
+ ),
148
+ );
126
149
  }
127
150
 
128
151
  dispose(reason: SessionDisposeReason): Promise<void> {
129
152
  return this.binding.runtimeSession.dispose(reason);
130
153
  }
131
154
 
155
+ private async replaceSession(
156
+ unavailableMessage: string,
157
+ createTarget: (
158
+ current: ManagedTuiSessionBinding,
159
+ ) => Promise<ManagedTuiSessionBinding>,
160
+ ): Promise<void> {
161
+ const current = this.binding;
162
+ if (!current.runtimeSession.canSwitchSession()) {
163
+ throw new Error(unavailableMessage);
164
+ }
165
+
166
+ const target = await createTarget(current);
167
+ try {
168
+ await current.runtimeSession.dispose({ type: "session_switch" });
169
+ } catch (error) {
170
+ await target.runtimeSession
171
+ .dispose({ type: "runner_failed", error: errorMessage(error) })
172
+ .catch(() => undefined);
173
+ throw error;
174
+ }
175
+ this.binding = target;
176
+ for (const listener of this.listeners) {
177
+ listener();
178
+ }
179
+ }
180
+
132
181
  private serialize<T>(operation: () => Promise<T>): Promise<T> {
133
182
  if (this.operation !== undefined) {
134
183
  return Promise.reject(new Error("Another session operation is already running."));
@@ -157,9 +206,22 @@ export function managedTuiBinding(input: {
157
206
  profileName: input.profileName,
158
207
  projectionStore: input.projectionStore,
159
208
  runtimeSession: input.runtimeSession,
160
- executeTurn: (userPrompt, signal) =>
209
+ skills: () => input.runtimeSession.skills(),
210
+ mcp: () => input.runtimeSession.mcp(),
211
+ supportsImageInput: () => input.runtimeSession.supportsImageInput(),
212
+ importImage: (sourcePath, signal, prospectiveMessageImageCount) =>
213
+ input.runtimeSession.importImage(
214
+ sourcePath,
215
+ signal,
216
+ prospectiveMessageImageCount,
217
+ ),
218
+ verifyImageAssets: (assets, signal) =>
219
+ input.runtimeSession.verifyImageAssets(assets, signal),
220
+ admitTurn: (userMessage, signal) =>
221
+ input.runtimeSession.admitTurn({ userMessage, signal }),
222
+ executeTurn: (userMessage, signal) =>
161
223
  input.runtimeSession.executeTurn({
162
- userPrompt,
224
+ userMessage,
163
225
  signal,
164
226
  } satisfies ExecuteTurnInput),
165
227
  };