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.
- package/CHANGELOG.md +23 -1
- package/README.md +54 -0
- package/package.json +5 -1
- package/src/agent/runtime-session.ts +79 -0
- package/src/cli/config.ts +29 -0
- package/src/cli/model-profiles.ts +84 -1
- package/src/cli/public-config-contract.ts +82 -0
- package/src/cli/runner-dependencies.ts +8 -0
- package/src/cli/tui-memory.ts +67 -0
- package/src/cli/tui-runner.tsx +27 -0
- package/src/context/context-policy.ts +2 -2
- package/src/events/stdout-event-printer.ts +1 -0
- package/src/memory/contracts.ts +148 -0
- package/src/memory/embedding-client.ts +105 -0
- package/src/memory/memory-coordinator.ts +556 -0
- package/src/memory/memory-extractor.ts +231 -0
- package/src/memory/memory-log.ts +88 -0
- package/src/memory/memory-search-tool.ts +100 -0
- package/src/memory/memory-store.ts +687 -0
- package/src/memory/vector.ts +153 -0
- package/src/model/fake-model-client.ts +971 -3
- package/src/observation/observation-builder.ts +20 -0
- package/src/session/session-store.ts +123 -0
- package/src/tools/registry.ts +4 -0
- package/src/tools/types.ts +16 -0
- package/src/tui/app.tsx +69 -2
- package/src/tui/clipboard.ts +22 -0
- package/src/tui/components/footer.tsx +9 -4
- package/src/tui/components/memory-browser.tsx +151 -0
- package/src/tui/event-store.ts +9 -2
- package/src/tui/slash-commands.ts +12 -0
|
@@ -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",
|
package/src/tools/registry.ts
CHANGED
|
@@ -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.");
|
package/src/tools/types.ts
CHANGED
|
@@ -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>(
|
|
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") {
|
package/src/tui/clipboard.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
46
|
+
function formatDuration(durationMs: number): string {
|
|
42
47
|
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
|
43
|
-
throw new Error(`Invalid
|
|
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
|
+
}
|
package/src/tui/event-store.ts
CHANGED
|
@@ -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
|
|
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" };
|