tinker-agent 1.4.0 → 1.5.1
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 +40 -1
- package/README.md +54 -0
- package/package.json +6 -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 +31 -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 +1068 -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 +211 -93
- package/src/tui/clipboard.ts +22 -0
- package/src/tui/components/assistant-markdown.tsx +27 -26
- package/src/tui/components/background-tasks.tsx +7 -2
- package/src/tui/components/file-viewer.tsx +2 -2
- package/src/tui/components/footer.tsx +5 -6
- package/src/tui/components/memory-browser.tsx +151 -0
- package/src/tui/components/resume-session-picker.tsx +3 -1
- package/src/tui/components/timeline.tsx +9 -11
- package/src/tui/event-store.ts +22 -2
- package/src/tui/shiki-highlighter.ts +104 -0
- package/src/tui/slash-commands.ts +12 -0
- package/src/tui/tui-projection-store.ts +33 -0
- package/src/tui/tui-session-controller.ts +14 -8
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { StatusMessage } from "@inkjs/ui";
|
|
2
|
-
import { Text } from "ink";
|
|
1
|
+
import { Spinner, StatusMessage } from "@inkjs/ui";
|
|
3
2
|
|
|
4
3
|
export type FooterProps = {
|
|
5
4
|
status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
|
|
@@ -14,7 +13,7 @@ export function Footer(props: FooterProps) {
|
|
|
14
13
|
|
|
15
14
|
return (
|
|
16
15
|
<StatusMessage variant="success">
|
|
17
|
-
Worked for {
|
|
16
|
+
Worked for {formatDuration(props.workedForMs)}
|
|
18
17
|
</StatusMessage>
|
|
19
18
|
);
|
|
20
19
|
}
|
|
@@ -24,7 +23,7 @@ export function Footer(props: FooterProps) {
|
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
if (props.status === "running") {
|
|
27
|
-
return <
|
|
26
|
+
return <Spinner label="Running" />;
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
if (props.status === "cancelling") {
|
|
@@ -38,9 +37,9 @@ export function Footer(props: FooterProps) {
|
|
|
38
37
|
return <StatusMessage variant="info">idle</StatusMessage>;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
function
|
|
40
|
+
function formatDuration(durationMs: number): string {
|
|
42
41
|
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
|
43
|
-
throw new Error(`Invalid
|
|
42
|
+
throw new Error(`Invalid duration: ${durationMs}`);
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
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 - 1);
|
|
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
|
+
}
|
|
@@ -10,6 +10,7 @@ export type ResumeSessionPickerProps = {
|
|
|
10
10
|
isResuming?: boolean;
|
|
11
11
|
error?: string;
|
|
12
12
|
now?: Date;
|
|
13
|
+
viewportRows?: number;
|
|
13
14
|
visibleItemCount?: number;
|
|
14
15
|
onCancel: () => void;
|
|
15
16
|
onSelect: (session: SessionSummary) => void;
|
|
@@ -29,7 +30,8 @@ export function ResumeSessionPicker(props: ResumeSessionPickerProps) {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function ResumeSessionPickerContent(props: ResumeSessionPickerProps) {
|
|
32
|
-
const
|
|
33
|
+
const windowSize = useWindowSize();
|
|
34
|
+
const rows = props.viewportRows ?? windowSize.rows - 1;
|
|
33
35
|
const visibleItemCount = Math.min(
|
|
34
36
|
props.sessions.length,
|
|
35
37
|
Math.max(
|
|
@@ -6,27 +6,25 @@ import { BashResultView } from "./bash-result-view";
|
|
|
6
6
|
import { DiffView } from "./diff-view";
|
|
7
7
|
|
|
8
8
|
export type TimelineProps = {
|
|
9
|
-
items: TimelineItem[];
|
|
9
|
+
items: readonly TimelineItem[];
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
export function Timeline(props: TimelineProps) {
|
|
13
13
|
return (
|
|
14
14
|
<Box flexDirection="column">
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
) : (
|
|
19
|
-
props.items.map((item) => renderTimelineItem(item))
|
|
20
|
-
)}
|
|
15
|
+
{props.items.map((item) => (
|
|
16
|
+
<TimelineRow key={item.id} item={item} />
|
|
17
|
+
))}
|
|
21
18
|
</Box>
|
|
22
19
|
);
|
|
23
20
|
}
|
|
24
21
|
|
|
25
|
-
function
|
|
22
|
+
export function TimelineRow(props: { item: TimelineItem }) {
|
|
23
|
+
const { item } = props;
|
|
26
24
|
if (item.label !== undefined) {
|
|
27
25
|
if (item.label === "assistant") {
|
|
28
26
|
return (
|
|
29
|
-
<Fragment
|
|
27
|
+
<Fragment>
|
|
30
28
|
<Text color="gray">- {item.label}</Text>
|
|
31
29
|
<AssistantMarkdown text={item.text} />
|
|
32
30
|
</Fragment>
|
|
@@ -34,7 +32,7 @@ function renderTimelineItem(item: TimelineItem) {
|
|
|
34
32
|
}
|
|
35
33
|
|
|
36
34
|
return (
|
|
37
|
-
<Fragment
|
|
35
|
+
<Fragment>
|
|
38
36
|
<Text color="gray">- {item.label}</Text>
|
|
39
37
|
{item.userPrompt === undefined ? (
|
|
40
38
|
<Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
|
|
@@ -48,7 +46,7 @@ function renderTimelineItem(item: TimelineItem) {
|
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
return (
|
|
51
|
-
<Fragment
|
|
49
|
+
<Fragment>
|
|
52
50
|
<Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
|
|
53
51
|
{renderItemBash(item)}
|
|
54
52
|
{renderItemDiff(item)}
|
package/src/tui/event-store.ts
CHANGED
|
@@ -393,6 +393,19 @@ function formatSurfaceComponent(component: string): string {
|
|
|
393
393
|
|
|
394
394
|
export const applyAgentEvent = reduceTuiProjection;
|
|
395
395
|
|
|
396
|
+
export function timelineStreamItems(state: TuiProjectionState): TimelineItem[] {
|
|
397
|
+
return [
|
|
398
|
+
...state.notices,
|
|
399
|
+
...state.recentTurns.flatMap((turn) => turn.items),
|
|
400
|
+
...(state.activeTurn?.items ?? []),
|
|
401
|
+
];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function firstRunningIndex(items: readonly TimelineItem[]): number {
|
|
405
|
+
const index = items.findIndex((item) => item.status === "running");
|
|
406
|
+
return index === -1 ? items.length : index;
|
|
407
|
+
}
|
|
408
|
+
|
|
396
409
|
export function visibleTimelineItems(state: TuiProjectionState): TimelineItem[] {
|
|
397
410
|
const items = [...state.notices];
|
|
398
411
|
|
|
@@ -697,8 +710,8 @@ function toolCallSummary(input: { name: string; args: unknown }): string {
|
|
|
697
710
|
if (input.name === "Grep") {
|
|
698
711
|
return `Grep ${toolPattern(input.args) ?? ""}`.trim();
|
|
699
712
|
}
|
|
700
|
-
if (input.name === "WebSearch") {
|
|
701
|
-
return
|
|
713
|
+
if (input.name === "WebSearch" || input.name === "MemorySearch") {
|
|
714
|
+
return `${input.name} ${toolQuery(input.args) ?? ""}`.trim();
|
|
702
715
|
}
|
|
703
716
|
if (input.name === "WebFetch") {
|
|
704
717
|
return `WebFetch ${toolUrl(input.args) ?? ""}`.trim();
|
|
@@ -779,6 +792,11 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
|
|
|
779
792
|
return raw.mode === "search"
|
|
780
793
|
? `${base} -> ${raw.page.hits.length} historical match${raw.page.hits.length === 1 ? "" : "es"}`
|
|
781
794
|
: `${base} -> ${raw.page.returnedBytes} historical bytes`;
|
|
795
|
+
case "memory_search":
|
|
796
|
+
if (!raw.ok) {
|
|
797
|
+
return base;
|
|
798
|
+
}
|
|
799
|
+
return `${base} -> ${raw.matches.length} derived memor${raw.matches.length === 1 ? "y" : "ies"}`;
|
|
782
800
|
case "skill":
|
|
783
801
|
if (!raw.ok) {
|
|
784
802
|
return `${base} failed -> ${boundedToolError(raw.error)}`;
|
|
@@ -846,6 +864,7 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
|
|
|
846
864
|
case "web_search":
|
|
847
865
|
case "web_fetch":
|
|
848
866
|
case "recall":
|
|
867
|
+
case "memory_search":
|
|
849
868
|
case "skill":
|
|
850
869
|
case "mcp":
|
|
851
870
|
case "generic":
|
|
@@ -880,6 +899,7 @@ function toolRawResultDiff(
|
|
|
880
899
|
case "web_search":
|
|
881
900
|
case "web_fetch":
|
|
882
901
|
case "recall":
|
|
902
|
+
case "memory_search":
|
|
883
903
|
case "skill":
|
|
884
904
|
case "mcp":
|
|
885
905
|
case "generic":
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { BundledLanguage, BundledTheme } from "shiki";
|
|
2
|
+
|
|
3
|
+
export type TuiShikiHighlighter = (code: string, language?: string) => string;
|
|
4
|
+
|
|
5
|
+
type ShikiToken = {
|
|
6
|
+
readonly content: string;
|
|
7
|
+
readonly color?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type TuiShikiTokenizer = {
|
|
11
|
+
codeToTokensBase(
|
|
12
|
+
code: string,
|
|
13
|
+
options: { readonly lang: string; readonly theme: string },
|
|
14
|
+
): readonly (readonly ShikiToken[])[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const THEME = "github-dark";
|
|
18
|
+
const HIGHLIGHTED_LANGUAGES = [
|
|
19
|
+
"typescript",
|
|
20
|
+
"javascript",
|
|
21
|
+
"tsx",
|
|
22
|
+
"jsx",
|
|
23
|
+
"json",
|
|
24
|
+
"bash",
|
|
25
|
+
"shellscript",
|
|
26
|
+
"python",
|
|
27
|
+
"markdown",
|
|
28
|
+
"html",
|
|
29
|
+
"css",
|
|
30
|
+
"yaml",
|
|
31
|
+
"diff",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
let preparation: Promise<void> | undefined;
|
|
35
|
+
let highlighter: TuiShikiHighlighter | undefined;
|
|
36
|
+
|
|
37
|
+
export function prepareShikiHighlighter(): Promise<void> {
|
|
38
|
+
preparation ??= createTuiShikiHighlighter(async () => {
|
|
39
|
+
const { createHighlighter } = await import("shiki");
|
|
40
|
+
const tokenizer = await createHighlighter({
|
|
41
|
+
themes: [THEME],
|
|
42
|
+
langs: [...HIGHLIGHTED_LANGUAGES],
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
codeToTokensBase: (code, options) =>
|
|
46
|
+
tokenizer.codeToTokensBase(code, {
|
|
47
|
+
lang: options.lang as BundledLanguage,
|
|
48
|
+
theme: options.theme as BundledTheme,
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
}).then((prepared) => {
|
|
52
|
+
highlighter = prepared;
|
|
53
|
+
});
|
|
54
|
+
return preparation;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getPreparedShikiHighlighter(): TuiShikiHighlighter | undefined {
|
|
58
|
+
return highlighter;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function createTuiShikiHighlighter(
|
|
62
|
+
createTokenizer: () => Promise<TuiShikiTokenizer>,
|
|
63
|
+
): Promise<TuiShikiHighlighter | undefined> {
|
|
64
|
+
try {
|
|
65
|
+
const tokenizer = await createTokenizer();
|
|
66
|
+
return (code, language) => {
|
|
67
|
+
if (language === undefined || language === "") {
|
|
68
|
+
return code;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return tokenizer
|
|
72
|
+
.codeToTokensBase(code, { lang: language, theme: THEME })
|
|
73
|
+
.map((line) =>
|
|
74
|
+
line
|
|
75
|
+
.map((token) => {
|
|
76
|
+
const ansi = tokenColorToAnsi(token.color);
|
|
77
|
+
return ansi === undefined
|
|
78
|
+
? token.content
|
|
79
|
+
: `${ansi}${token.content}\u001b[39m`;
|
|
80
|
+
})
|
|
81
|
+
.join(""),
|
|
82
|
+
)
|
|
83
|
+
.join("\n");
|
|
84
|
+
} catch {
|
|
85
|
+
return code;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function tokenColorToAnsi(color: string | undefined): string | undefined {
|
|
94
|
+
if (color === undefined || !color.startsWith("#") || color.length < 7) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const red = Number.parseInt(color.slice(1, 3), 16);
|
|
98
|
+
const green = Number.parseInt(color.slice(3, 5), 16);
|
|
99
|
+
const blue = Number.parseInt(color.slice(5, 7), 16);
|
|
100
|
+
if ([red, green, blue].some((component) => Number.isNaN(component))) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
return `\u001b[38;2;${red};${green};${blue}m`;
|
|
104
|
+
}
|
|
@@ -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" };
|
|
@@ -2,7 +2,11 @@ import type { EventSink } from "../events/event-sink";
|
|
|
2
2
|
import type { AgentEvent } from "../events/types";
|
|
3
3
|
import {
|
|
4
4
|
createInitialTuiProjectionState,
|
|
5
|
+
firstRunningIndex,
|
|
5
6
|
reduceTuiProjection,
|
|
7
|
+
timelineStreamItems,
|
|
8
|
+
visibleTimelineItems,
|
|
9
|
+
type TimelineItem,
|
|
6
10
|
type TuiProjectionState,
|
|
7
11
|
} from "./event-store";
|
|
8
12
|
import {
|
|
@@ -19,11 +23,18 @@ export type TuiProjectionStoreInput = {
|
|
|
19
23
|
initialSnapshot?: TuiProjectionState;
|
|
20
24
|
};
|
|
21
25
|
|
|
26
|
+
export type TuiTimelineLog = Readonly<{
|
|
27
|
+
committed: readonly TimelineItem[];
|
|
28
|
+
live: readonly TimelineItem[];
|
|
29
|
+
}>;
|
|
30
|
+
|
|
22
31
|
export class TuiProjectionStore implements EventSink {
|
|
23
32
|
readonly name = "tui-projection-store";
|
|
24
33
|
private readonly listeners = new Set<() => void>();
|
|
25
34
|
private readonly policy: TuiProjectionPolicy;
|
|
35
|
+
private readonly printed = new Set<string>();
|
|
26
36
|
private snapshot: TuiProjectionState;
|
|
37
|
+
private log: TuiTimelineLog = { committed: [], live: [] };
|
|
27
38
|
|
|
28
39
|
constructor(input: TuiProjectionStoreInput) {
|
|
29
40
|
this.policy = validateTuiProjectionPolicy(
|
|
@@ -33,10 +44,15 @@ export class TuiProjectionStore implements EventSink {
|
|
|
33
44
|
input.initialSnapshot === undefined
|
|
34
45
|
? createInitialTuiProjectionState(input)
|
|
35
46
|
: validateInitialSnapshot(input, input.initialSnapshot, this.policy);
|
|
47
|
+
if (input.initialSnapshot !== undefined) {
|
|
48
|
+
this.refreshLog(visibleTimelineItems(this.snapshot));
|
|
49
|
+
}
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
readonly getSnapshot = (): TuiProjectionState => this.snapshot;
|
|
39
53
|
|
|
54
|
+
readonly getLogSnapshot = (): TuiTimelineLog => this.log;
|
|
55
|
+
|
|
40
56
|
readonly subscribe = (listener: () => void): (() => void) => {
|
|
41
57
|
this.listeners.add(listener);
|
|
42
58
|
return () => {
|
|
@@ -51,6 +67,7 @@ export class TuiProjectionStore implements EventSink {
|
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
this.snapshot = next;
|
|
70
|
+
this.refreshLog();
|
|
54
71
|
for (const listener of this.listeners) {
|
|
55
72
|
listener();
|
|
56
73
|
}
|
|
@@ -73,10 +90,26 @@ export class TuiProjectionStore implements EventSink {
|
|
|
73
90
|
snapshot,
|
|
74
91
|
this.policy,
|
|
75
92
|
);
|
|
93
|
+
this.refreshLog(visibleTimelineItems(this.snapshot));
|
|
76
94
|
for (const listener of this.listeners) {
|
|
77
95
|
listener();
|
|
78
96
|
}
|
|
79
97
|
}
|
|
98
|
+
|
|
99
|
+
private refreshLog(stream = timelineStreamItems(this.snapshot)): void {
|
|
100
|
+
const settledEnd = firstRunningIndex(stream);
|
|
101
|
+
const pending = stream
|
|
102
|
+
.slice(0, settledEnd)
|
|
103
|
+
.filter((item) => !this.printed.has(item.id));
|
|
104
|
+
for (const item of pending) {
|
|
105
|
+
this.printed.add(item.id);
|
|
106
|
+
}
|
|
107
|
+
this.log = {
|
|
108
|
+
committed:
|
|
109
|
+
pending.length === 0 ? this.log.committed : [...this.log.committed, ...pending],
|
|
110
|
+
live: stream.slice(settledEnd),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
80
113
|
}
|
|
81
114
|
|
|
82
115
|
function validateInitialSnapshot(
|
|
@@ -47,11 +47,11 @@ export type TuiSessionController = {
|
|
|
47
47
|
listSessions: () => Promise<readonly SessionSummary[]>;
|
|
48
48
|
compact: () => Promise<ContextCompactionResult>;
|
|
49
49
|
retire: () => Promise<ContextRetirementResult>;
|
|
50
|
-
fork: () => Promise<SessionId>;
|
|
51
|
-
clear: () => Promise<void>;
|
|
52
|
-
resume: (sessionId: SessionId) => Promise<void>;
|
|
50
|
+
fork: (beforeCommit?: () => void) => Promise<SessionId>;
|
|
51
|
+
clear: (beforeCommit?: () => void) => Promise<void>;
|
|
52
|
+
resume: (sessionId: SessionId, beforeCommit?: () => void) => Promise<void>;
|
|
53
53
|
delete: (sessionId: SessionId) => Promise<void>;
|
|
54
|
-
switchModel: (profile: ModelProfile) => Promise<void>;
|
|
54
|
+
switchModel: (profile: ModelProfile, beforeCommit?: () => void) => Promise<void>;
|
|
55
55
|
};
|
|
56
56
|
|
|
57
57
|
export type ManagedTuiSessionBinding = TuiSessionBinding & {
|
|
@@ -100,7 +100,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
100
100
|
return this.serialize(() => this.binding.runtimeSession.retireContext());
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
fork(): Promise<SessionId> {
|
|
103
|
+
fork(beforeCommit?: () => void): Promise<SessionId> {
|
|
104
104
|
return this.serialize(async () => {
|
|
105
105
|
const targetSessionId = createUuidV7() as SessionId;
|
|
106
106
|
await this.replaceSession(
|
|
@@ -109,21 +109,23 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
109
109
|
await current.runtimeSession.cloneSession(targetSessionId);
|
|
110
110
|
return this.openSession(targetSessionId);
|
|
111
111
|
},
|
|
112
|
+
beforeCommit,
|
|
112
113
|
);
|
|
113
114
|
return targetSessionId;
|
|
114
115
|
});
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
clear(): Promise<void> {
|
|
118
|
+
clear(beforeCommit?: () => void): Promise<void> {
|
|
118
119
|
return this.serialize(() =>
|
|
119
120
|
this.replaceSession(
|
|
120
121
|
"Cannot clear the session while a turn, context operation, or background task is active.",
|
|
121
122
|
(current) => this.createFreshSession(current),
|
|
123
|
+
beforeCommit,
|
|
122
124
|
),
|
|
123
125
|
);
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
resume(sessionId: SessionId): Promise<void> {
|
|
128
|
+
resume(sessionId: SessionId, beforeCommit?: () => void): Promise<void> {
|
|
127
129
|
return this.serialize(async () => {
|
|
128
130
|
if (sessionId === this.binding.sessionId) {
|
|
129
131
|
throw new Error(`Session ${sessionId} is already current.`);
|
|
@@ -131,6 +133,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
131
133
|
await this.replaceSession(
|
|
132
134
|
"Cannot switch sessions while a turn or background task is active.",
|
|
133
135
|
() => this.openSession(sessionId),
|
|
136
|
+
beforeCommit,
|
|
134
137
|
);
|
|
135
138
|
});
|
|
136
139
|
}
|
|
@@ -139,11 +142,12 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
139
142
|
return this.serialize(() => this.catalog.delete(sessionId, this.binding.sessionId));
|
|
140
143
|
}
|
|
141
144
|
|
|
142
|
-
switchModel(profile: ModelProfile): Promise<void> {
|
|
145
|
+
switchModel(profile: ModelProfile, beforeCommit?: () => void): Promise<void> {
|
|
143
146
|
return this.serialize(() =>
|
|
144
147
|
this.replaceSession(
|
|
145
148
|
"Cannot switch models while a turn or background task is active.",
|
|
146
149
|
() => this.createSessionWithProfile(profile),
|
|
150
|
+
beforeCommit,
|
|
147
151
|
),
|
|
148
152
|
);
|
|
149
153
|
}
|
|
@@ -157,6 +161,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
157
161
|
createTarget: (
|
|
158
162
|
current: ManagedTuiSessionBinding,
|
|
159
163
|
) => Promise<ManagedTuiSessionBinding>,
|
|
164
|
+
beforeCommit?: () => void,
|
|
160
165
|
): Promise<void> {
|
|
161
166
|
const current = this.binding;
|
|
162
167
|
if (!current.runtimeSession.canSwitchSession()) {
|
|
@@ -172,6 +177,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
172
177
|
.catch(() => undefined);
|
|
173
178
|
throw error;
|
|
174
179
|
}
|
|
180
|
+
beforeCommit?.();
|
|
175
181
|
this.binding = target;
|
|
176
182
|
for (const listener of this.listeners) {
|
|
177
183
|
listener();
|