tinker-agent 1.4.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.
@@ -5,6 +5,7 @@ import type {
5
5
  GenericToolRawResult,
6
6
  GlobRawResult,
7
7
  GrepRawResult,
8
+ MemorySearchRawResult,
8
9
  McpToolRawResult,
9
10
  ReadFileRawResult,
10
11
  RecallRawResult,
@@ -33,6 +34,8 @@ export class ObservationBuilder {
33
34
  return { content: renderReadObservation(input.raw) };
34
35
  case "recall":
35
36
  return { content: renderRecallObservation(input.raw) };
37
+ case "memory_search":
38
+ return { content: renderMemorySearchObservation(input.raw) };
36
39
  case "skill":
37
40
  return { content: renderSkillObservation(input.raw) };
38
41
  case "write":
@@ -227,6 +230,23 @@ function renderRecallObservation(raw: RecallRawResult): string {
227
230
  return [header, ...hits].join("\n\n");
228
231
  }
229
232
 
233
+ function renderMemorySearchObservation(raw: MemorySearchRawResult): string {
234
+ if (!raw.ok) {
235
+ return `MemorySearch unavailable: ${raw.error}`;
236
+ }
237
+ if (raw.matches.length === 0) {
238
+ return "MemorySearch found no stored memories.";
239
+ }
240
+ const header = `MemorySearch returned ${raw.matches.length} derived memories. They may be stale or wrong; verify current workspace facts.`;
241
+ return [
242
+ header,
243
+ ...raw.matches.map(
244
+ (match, index) =>
245
+ `${index + 1}. score=${match.score.toFixed(3)} created_at=${match.createdAt} workspace=${match.sourceWorkspace}\n ${match.text}`,
246
+ ),
247
+ ].join("\n\n");
248
+ }
249
+
230
250
  export function renderSkillObservation(raw: SkillRawResult): string {
231
251
  if (!raw.ok) {
232
252
  return `Skill failed for ${raw.name || "(unknown skill)"} (${raw.errorCode}): ${raw.error}`;
@@ -144,6 +144,29 @@ export type SessionImageInputCompatibility = {
144
144
  readonly tokenEstimator?: InputTokenEstimatorCompatibility;
145
145
  };
146
146
 
147
+ export type CompletedTurnMessageSnapshot =
148
+ | {
149
+ readonly ordinal: number;
150
+ readonly role: "user";
151
+ readonly content: string;
152
+ }
153
+ | {
154
+ readonly ordinal: number;
155
+ readonly role: "assistant";
156
+ readonly content: string | null;
157
+ readonly reasoningContent?: string | null;
158
+ }
159
+ | {
160
+ readonly ordinal: number;
161
+ readonly role: "tool";
162
+ readonly name: string;
163
+ readonly content: string;
164
+ };
165
+
166
+ export type CompletedTurnSnapshot = {
167
+ readonly messages: readonly CompletedTurnMessageSnapshot[];
168
+ };
169
+
147
170
  export type SessionCompatibilityContract = {
148
171
  modelName: string;
149
172
  profileName?: string;
@@ -2082,6 +2105,105 @@ export class SessionStore implements SessionLedgerCommitter {
2082
2105
  });
2083
2106
  }
2084
2107
 
2108
+ readCompletedTurnSnapshot(turnId: TurnId): CompletedTurnSnapshot {
2109
+ this.requireOpen();
2110
+ const turnRow = this.database
2111
+ .query("SELECT status FROM turns WHERE turn_id = ?")
2112
+ .get(turnId);
2113
+ const status = enumFromSql(
2114
+ recordFromSql(turnRow, "completed turn").status,
2115
+ ["open", "completed", "failed", "cancelled", "interrupted"] as const,
2116
+ "turn status",
2117
+ );
2118
+ if (status !== "completed") {
2119
+ throw new Error(`Turn ${turnId} is not completed.`);
2120
+ }
2121
+
2122
+ const rows = this.database
2123
+ .query(
2124
+ `SELECT ordinal, role, content, reasoning_content,
2125
+ reasoning_content_present, name
2126
+ FROM messages
2127
+ WHERE turn_id = ?
2128
+ ORDER BY ordinal`,
2129
+ )
2130
+ .all(turnId);
2131
+ if (rows.length === 0) {
2132
+ throw new Error(`Completed turn ${turnId} has no messages.`);
2133
+ }
2134
+
2135
+ let previousOrdinal = 0;
2136
+ const messages = rows.map((value): CompletedTurnMessageSnapshot => {
2137
+ const row = recordFromSql(value, "completed turn message");
2138
+ const ordinal = numberFromSql(row.ordinal, "completed turn ordinal");
2139
+ if (ordinal < 1 || ordinal <= previousOrdinal) {
2140
+ throw new Error("Completed turn message ordinals are invalid.");
2141
+ }
2142
+ previousOrdinal = ordinal;
2143
+ const role = enumFromSql(
2144
+ row.role,
2145
+ ["user", "assistant", "tool"] as const,
2146
+ "completed turn message role",
2147
+ );
2148
+ if (role === "user") {
2149
+ if (
2150
+ row.reasoning_content !== null ||
2151
+ numberFromSql(row.reasoning_content_present, "reasoning_content_present") !==
2152
+ 0 ||
2153
+ row.name !== null
2154
+ ) {
2155
+ throw new Error("Completed user message fields are invalid.");
2156
+ }
2157
+ return Object.freeze({
2158
+ ordinal,
2159
+ role,
2160
+ content: stringFromSql(row.content, "completed user content"),
2161
+ });
2162
+ }
2163
+ if (role === "assistant") {
2164
+ if (row.name !== null) {
2165
+ throw new Error("Completed assistant message name must be null.");
2166
+ }
2167
+ const reasoningPresent = numberFromSql(
2168
+ row.reasoning_content_present,
2169
+ "reasoning_content_present",
2170
+ );
2171
+ if (reasoningPresent !== 0 && reasoningPresent !== 1) {
2172
+ throw new Error("reasoning_content_present must be 0 or 1.");
2173
+ }
2174
+ if (reasoningPresent === 0 && row.reasoning_content !== null) {
2175
+ throw new Error("Absent assistant reasoning content must be null.");
2176
+ }
2177
+ return Object.freeze({
2178
+ ordinal,
2179
+ role,
2180
+ content: nullableTextFromSql(row.content, "completed assistant content"),
2181
+ ...(reasoningPresent === 0
2182
+ ? {}
2183
+ : {
2184
+ reasoningContent: nullableTextFromSql(
2185
+ row.reasoning_content,
2186
+ "completed assistant reasoning content",
2187
+ ),
2188
+ }),
2189
+ });
2190
+ }
2191
+ if (
2192
+ row.reasoning_content !== null ||
2193
+ numberFromSql(row.reasoning_content_present, "reasoning_content_present") !== 0
2194
+ ) {
2195
+ throw new Error("Completed tool message reasoning fields are invalid.");
2196
+ }
2197
+ return Object.freeze({
2198
+ ordinal,
2199
+ role,
2200
+ name: stringFromSql(row.name, "completed tool name"),
2201
+ content: stringFromSql(row.content, "completed tool content"),
2202
+ });
2203
+ });
2204
+ return Object.freeze({ messages: Object.freeze(messages) });
2205
+ }
2206
+
2085
2207
  loadProtocolView(): ProtocolContextView {
2086
2208
  this.requireOpen();
2087
2209
  const imageAttachments = loadMessageImageAttachments(this.database);
@@ -4725,6 +4847,7 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
4725
4847
  "web_search",
4726
4848
  "web_fetch",
4727
4849
  "recall",
4850
+ "memory_search",
4728
4851
  "skill",
4729
4852
  "mcp",
4730
4853
  "generic",
@@ -133,6 +133,7 @@ export function createDefaultTooling(options: {
133
133
  skillCatalog?: SkillCatalogSnapshot;
134
134
  skillCoordinator?: SkillActivationCoordinator;
135
135
  toolingConfig?: PublicToolingConfig;
136
+ memorySearch?: ToolExecutor;
136
137
  }): DefaultTooling {
137
138
  const snapshots: FileSnapshotStore = new Map();
138
139
  const registry = new ToolRegistry();
@@ -170,6 +171,9 @@ export function createDefaultTooling(options: {
170
171
  }),
171
172
  );
172
173
  registry.register(createRecallToolExecutor({ historyReader: options.historyReader }));
174
+ if (options.memorySearch !== undefined) {
175
+ registry.register(options.memorySearch);
176
+ }
173
177
  if (options.skillCatalog !== undefined) {
174
178
  if (options.skillCatalog.skills.size === 0) {
175
179
  throw new Error("An empty Agent Skill catalog must not register tooling.");
@@ -245,6 +245,21 @@ export type RecallGetRawResult =
245
245
 
246
246
  export type RecallRawResult = RecallSearchRawResult | RecallGetRawResult;
247
247
 
248
+ export type MemorySearchRawResult =
249
+ | {
250
+ ok: true;
251
+ matches: readonly {
252
+ text: string;
253
+ score: number;
254
+ sourceWorkspace: string;
255
+ createdAt: string;
256
+ }[];
257
+ }
258
+ | {
259
+ ok: false;
260
+ error: string;
261
+ };
262
+
248
263
  export type SkillRawResult =
249
264
  | {
250
265
  ok: true;
@@ -307,6 +322,7 @@ export type ToolRawResultByKind = {
307
322
  web_search: WebSearchRawResult;
308
323
  web_fetch: WebFetchRawResult;
309
324
  recall: RecallRawResult;
325
+ memory_search: MemorySearchRawResult;
310
326
  skill: SkillRawResult;
311
327
  mcp: McpToolRawResult;
312
328
  generic: GenericToolRawResult;
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,
@@ -59,6 +61,9 @@ export type AppProps = {
59
61
  ) => Promise<string | undefined>;
60
62
  writeClipboard?: (markdown: string) => Promise<void>;
61
63
  onQuit?: () => void;
64
+ initialNotice?: string;
65
+ listStoredMemories?: () => readonly StoredMemorySummary[];
66
+ memoryDisabledNotice?: string;
62
67
  };
63
68
 
64
69
  type ResumePickerState =
@@ -99,7 +104,7 @@ export function App(props: AppProps) {
99
104
  const [isSessionOperation, setIsSessionOperation] = useState(false);
100
105
  const [isCopying, setIsCopying] = useState(false);
101
106
  const [isCancelling, setIsCancelling] = useState(false);
102
- const [notice, setNotice] = useState<string | undefined>(undefined);
107
+ const [notice, setNotice] = useState<string | undefined>(props.initialNotice);
103
108
  const [showStatus, setShowStatus] = useState(false);
104
109
  const [showSkills, setShowSkills] = useState(false);
105
110
  const [showMcp, setShowMcp] = useState(false);
@@ -111,6 +116,9 @@ export function App(props: AppProps) {
111
116
  ModelPickerState | undefined
112
117
  >(undefined);
113
118
  const [fileView, setFileView] = useState<FileViewState | undefined>(undefined);
119
+ const [memoryView, setMemoryView] = useState<
120
+ readonly StoredMemorySummary[] | undefined
121
+ >(undefined);
114
122
  const [viewError, setViewError] = useState<string | undefined>(undefined);
115
123
  const [gitBranch, setGitBranch] = useState<string | undefined>(undefined);
116
124
  const [gitBranchRefresh, setGitBranchRefresh] = useState(0);
@@ -132,6 +140,8 @@ export function App(props: AppProps) {
132
140
 
133
141
  const profileList = props.profiles ? [...props.profiles.profiles.values()] : [];
134
142
 
143
+ const runningElapsedMs = useElapsedMs(state.activeTurn?.startedAt);
144
+
135
145
  useEffect(() => {
136
146
  if (readGitBranch === undefined) {
137
147
  return;
@@ -276,6 +286,20 @@ export function App(props: AppProps) {
276
286
  setFileView(undefined);
277
287
  };
278
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
+
279
303
  const openFileView = (filePath: string) => {
280
304
  const requestId = fileViewRequest.current + 1;
281
305
  fileViewRequest.current = requestId;
@@ -454,6 +478,10 @@ export function App(props: AppProps) {
454
478
  openFileView(command.filePath);
455
479
  return true;
456
480
  }
481
+ if (command.type === "memory") {
482
+ openMemoryView();
483
+ return true;
484
+ }
457
485
  if (command.type === "copy") {
458
486
  copyLastResponse();
459
487
  return true;
@@ -583,6 +611,8 @@ export function App(props: AppProps) {
583
611
  <FileViewerLoading filePath={fileView.filePath} onCancel={closeFileView} />
584
612
  ) : fileView?.status === "ready" ? (
585
613
  <FileViewer file={fileView.file} onClose={closeFileView} />
614
+ ) : memoryView !== undefined ? (
615
+ <MemoryBrowser memories={memoryView} onClose={() => setMemoryView(undefined)} />
586
616
  ) : resumePicker?.status === "loading" ? (
587
617
  <ResumeSessionPickerLoading onCancel={closeResumePicker} />
588
618
  ) : resumePicker?.status === "ready" ? (
@@ -628,6 +658,7 @@ export function App(props: AppProps) {
628
658
  <Footer
629
659
  status={isCancelling ? "cancelling" : state.status}
630
660
  workedForMs={state.workedForMs}
661
+ elapsedMs={runningElapsedMs}
631
662
  />
632
663
  </Box>
633
664
  <Box marginTop={1} flexDirection="column">
@@ -670,6 +701,42 @@ function errorMessage(error: unknown): string {
670
701
  return error instanceof Error ? error.message : String(error);
671
702
  }
672
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
+
673
740
  export function formatContextCompactionNotice(result: ContextCompactionResult): string {
674
741
  if (result.status === "unchanged") {
675
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
+ }
@@ -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":
@@ -24,6 +24,11 @@ export const SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
24
24
  usage: "/mcp",
25
25
  description: "Show MCP servers and runtime tools",
26
26
  },
27
+ {
28
+ name: "memory",
29
+ usage: "/memory",
30
+ description: "Browse stored global memories",
31
+ },
27
32
  {
28
33
  name: "compact",
29
34
  usage: "/compact [retire]",
@@ -67,6 +72,7 @@ export type ParsedSlashCommand =
67
72
  | { type: "status" }
68
73
  | { type: "skills" }
69
74
  | { type: "mcp" }
75
+ | { type: "memory" }
70
76
  | { type: "compact" }
71
77
  | { type: "compact_retire" }
72
78
  | { type: "clear" }
@@ -114,6 +120,12 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
114
120
  }
115
121
  throw slashCommandUsageError("mcp");
116
122
  }
123
+ if (command === "/memory") {
124
+ if (tokens.length === 1) {
125
+ return { type: "memory" };
126
+ }
127
+ throw slashCommandUsageError("memory");
128
+ }
117
129
  if (command === "/compact") {
118
130
  if (tokens.length === 1) {
119
131
  return { type: "compact" };