tinker-agent 1.3.0 → 1.5.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 (49) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +271 -72
  3. package/bin/tinker.js +75 -25
  4. package/package.json +12 -3
  5. package/src/agent/runtime-session.ts +113 -15
  6. package/src/cli/command-line.ts +291 -0
  7. package/src/cli/config.ts +158 -262
  8. package/src/cli/index.ts +33 -21
  9. package/src/cli/main.ts +213 -0
  10. package/src/cli/model-profiles.ts +226 -72
  11. package/src/cli/output.ts +113 -0
  12. package/src/cli/package-metadata.ts +36 -0
  13. package/src/cli/prompt-source.ts +229 -0
  14. package/src/cli/public-cli-contract.ts +69 -0
  15. package/src/cli/public-config-contract.ts +732 -0
  16. package/src/cli/run-runner.ts +17 -12
  17. package/src/cli/runner-dependencies.ts +108 -0
  18. package/src/cli/tui-memory.ts +67 -0
  19. package/src/cli/tui-runner.tsx +79 -49
  20. package/src/context/context-policy.ts +2 -2
  21. package/src/events/stdout-event-printer.ts +1 -0
  22. package/src/mcp/mcp-manager.ts +2 -19
  23. package/src/mcp/mcp-tool-executor.ts +3 -4
  24. package/src/memory/contracts.ts +148 -0
  25. package/src/memory/embedding-client.ts +105 -0
  26. package/src/memory/memory-coordinator.ts +556 -0
  27. package/src/memory/memory-extractor.ts +231 -0
  28. package/src/memory/memory-log.ts +88 -0
  29. package/src/memory/memory-search-tool.ts +100 -0
  30. package/src/memory/memory-store.ts +687 -0
  31. package/src/memory/vector.ts +153 -0
  32. package/src/model/fake-model-client.ts +971 -3
  33. package/src/model/model-context-profile.ts +0 -30
  34. package/src/observation/observation-builder.ts +20 -0
  35. package/src/session/session-store.ts +123 -0
  36. package/src/tools/bash.ts +8 -25
  37. package/src/tools/grep.ts +9 -1
  38. package/src/tools/registry.ts +19 -1
  39. package/src/tools/ripgrep.ts +24 -27
  40. package/src/tools/types.ts +16 -0
  41. package/src/tools/web-fetch/index.ts +2 -15
  42. package/src/tui/app.tsx +72 -2
  43. package/src/tui/clipboard.ts +22 -0
  44. package/src/tui/components/footer.tsx +9 -4
  45. package/src/tui/components/memory-browser.tsx +151 -0
  46. package/src/tui/components/prompt-input.tsx +6 -3
  47. package/src/tui/event-store.ts +9 -2
  48. package/src/tui/slash-commands.ts +88 -24
  49. package/src/tui/workspace-file-search.ts +78 -71
package/src/tui/app.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Box, Text, useApp, useInput } from "ink";
2
- import { useEffect, useRef, useState, useSyncExternalStore } from "react";
2
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
3
3
  import { TurnCancelledError } from "../agent/turn-cancellation";
4
+ import { boundedMemoryError, type StoredMemorySummary } from "../memory/contracts";
4
5
  import {
5
6
  ContextManagerError,
6
7
  type ContextCompactionResult,
@@ -18,6 +19,7 @@ import { BackgroundTasks } from "./components/background-tasks";
18
19
  import { Header } from "./components/header";
19
20
  import { ModelPicker } from "./components/model-picker";
20
21
  import { FileViewer, FileViewerLoading } from "./components/file-viewer";
22
+ import { MemoryBrowser } from "./components/memory-browser";
21
23
  import {
22
24
  PromptInput,
23
25
  type PromptMaintenanceAction,
@@ -42,12 +44,14 @@ import type { SessionSummary } from "../session/session-catalog";
42
44
  import type { ModelProfile, ModelProfiles } from "../cli/model-profiles";
43
45
  import { loadViewFile, type ViewFile } from "./view-file";
44
46
  import { writeClipboardText } from "./clipboard";
47
+ import type { WorkspaceFileLister } from "./workspace-file-search";
45
48
 
46
49
  export type AppProps = {
47
50
  sessionController: TuiSessionController;
48
51
  readGitBranch?: (workspaceRoot: string) => Promise<string | undefined>;
49
52
  history?: PromptHistory;
50
53
  projectSlashCommands?: readonly ProjectSlashCommand[];
54
+ fileLister?: WorkspaceFileLister;
51
55
  profiles?: ModelProfiles;
52
56
  persistDefaultProfile?: (profileName: string) => Promise<void>;
53
57
  readViewFile?: (workspaceRoot: string, filePath: string) => Promise<ViewFile>;
@@ -57,6 +61,9 @@ export type AppProps = {
57
61
  ) => Promise<string | undefined>;
58
62
  writeClipboard?: (markdown: string) => Promise<void>;
59
63
  onQuit?: () => void;
64
+ initialNotice?: string;
65
+ listStoredMemories?: () => readonly StoredMemorySummary[];
66
+ memoryDisabledNotice?: string;
60
67
  };
61
68
 
62
69
  type ResumePickerState =
@@ -97,7 +104,7 @@ export function App(props: AppProps) {
97
104
  const [isSessionOperation, setIsSessionOperation] = useState(false);
98
105
  const [isCopying, setIsCopying] = useState(false);
99
106
  const [isCancelling, setIsCancelling] = useState(false);
100
- const [notice, setNotice] = useState<string | undefined>(undefined);
107
+ const [notice, setNotice] = useState<string | undefined>(props.initialNotice);
101
108
  const [showStatus, setShowStatus] = useState(false);
102
109
  const [showSkills, setShowSkills] = useState(false);
103
110
  const [showMcp, setShowMcp] = useState(false);
@@ -109,6 +116,9 @@ export function App(props: AppProps) {
109
116
  ModelPickerState | undefined
110
117
  >(undefined);
111
118
  const [fileView, setFileView] = useState<FileViewState | undefined>(undefined);
119
+ const [memoryView, setMemoryView] = useState<
120
+ readonly StoredMemorySummary[] | undefined
121
+ >(undefined);
112
122
  const [viewError, setViewError] = useState<string | undefined>(undefined);
113
123
  const [gitBranch, setGitBranch] = useState<string | undefined>(undefined);
114
124
  const [gitBranchRefresh, setGitBranchRefresh] = useState(0);
@@ -130,6 +140,8 @@ export function App(props: AppProps) {
130
140
 
131
141
  const profileList = props.profiles ? [...props.profiles.profiles.values()] : [];
132
142
 
143
+ const runningElapsedMs = useElapsedMs(state.activeTurn?.startedAt);
144
+
133
145
  useEffect(() => {
134
146
  if (readGitBranch === undefined) {
135
147
  return;
@@ -274,6 +286,20 @@ export function App(props: AppProps) {
274
286
  setFileView(undefined);
275
287
  };
276
288
 
289
+ const openMemoryView = () => {
290
+ if (props.listStoredMemories === undefined) {
291
+ setNotice(props.memoryDisabledNotice ?? "memory disabled: not configured");
292
+ return;
293
+ }
294
+ try {
295
+ const snapshot = props.listStoredMemories();
296
+ setNotice(undefined);
297
+ setMemoryView(snapshot);
298
+ } catch (error) {
299
+ setNotice(`memory unavailable: ${boundedMemoryError(error)}`);
300
+ }
301
+ };
302
+
277
303
  const openFileView = (filePath: string) => {
278
304
  const requestId = fileViewRequest.current + 1;
279
305
  fileViewRequest.current = requestId;
@@ -452,6 +478,10 @@ export function App(props: AppProps) {
452
478
  openFileView(command.filePath);
453
479
  return true;
454
480
  }
481
+ if (command.type === "memory") {
482
+ openMemoryView();
483
+ return true;
484
+ }
455
485
  if (command.type === "copy") {
456
486
  copyLastResponse();
457
487
  return true;
@@ -581,6 +611,8 @@ export function App(props: AppProps) {
581
611
  <FileViewerLoading filePath={fileView.filePath} onCancel={closeFileView} />
582
612
  ) : fileView?.status === "ready" ? (
583
613
  <FileViewer file={fileView.file} onClose={closeFileView} />
614
+ ) : memoryView !== undefined ? (
615
+ <MemoryBrowser memories={memoryView} onClose={() => setMemoryView(undefined)} />
584
616
  ) : resumePicker?.status === "loading" ? (
585
617
  <ResumeSessionPickerLoading onCancel={closeResumePicker} />
586
618
  ) : resumePicker?.status === "ready" ? (
@@ -626,6 +658,7 @@ export function App(props: AppProps) {
626
658
  <Footer
627
659
  status={isCancelling ? "cancelling" : state.status}
628
660
  workedForMs={state.workedForMs}
661
+ elapsedMs={runningElapsedMs}
629
662
  />
630
663
  </Box>
631
664
  <Box marginTop={1} flexDirection="column">
@@ -647,6 +680,7 @@ export function App(props: AppProps) {
647
680
  isDisabled={isRunning || isSessionOperation || isCopying}
648
681
  history={props.history}
649
682
  commands={availableCommands}
683
+ fileLister={props.fileLister}
650
684
  importImage={binding.importImage}
651
685
  verifyImageAssets={binding.verifyImageAssets}
652
686
  onSubmit={onSubmit}
@@ -667,6 +701,42 @@ function errorMessage(error: unknown): string {
667
701
  return error instanceof Error ? error.message : String(error);
668
702
  }
669
703
 
704
+ const ELAPSED_TICK_MS = 1_000;
705
+
706
+ // The wall clock, quantized to whole ticks so a render pass reads one stable
707
+ // value and re-renders at most once per second.
708
+ function readElapsedClockMs(): number {
709
+ return Math.floor(Date.now() / ELAPSED_TICK_MS) * ELAPSED_TICK_MS;
710
+ }
711
+
712
+ // Counts up while a turn is active. The interval only exists while `startedAt`
713
+ // is defined, so an idle TUI keeps no timer running.
714
+ function useElapsedMs(startedAt: string | undefined): number | undefined {
715
+ const subscribe = useCallback(
716
+ (onClockTick: () => void) => {
717
+ if (startedAt === undefined) {
718
+ return () => undefined;
719
+ }
720
+
721
+ const timer = setInterval(onClockTick, ELAPSED_TICK_MS);
722
+ return () => clearInterval(timer);
723
+ },
724
+ [startedAt],
725
+ );
726
+ const nowMs = useSyncExternalStore(subscribe, readElapsedClockMs, readElapsedClockMs);
727
+
728
+ if (startedAt === undefined) {
729
+ return undefined;
730
+ }
731
+
732
+ const startedAtMs = Date.parse(startedAt);
733
+ if (!Number.isFinite(startedAtMs)) {
734
+ throw new Error(`Invalid turn start timestamp: ${startedAt}`);
735
+ }
736
+
737
+ return Math.max(0, nowMs - startedAtMs);
738
+ }
739
+
670
740
  export function formatContextCompactionNotice(result: ContextCompactionResult): string {
671
741
  if (result.status === "unchanged") {
672
742
  if (result.outcome === "below_target") {
@@ -1,5 +1,27 @@
1
1
  import clipboard from "clipboardy";
2
+ import { writeFile } from "node:fs/promises";
3
+ import path from "node:path";
2
4
 
3
5
  export async function writeClipboardText(text: string): Promise<void> {
4
6
  await clipboard.write(text);
5
7
  }
8
+
9
+ export function clipboardWriterForEnvironment(
10
+ env: NodeJS.ProcessEnv,
11
+ ): ((text: string) => Promise<void>) | undefined {
12
+ const filePath = env.TINKER_TEST_CLIPBOARD_FILE;
13
+ if (filePath === undefined || filePath === "") {
14
+ return undefined;
15
+ }
16
+ if (env.TINKER_TEST_FAKE_MODEL === undefined || env.TINKER_TEST_FAKE_MODEL === "") {
17
+ throw new Error("TINKER_TEST_CLIPBOARD_FILE requires TINKER_TEST_FAKE_MODEL.");
18
+ }
19
+ if (!path.isAbsolute(filePath)) {
20
+ throw new Error("TINKER_TEST_CLIPBOARD_FILE must be an absolute path.");
21
+ }
22
+ return (text) =>
23
+ writeFile(filePath, text, {
24
+ encoding: "utf8",
25
+ mode: 0o600,
26
+ });
27
+ }
@@ -4,6 +4,7 @@ import { Text } from "ink";
4
4
  export type FooterProps = {
5
5
  status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
6
6
  workedForMs?: number;
7
+ elapsedMs?: number;
7
8
  };
8
9
 
9
10
  export function Footer(props: FooterProps) {
@@ -14,7 +15,7 @@ export function Footer(props: FooterProps) {
14
15
 
15
16
  return (
16
17
  <StatusMessage variant="success">
17
- Worked for {formatWorkedDuration(props.workedForMs)}
18
+ Worked for {formatDuration(props.workedForMs)}
18
19
  </StatusMessage>
19
20
  );
20
21
  }
@@ -24,7 +25,11 @@ export function Footer(props: FooterProps) {
24
25
  }
25
26
 
26
27
  if (props.status === "running") {
27
- return <Text color="yellow">• Running</Text>;
28
+ if (props.elapsedMs === undefined) {
29
+ return <Text color="yellow">• Running</Text>;
30
+ }
31
+
32
+ return <Text color="yellow">{`• Running ${formatDuration(props.elapsedMs)}`}</Text>;
28
33
  }
29
34
 
30
35
  if (props.status === "cancelling") {
@@ -38,9 +43,9 @@ export function Footer(props: FooterProps) {
38
43
  return <StatusMessage variant="info">idle</StatusMessage>;
39
44
  }
40
45
 
41
- function formatWorkedDuration(durationMs: number): string {
46
+ function formatDuration(durationMs: number): string {
42
47
  if (!Number.isFinite(durationMs) || durationMs < 0) {
43
- throw new Error(`Invalid worked duration: ${durationMs}`);
48
+ throw new Error(`Invalid duration: ${durationMs}`);
44
49
  }
45
50
 
46
51
  const totalSeconds = Math.floor(durationMs / 1000);
@@ -0,0 +1,151 @@
1
+ import {
2
+ Box,
3
+ Text,
4
+ useBoxMetrics,
5
+ useInput,
6
+ useWindowSize,
7
+ type DOMElement,
8
+ } from "ink";
9
+ import { useRef, useState } from "react";
10
+ import type { StoredMemorySummary } from "../../memory/contracts";
11
+
12
+ const BROWSER_CHROME_ROWS = 3;
13
+
14
+ export type MemoryBrowserProps = {
15
+ readonly memories: readonly StoredMemorySummary[];
16
+ readonly onClose: () => void;
17
+ readonly viewportRows?: number;
18
+ readonly viewportColumns?: number;
19
+ };
20
+
21
+ export function MemoryBrowser(props: MemoryBrowserProps) {
22
+ const windowSize = useWindowSize();
23
+ const rows = Math.max(4, props.viewportRows ?? windowSize.rows);
24
+ const columns = Math.max(20, props.viewportColumns ?? windowSize.columns);
25
+ const bodyRows = Math.max(1, rows - BROWSER_CHROME_ROWS);
26
+ const contentRef = useRef<DOMElement>(null);
27
+ const { height: measuredContentRows, hasMeasured } = useBoxMetrics(contentRef);
28
+ const totalLines = props.memories.length === 0 ? 0 : measuredContentRows;
29
+ const maxTopLine = Math.max(0, totalLines - bodyRows);
30
+ const [topLine, setTopLine] = useState(0);
31
+ const visibleTopLine = clamp(topLine, 0, maxTopLine);
32
+ const visibleEnd = Math.min(totalLines, visibleTopLine + bodyRows);
33
+
34
+ useInput((input, key) => {
35
+ if (key.escape) {
36
+ props.onClose();
37
+ return;
38
+ }
39
+ if (key.home) {
40
+ setTopLine(0);
41
+ return;
42
+ }
43
+ if (key.end) {
44
+ setTopLine(maxTopLine);
45
+ return;
46
+ }
47
+ if (key.pageUp || key.pageDown) {
48
+ const direction = key.pageUp ? -1 : 1;
49
+ setTopLine((current) => clamp(current + direction * bodyRows, 0, maxTopLine));
50
+ return;
51
+ }
52
+ const direction =
53
+ key.upArrow || (input === "k" && !key.ctrl && !key.meta)
54
+ ? -1
55
+ : key.downArrow || (input === "j" && !key.ctrl && !key.meta)
56
+ ? 1
57
+ : 0;
58
+ if (direction !== 0) {
59
+ setTopLine((current) => clamp(current + direction, 0, maxTopLine));
60
+ }
61
+ });
62
+
63
+ return (
64
+ <Box width={columns} height={rows} flexDirection="column" overflow="hidden">
65
+ <Text bold>Global memory</Text>
66
+ <Text dimColor wrap="truncate-end">
67
+ ↑/↓ or j/k · PgUp/PgDn · Home/End · Esc close
68
+ </Text>
69
+ <Box
70
+ height={bodyRows}
71
+ width={columns}
72
+ position="relative"
73
+ overflow="hidden"
74
+ flexDirection="column"
75
+ >
76
+ {props.memories.length === 0 ? (
77
+ <Text dimColor>No stored memories.</Text>
78
+ ) : (
79
+ <Box
80
+ ref={contentRef}
81
+ position="absolute"
82
+ top={-visibleTopLine}
83
+ width={columns}
84
+ flexDirection="column"
85
+ >
86
+ {props.memories.map((memory, index) => (
87
+ <Box
88
+ key={memory.memoryId}
89
+ flexDirection="column"
90
+ marginBottom={index === props.memories.length - 1 ? 0 : 1}
91
+ >
92
+ <Text dimColor wrap="truncate-middle">
93
+ {formatMemoryCreatedAt(memory.createdAt)} · {memory.sourceWorkspace}
94
+ </Text>
95
+ <Text>{normalizeMemoryDisplayText(memory.text)}</Text>
96
+ </Box>
97
+ ))}
98
+ </Box>
99
+ )}
100
+ </Box>
101
+ <Text dimColor wrap="truncate-end">
102
+ {props.memories.length === 0
103
+ ? "0 memories"
104
+ : memoryBrowserStatus(
105
+ visibleTopLine,
106
+ visibleEnd,
107
+ hasMeasured ? totalLines : 0,
108
+ props.memories.length,
109
+ )}
110
+ </Text>
111
+ </Box>
112
+ );
113
+ }
114
+
115
+ export function normalizeMemoryDisplayText(text: string): string {
116
+ return (
117
+ text
118
+ .replaceAll(/\r\n?/g, "\n")
119
+ .replaceAll("\t", " ")
120
+ // eslint-disable-next-line no-control-regex
121
+ .replaceAll(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "\uFFFD")
122
+ );
123
+ }
124
+
125
+ export function formatMemoryCreatedAt(createdAt: string): string {
126
+ const date = new Date(createdAt);
127
+ const year = String(date.getFullYear()).padStart(4, "0");
128
+ const month = String(date.getMonth() + 1).padStart(2, "0");
129
+ const day = String(date.getDate()).padStart(2, "0");
130
+ const hours = String(date.getHours()).padStart(2, "0");
131
+ const minutes = String(date.getMinutes()).padStart(2, "0");
132
+ return `${year}-${month}-${day} ${hours}:${minutes}`;
133
+ }
134
+
135
+ function memoryBrowserStatus(
136
+ topLine: number,
137
+ endLine: number,
138
+ totalLines: number,
139
+ memoryCount: number,
140
+ ): string {
141
+ if (totalLines === 0) {
142
+ return `0 lines · ${memoryCount} ${memoryCount === 1 ? "memory" : "memories"}`;
143
+ }
144
+ return `${topLine + 1}–${endLine} / ${totalLines} lines · ${memoryCount} ${
145
+ memoryCount === 1 ? "memory" : "memories"
146
+ }`;
147
+ }
148
+
149
+ function clamp(value: number, minimum: number, maximum: number): number {
150
+ return Math.min(Math.max(value, minimum), maximum);
151
+ }
@@ -222,7 +222,8 @@ export function PromptInput(props: PromptInputProps) {
222
222
  if (mention === undefined || locked) {
223
223
  return;
224
224
  }
225
- if (props.importImage === undefined) {
225
+ const importImage = props.importImage;
226
+ if (importImage === undefined) {
226
227
  insertFilePath(filePath);
227
228
  return;
228
229
  }
@@ -236,8 +237,10 @@ export function PromptInput(props: PromptInputProps) {
236
237
  phase: { kind: "attaching", operationId },
237
238
  error: undefined,
238
239
  }));
239
- void props
240
- .importImage(filePath, controller.signal, captured.attachments.length + 1)
240
+ void Promise.resolve()
241
+ .then(() =>
242
+ importImage(filePath, controller.signal, captured.attachments.length + 1),
243
+ )
241
244
  .then((imported) => {
242
245
  setState((current) => {
243
246
  if (
@@ -697,8 +697,8 @@ function toolCallSummary(input: { name: string; args: unknown }): string {
697
697
  if (input.name === "Grep") {
698
698
  return `Grep ${toolPattern(input.args) ?? ""}`.trim();
699
699
  }
700
- if (input.name === "WebSearch") {
701
- return `WebSearch ${toolQuery(input.args) ?? ""}`.trim();
700
+ if (input.name === "WebSearch" || input.name === "MemorySearch") {
701
+ return `${input.name} ${toolQuery(input.args) ?? ""}`.trim();
702
702
  }
703
703
  if (input.name === "WebFetch") {
704
704
  return `WebFetch ${toolUrl(input.args) ?? ""}`.trim();
@@ -779,6 +779,11 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
779
779
  return raw.mode === "search"
780
780
  ? `${base} -> ${raw.page.hits.length} historical match${raw.page.hits.length === 1 ? "" : "es"}`
781
781
  : `${base} -> ${raw.page.returnedBytes} historical bytes`;
782
+ case "memory_search":
783
+ if (!raw.ok) {
784
+ return base;
785
+ }
786
+ return `${base} -> ${raw.matches.length} derived memor${raw.matches.length === 1 ? "y" : "ies"}`;
782
787
  case "skill":
783
788
  if (!raw.ok) {
784
789
  return `${base} failed -> ${boundedToolError(raw.error)}`;
@@ -846,6 +851,7 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
846
851
  case "web_search":
847
852
  case "web_fetch":
848
853
  case "recall":
854
+ case "memory_search":
849
855
  case "skill":
850
856
  case "mcp":
851
857
  case "generic":
@@ -880,6 +886,7 @@ function toolRawResultDiff(
880
886
  case "web_search":
881
887
  case "web_fetch":
882
888
  case "recall":
889
+ case "memory_search":
883
890
  case "skill":
884
891
  case "mcp":
885
892
  case "generic":
@@ -1,30 +1,78 @@
1
1
  export type SlashCommand = {
2
- name: string;
3
- description: string;
2
+ readonly name: string;
3
+ readonly description: string;
4
+ readonly usage?: string;
4
5
  };
5
6
 
6
- export const SLASH_COMMANDS: readonly SlashCommand[] = [
7
- { name: "status", description: "Show session and context details" },
8
- { name: "skills", description: "Show available and active Agent Skills" },
9
- { name: "mcp", description: "Show MCP servers and runtime tools" },
7
+ export type BuiltInSlashCommand = SlashCommand & {
8
+ readonly usage: string;
9
+ };
10
+
11
+ export const SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
12
+ {
13
+ name: "status",
14
+ usage: "/status",
15
+ description: "Show session and context details",
16
+ },
17
+ {
18
+ name: "skills",
19
+ usage: "/skills",
20
+ description: "Show available and active Agent Skills",
21
+ },
22
+ {
23
+ name: "mcp",
24
+ usage: "/mcp",
25
+ description: "Show MCP servers and runtime tools",
26
+ },
27
+ {
28
+ name: "memory",
29
+ usage: "/memory",
30
+ description: "Browse stored global memories",
31
+ },
10
32
  {
11
33
  name: "compact",
34
+ usage: "/compact [retire]",
12
35
  description: "Swap tool output or retire a cold history prefix",
13
36
  },
14
- { name: "clear", description: "Start a new session and clear conversation" },
15
- { name: "fork", description: "Clone the current session" },
16
- { name: "view", description: "View a local UTF-8 text file" },
17
- { name: "copy", description: "Copy the last response as Markdown" },
18
- { name: "model", description: "Switch model profile (new session)" },
19
- { name: "resume", description: "Choose or resume a session" },
20
- { name: "session", description: "Manage stored sessions" },
21
- { name: "quit", description: "Exit the TUI" },
37
+ {
38
+ name: "clear",
39
+ usage: "/clear",
40
+ description: "Start a new session and clear conversation",
41
+ },
42
+ { name: "fork", usage: "/fork", description: "Clone the current session" },
43
+ {
44
+ name: "view",
45
+ usage: "/view <path>",
46
+ description: "View a local UTF-8 text file",
47
+ },
48
+ {
49
+ name: "copy",
50
+ usage: "/copy",
51
+ description: "Copy the last response as Markdown",
52
+ },
53
+ {
54
+ name: "model",
55
+ usage: "/model [profile-name]",
56
+ description: "Switch model profile (new session)",
57
+ },
58
+ {
59
+ name: "resume",
60
+ usage: "/resume [session-id]",
61
+ description: "Choose or resume a session",
62
+ },
63
+ {
64
+ name: "session",
65
+ usage: "/session delete <session-id> --confirm",
66
+ description: "Manage stored sessions",
67
+ },
68
+ { name: "quit", usage: "/quit", description: "Exit the TUI" },
22
69
  ];
23
70
 
24
71
  export type ParsedSlashCommand =
25
72
  | { type: "status" }
26
73
  | { type: "skills" }
27
74
  | { type: "mcp" }
75
+ | { type: "memory" }
28
76
  | { type: "compact" }
29
77
  | { type: "compact_retire" }
30
78
  | { type: "clear" }
@@ -48,12 +96,12 @@ export class SlashCommandError extends Error {
48
96
  export function parseSlashCommand(input: string): ParsedSlashCommand {
49
97
  const trimmed = input.trim();
50
98
  if (trimmed === "/view") {
51
- throw new SlashCommandError("Usage: /view <path>");
99
+ throw slashCommandUsageError("view");
52
100
  }
53
101
  if (trimmed.startsWith("/view ") || trimmed.startsWith("/view\t")) {
54
102
  const filePath = trimmed.slice(5).trim();
55
103
  if (filePath === "") {
56
- throw new SlashCommandError("Usage: /view <path>");
104
+ throw slashCommandUsageError("view");
57
105
  }
58
106
  return { type: "view", filePath };
59
107
  }
@@ -70,7 +118,13 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
70
118
  if (tokens.length === 1) {
71
119
  return { type: "mcp" };
72
120
  }
73
- throw new SlashCommandError("Usage: /mcp");
121
+ throw slashCommandUsageError("mcp");
122
+ }
123
+ if (command === "/memory") {
124
+ if (tokens.length === 1) {
125
+ return { type: "memory" };
126
+ }
127
+ throw slashCommandUsageError("memory");
74
128
  }
75
129
  if (command === "/compact") {
76
130
  if (tokens.length === 1) {
@@ -79,25 +133,25 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
79
133
  if (tokens.length === 2 && tokens[1] === "retire") {
80
134
  return { type: "compact_retire" };
81
135
  }
82
- throw new SlashCommandError("Usage: /compact [retire]");
136
+ throw slashCommandUsageError("compact");
83
137
  }
84
138
  if (command === "/clear") {
85
139
  if (tokens.length === 1) {
86
140
  return { type: "clear" };
87
141
  }
88
- throw new SlashCommandError("Usage: /clear");
142
+ throw slashCommandUsageError("clear");
89
143
  }
90
144
  if (command === "/fork") {
91
145
  if (tokens.length === 1) {
92
146
  return { type: "fork" };
93
147
  }
94
- throw new SlashCommandError("Usage: /fork");
148
+ throw slashCommandUsageError("fork");
95
149
  }
96
150
  if (command === "/copy") {
97
151
  if (tokens.length === 1) {
98
152
  return { type: "copy" };
99
153
  }
100
- throw new SlashCommandError("Usage: /copy");
154
+ throw slashCommandUsageError("copy");
101
155
  }
102
156
  if (command === "/quit" && tokens.length === 1) {
103
157
  return { type: "quit" };
@@ -109,7 +163,7 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
109
163
  if (tokens.length === 2) {
110
164
  return { type: "model_switch", profileName: tokens[1] };
111
165
  }
112
- throw new SlashCommandError("Usage: /model [profile-name]");
166
+ throw slashCommandUsageError("model");
113
167
  }
114
168
  if (command === "/resume") {
115
169
  if (tokens.length === 1) {
@@ -118,7 +172,7 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
118
172
  if (tokens.length === 2) {
119
173
  return { type: "resume", sessionId: parsePublicSessionId(tokens[1]) };
120
174
  }
121
- throw new SlashCommandError("Usage: /resume [session-id]");
175
+ throw slashCommandUsageError("resume");
122
176
  }
123
177
  if (command === "/session") {
124
178
  if (tokens.length === 4 && tokens[1] === "delete" && tokens[3] === "--confirm") {
@@ -127,7 +181,7 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
127
181
  sessionId: parsePublicSessionId(tokens[2]),
128
182
  };
129
183
  }
130
- throw new SlashCommandError("Usage: /session delete <session-id> --confirm");
184
+ throw slashCommandUsageError("session");
131
185
  }
132
186
  throw new SlashCommandError(`Unknown command: ${trimmed}`);
133
187
  }
@@ -167,4 +221,14 @@ function parsePublicSessionId(value: string): SessionId {
167
221
  throw new SlashCommandError(`Invalid session ID: ${value}`);
168
222
  }
169
223
  }
224
+
225
+ function slashCommandUsageError(
226
+ name: (typeof SLASH_COMMANDS)[number]["name"],
227
+ ): SlashCommandError {
228
+ const command = SLASH_COMMANDS.find((candidate) => candidate.name === name);
229
+ if (command === undefined) {
230
+ throw new Error(`Missing built-in slash command declaration for ${name}.`);
231
+ }
232
+ return new SlashCommandError(`Usage: ${command.usage}`);
233
+ }
170
234
  import { parseSessionId, type SessionId } from "../ids/runtime-id";