tinker-agent 1.0.65
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/README.md +173 -0
- package/package.json +78 -0
- package/patches/markdansi@0.3.2.patch +37 -0
- package/src/agent/context-builder.ts +43 -0
- package/src/agent/context-meter.ts +310 -0
- package/src/agent/loop.ts +525 -0
- package/src/agent/runtime-session.ts +1212 -0
- package/src/agent/session-ledger.ts +828 -0
- package/src/agent/turn-cancellation.ts +44 -0
- package/src/agent/types.ts +77 -0
- package/src/cli/config.ts +283 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/model-profiles.ts +289 -0
- package/src/cli/run-runner.ts +107 -0
- package/src/cli/tui-runner.tsx +290 -0
- package/src/context/compiled-context-hash.ts +138 -0
- package/src/context/compiled-context-validator.ts +209 -0
- package/src/context/context-manager.ts +362 -0
- package/src/context/context-policy.ts +8 -0
- package/src/context/context-protocol-validator.ts +463 -0
- package/src/context/context-revision-compiler.ts +281 -0
- package/src/context/context-revision.ts +111 -0
- package/src/context/context-source.ts +30 -0
- package/src/context/context-swap-renderer.ts +272 -0
- package/src/context/protocol-frame.ts +240 -0
- package/src/context/swap-planner.ts +725 -0
- package/src/events/append-private-file.ts +16 -0
- package/src/events/bash-result-detail.ts +70 -0
- package/src/events/composite-event-sink.ts +82 -0
- package/src/events/event-sink.ts +16 -0
- package/src/events/jsonl-event-log.ts +13 -0
- package/src/events/observation-text-log.ts +195 -0
- package/src/events/stdout-event-printer.ts +396 -0
- package/src/events/types.ts +263 -0
- package/src/ids/runtime-id.ts +68 -0
- package/src/ids/uuid-v7.ts +5 -0
- package/src/instructions/project-instructions.ts +242 -0
- package/src/mcp/mcp-config.ts +144 -0
- package/src/mcp/mcp-manager.ts +216 -0
- package/src/mcp/mcp-tool-executor.ts +178 -0
- package/src/model/committed-prefix-auditor.ts +68 -0
- package/src/model/fake-model-client.ts +280 -0
- package/src/model/model-client.ts +64 -0
- package/src/model/model-context-profile.ts +134 -0
- package/src/model/model-request-preflight.ts +120 -0
- package/src/model/openai-chat-mapping.ts +444 -0
- package/src/model/openai-chat-model-client.ts +190 -0
- package/src/model/prompt-prefix-hash.ts +47 -0
- package/src/model/token-estimator.ts +148 -0
- package/src/observation/observation-builder.ts +481 -0
- package/src/session/resume-projection.ts +616 -0
- package/src/session/session-catalog.ts +270 -0
- package/src/session/session-errors.ts +121 -0
- package/src/session/session-history-reader.ts +535 -0
- package/src/session/session-lock.ts +291 -0
- package/src/session/session-schema.ts +741 -0
- package/src/session/session-store.ts +3067 -0
- package/src/session/sqlite-session-ledger.ts +153 -0
- package/src/tools/bash-task.ts +617 -0
- package/src/tools/bash.ts +450 -0
- package/src/tools/cwd-state.ts +22 -0
- package/src/tools/edit.ts +428 -0
- package/src/tools/file-diff.ts +116 -0
- package/src/tools/glob.ts +202 -0
- package/src/tools/grep.ts +550 -0
- package/src/tools/hash.ts +9 -0
- package/src/tools/path-safety.ts +33 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/recall.ts +400 -0
- package/src/tools/registry.ts +213 -0
- package/src/tools/ripgrep.ts +220 -0
- package/src/tools/task-list.ts +59 -0
- package/src/tools/task-output-snapshot.ts +47 -0
- package/src/tools/task-output-tool.ts +62 -0
- package/src/tools/task-output.ts +159 -0
- package/src/tools/task-stop.ts +59 -0
- package/src/tools/task-tool-args.ts +29 -0
- package/src/tools/types.ts +330 -0
- package/src/tools/web-fetch/backend.ts +27 -0
- package/src/tools/web-fetch/browser-backend.ts +126 -0
- package/src/tools/web-fetch/exa-backend.ts +172 -0
- package/src/tools/web-fetch/index.ts +298 -0
- package/src/tools/web-fetch/local-backend.ts +267 -0
- package/src/tools/web-fetch/refiner.ts +78 -0
- package/src/tools/web-fetch/route.ts +95 -0
- package/src/tools/web-search.ts +300 -0
- package/src/tools/write.ts +244 -0
- package/src/tui/app.tsx +497 -0
- package/src/tui/components/assistant-markdown.tsx +47 -0
- package/src/tui/components/background-tasks.tsx +92 -0
- package/src/tui/components/bash-result-view.tsx +47 -0
- package/src/tui/components/context-status.tsx +127 -0
- package/src/tui/components/diff-view.tsx +151 -0
- package/src/tui/components/file-viewer.tsx +212 -0
- package/src/tui/components/footer.tsx +60 -0
- package/src/tui/components/header.tsx +21 -0
- package/src/tui/components/model-picker.tsx +142 -0
- package/src/tui/components/prompt-input.tsx +432 -0
- package/src/tui/components/resume-session-picker.tsx +273 -0
- package/src/tui/components/timeline.tsx +121 -0
- package/src/tui/context-format.ts +24 -0
- package/src/tui/event-store.ts +865 -0
- package/src/tui/git-branch.ts +23 -0
- package/src/tui/line-editor.ts +157 -0
- package/src/tui/prompt-history.ts +94 -0
- package/src/tui/slash-commands.ts +126 -0
- package/src/tui/tui-projection-policy.ts +35 -0
- package/src/tui/tui-projection-store.ts +123 -0
- package/src/tui/tui-session-controller.ts +170 -0
- package/src/tui/view-file.ts +122 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export async function readCurrentGitBranch(
|
|
2
|
+
workspaceRoot: string,
|
|
3
|
+
): Promise<string | undefined> {
|
|
4
|
+
try {
|
|
5
|
+
const subprocess = Bun.spawn(
|
|
6
|
+
["git", "-C", workspaceRoot, "symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
7
|
+
{ stdout: "pipe", stderr: "ignore" },
|
|
8
|
+
);
|
|
9
|
+
const [exitCode, stdout] = await Promise.all([
|
|
10
|
+
subprocess.exited,
|
|
11
|
+
new Response(subprocess.stdout).text(),
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
if (exitCode !== 0) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const branch = stdout.trim();
|
|
19
|
+
return branch === "" ? undefined : branch;
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
export type LineEditorState = {
|
|
2
|
+
value: string;
|
|
3
|
+
cursor: number;
|
|
4
|
+
preferredColumn?: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function createLineEditorState(value = ""): LineEditorState {
|
|
8
|
+
return { value, cursor: codePointLength(value) };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function insert(state: LineEditorState, text: string): LineEditorState {
|
|
12
|
+
if (text === "") {
|
|
13
|
+
return state;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const chars = [...state.value];
|
|
17
|
+
const inserted = [...text];
|
|
18
|
+
chars.splice(state.cursor, 0, ...inserted);
|
|
19
|
+
return { value: chars.join(""), cursor: state.cursor + inserted.length };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function backspace(state: LineEditorState): LineEditorState {
|
|
23
|
+
if (state.cursor === 0) {
|
|
24
|
+
return state;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const chars = [...state.value];
|
|
28
|
+
chars.splice(state.cursor - 1, 1);
|
|
29
|
+
return { value: chars.join(""), cursor: state.cursor - 1 };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function deleteForward(state: LineEditorState): LineEditorState {
|
|
33
|
+
const chars = [...state.value];
|
|
34
|
+
if (state.cursor >= chars.length) {
|
|
35
|
+
return state;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
chars.splice(state.cursor, 1);
|
|
39
|
+
return { value: chars.join(""), cursor: state.cursor };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function deleteToLineStart(state: LineEditorState): LineEditorState {
|
|
43
|
+
const chars = [...state.value];
|
|
44
|
+
const lineStart = currentLineStart(chars, state.cursor);
|
|
45
|
+
|
|
46
|
+
if (state.cursor > lineStart) {
|
|
47
|
+
chars.splice(lineStart, state.cursor - lineStart);
|
|
48
|
+
return { value: chars.join(""), cursor: lineStart };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (lineStart === 0) {
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
chars.splice(lineStart - 1, 1);
|
|
56
|
+
return { value: chars.join(""), cursor: lineStart - 1 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function moveLeft(state: LineEditorState): LineEditorState {
|
|
60
|
+
return state.cursor === 0 ? state : { value: state.value, cursor: state.cursor - 1 };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function moveRight(state: LineEditorState): LineEditorState {
|
|
64
|
+
return state.cursor >= codePointLength(state.value)
|
|
65
|
+
? state
|
|
66
|
+
: { value: state.value, cursor: state.cursor + 1 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function moveUp(state: LineEditorState): LineEditorState {
|
|
70
|
+
const chars = [...state.value];
|
|
71
|
+
const lineStart = currentLineStart(chars, state.cursor);
|
|
72
|
+
const preferredColumn = state.preferredColumn ?? state.cursor - lineStart;
|
|
73
|
+
|
|
74
|
+
if (lineStart === 0) {
|
|
75
|
+
return state.cursor === 0 ? state : { ...state, cursor: 0, preferredColumn };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const previousLineEnd = lineStart - 1;
|
|
79
|
+
const previousLineStart = currentLineStart(chars, previousLineEnd);
|
|
80
|
+
const cursor =
|
|
81
|
+
previousLineStart + Math.min(preferredColumn, previousLineEnd - previousLineStart);
|
|
82
|
+
|
|
83
|
+
return { ...state, cursor, preferredColumn };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function moveDown(state: LineEditorState): LineEditorState {
|
|
87
|
+
const chars = [...state.value];
|
|
88
|
+
const lineStart = currentLineStart(chars, state.cursor);
|
|
89
|
+
const preferredColumn = state.preferredColumn ?? state.cursor - lineStart;
|
|
90
|
+
const lineEnd = chars.indexOf("\n", state.cursor);
|
|
91
|
+
|
|
92
|
+
if (lineEnd === -1) {
|
|
93
|
+
return state.cursor === chars.length
|
|
94
|
+
? state
|
|
95
|
+
: { ...state, cursor: chars.length, preferredColumn };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const nextLineStart = lineEnd + 1;
|
|
99
|
+
const nextLineBreak = chars.indexOf("\n", nextLineStart);
|
|
100
|
+
const nextLineEnd = nextLineBreak === -1 ? chars.length : nextLineBreak;
|
|
101
|
+
const cursor = nextLineStart + Math.min(preferredColumn, nextLineEnd - nextLineStart);
|
|
102
|
+
|
|
103
|
+
return { ...state, cursor, preferredColumn };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function moveToLineStart(state: LineEditorState): LineEditorState {
|
|
107
|
+
const chars = [...state.value];
|
|
108
|
+
const lineStart = currentLineStart(chars, state.cursor);
|
|
109
|
+
|
|
110
|
+
if (state.cursor !== lineStart || lineStart === 0) {
|
|
111
|
+
return state.cursor === lineStart
|
|
112
|
+
? state
|
|
113
|
+
: { value: state.value, cursor: lineStart };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
value: state.value,
|
|
118
|
+
cursor: lineStart === 1 ? 0 : chars.lastIndexOf("\n", lineStart - 2) + 1,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function moveToLineEnd(state: LineEditorState): LineEditorState {
|
|
123
|
+
const chars = [...state.value];
|
|
124
|
+
const lineBreak = chars.indexOf("\n", state.cursor);
|
|
125
|
+
const lineEnd = lineBreak === -1 ? chars.length : lineBreak;
|
|
126
|
+
|
|
127
|
+
if (state.cursor !== lineEnd || lineEnd === chars.length) {
|
|
128
|
+
return state.cursor === lineEnd ? state : { value: state.value, cursor: lineEnd };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const nextLineBreak = chars.indexOf("\n", lineEnd + 1);
|
|
132
|
+
return {
|
|
133
|
+
value: state.value,
|
|
134
|
+
cursor: nextLineBreak === -1 ? chars.length : nextLineBreak,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function splitAtCursor(state: LineEditorState): {
|
|
139
|
+
before: string;
|
|
140
|
+
at: string;
|
|
141
|
+
after: string;
|
|
142
|
+
} {
|
|
143
|
+
const chars = [...state.value];
|
|
144
|
+
return {
|
|
145
|
+
before: chars.slice(0, state.cursor).join(""),
|
|
146
|
+
at: chars[state.cursor] ?? " ",
|
|
147
|
+
after: chars.slice(state.cursor + 1).join(""),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function currentLineStart(chars: string[], cursor: number): number {
|
|
152
|
+
return cursor === 0 ? 0 : chars.lastIndexOf("\n", cursor - 1) + 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function codePointLength(value: string): number {
|
|
156
|
+
return [...value].length;
|
|
157
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MAX_ENTRIES = 200;
|
|
5
|
+
|
|
6
|
+
export type PromptHistoryOptions = {
|
|
7
|
+
filePath?: string;
|
|
8
|
+
entries?: string[];
|
|
9
|
+
maxEntries?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export class PromptHistory {
|
|
13
|
+
readonly filePath?: string;
|
|
14
|
+
private readonly maxEntries: number;
|
|
15
|
+
private items: string[];
|
|
16
|
+
|
|
17
|
+
constructor(options: PromptHistoryOptions = {}) {
|
|
18
|
+
this.filePath = options.filePath;
|
|
19
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
20
|
+
this.items = trimToMax(options.entries ?? [], this.maxEntries);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static async load(
|
|
24
|
+
filePath: string,
|
|
25
|
+
options: { maxEntries?: number } = {},
|
|
26
|
+
): Promise<PromptHistory> {
|
|
27
|
+
let content: string;
|
|
28
|
+
try {
|
|
29
|
+
content = await readFile(filePath, "utf8");
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (isFileNotFound(error)) {
|
|
32
|
+
return new PromptHistory({ filePath, maxEntries: options.maxEntries });
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const entries: string[] = [];
|
|
38
|
+
for (const line of content.split("\n")) {
|
|
39
|
+
if (line.trim() === "") {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const parsed = parsePromptLine(line);
|
|
44
|
+
if (parsed !== undefined) {
|
|
45
|
+
entries.push(parsed);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return new PromptHistory({ filePath, entries, maxEntries: options.maxEntries });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get entries(): readonly string[] {
|
|
53
|
+
return this.items;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async append(prompt: string): Promise<void> {
|
|
57
|
+
if (prompt === "" || this.items.at(-1) === prompt) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.items = trimToMax([...this.items, prompt], this.maxEntries);
|
|
62
|
+
|
|
63
|
+
if (this.filePath === undefined) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await mkdir(path.dirname(this.filePath), { recursive: true });
|
|
68
|
+
await appendFile(this.filePath, `${JSON.stringify(prompt)}\n`, "utf8");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parsePromptLine(line: string): string | undefined {
|
|
73
|
+
try {
|
|
74
|
+
const parsed: unknown = JSON.parse(line);
|
|
75
|
+
return typeof parsed === "string" && parsed !== "" ? parsed : undefined;
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function trimToMax(entries: string[], maxEntries: number): string[] {
|
|
82
|
+
return entries.length > maxEntries
|
|
83
|
+
? entries.slice(entries.length - maxEntries)
|
|
84
|
+
: entries;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isFileNotFound(error: unknown): boolean {
|
|
88
|
+
return (
|
|
89
|
+
typeof error === "object" &&
|
|
90
|
+
error !== null &&
|
|
91
|
+
"code" in error &&
|
|
92
|
+
error.code === "ENOENT"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export type SlashCommand = {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const SLASH_COMMANDS: readonly SlashCommand[] = [
|
|
7
|
+
{ name: "status", description: "Show session and context details" },
|
|
8
|
+
{ name: "compact", description: "Compact eligible historical tool output" },
|
|
9
|
+
{ name: "view", description: "View a local UTF-8 text file" },
|
|
10
|
+
{ name: "model", description: "Switch model profile (new session)" },
|
|
11
|
+
{ name: "resume", description: "Choose or resume a session" },
|
|
12
|
+
{ name: "session", description: "Manage stored sessions" },
|
|
13
|
+
{ name: "quit", description: "Exit the TUI" },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export type ParsedSlashCommand =
|
|
17
|
+
| { type: "status" }
|
|
18
|
+
| { type: "compact" }
|
|
19
|
+
| { type: "view"; filePath: string }
|
|
20
|
+
| { type: "quit" }
|
|
21
|
+
| { type: "model" }
|
|
22
|
+
| { type: "model_switch"; profileName: string }
|
|
23
|
+
| { type: "resume_list" }
|
|
24
|
+
| { type: "resume"; sessionId: SessionId }
|
|
25
|
+
| { type: "session_delete"; sessionId: SessionId };
|
|
26
|
+
|
|
27
|
+
export class SlashCommandError extends Error {
|
|
28
|
+
constructor(message: string) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "SlashCommandError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseSlashCommand(input: string): ParsedSlashCommand {
|
|
35
|
+
const trimmed = input.trim();
|
|
36
|
+
if (trimmed === "/view") {
|
|
37
|
+
throw new SlashCommandError("Usage: /view <path>");
|
|
38
|
+
}
|
|
39
|
+
if (trimmed.startsWith("/view ") || trimmed.startsWith("/view\t")) {
|
|
40
|
+
const filePath = trimmed.slice(5).trim();
|
|
41
|
+
if (filePath === "") {
|
|
42
|
+
throw new SlashCommandError("Usage: /view <path>");
|
|
43
|
+
}
|
|
44
|
+
return { type: "view", filePath };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const tokens = trimmed.split(/\s+/);
|
|
48
|
+
const command = tokens[0];
|
|
49
|
+
if (command === "/status" && tokens.length === 1) {
|
|
50
|
+
return { type: "status" };
|
|
51
|
+
}
|
|
52
|
+
if (command === "/compact") {
|
|
53
|
+
if (tokens.length === 1) {
|
|
54
|
+
return { type: "compact" };
|
|
55
|
+
}
|
|
56
|
+
throw new SlashCommandError("Usage: /compact");
|
|
57
|
+
}
|
|
58
|
+
if (command === "/quit" && tokens.length === 1) {
|
|
59
|
+
return { type: "quit" };
|
|
60
|
+
}
|
|
61
|
+
if (command === "/model") {
|
|
62
|
+
if (tokens.length === 1) {
|
|
63
|
+
return { type: "model" };
|
|
64
|
+
}
|
|
65
|
+
if (tokens.length === 2) {
|
|
66
|
+
return { type: "model_switch", profileName: tokens[1] };
|
|
67
|
+
}
|
|
68
|
+
throw new SlashCommandError("Usage: /model [profile-name]");
|
|
69
|
+
}
|
|
70
|
+
if (command === "/resume") {
|
|
71
|
+
if (tokens.length === 1) {
|
|
72
|
+
return { type: "resume_list" };
|
|
73
|
+
}
|
|
74
|
+
if (tokens.length === 2) {
|
|
75
|
+
return { type: "resume", sessionId: parsePublicSessionId(tokens[1]) };
|
|
76
|
+
}
|
|
77
|
+
throw new SlashCommandError("Usage: /resume [session-id]");
|
|
78
|
+
}
|
|
79
|
+
if (command === "/session") {
|
|
80
|
+
if (tokens.length === 4 && tokens[1] === "delete" && tokens[3] === "--confirm") {
|
|
81
|
+
return {
|
|
82
|
+
type: "session_delete",
|
|
83
|
+
sessionId: parsePublicSessionId(tokens[2]),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
throw new SlashCommandError("Usage: /session delete <session-id> --confirm");
|
|
87
|
+
}
|
|
88
|
+
throw new SlashCommandError(`Unknown command: ${trimmed}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function matchSlashCommands(
|
|
92
|
+
input: string,
|
|
93
|
+
commands: readonly SlashCommand[] = SLASH_COMMANDS,
|
|
94
|
+
): SlashCommand[] {
|
|
95
|
+
if (!input.startsWith("/")) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const query = input.slice(1);
|
|
100
|
+
if (/\s/.test(query)) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return commands.filter((command) => command.name.startsWith(query));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function findSlashCommand(
|
|
108
|
+
input: string,
|
|
109
|
+
commands: readonly SlashCommand[] = SLASH_COMMANDS,
|
|
110
|
+
): SlashCommand | undefined {
|
|
111
|
+
if (!input.startsWith("/")) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const [name = ""] = input.slice(1).split(/\s+/, 1);
|
|
116
|
+
return commands.find((command) => command.name === name);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parsePublicSessionId(value: string): SessionId {
|
|
120
|
+
try {
|
|
121
|
+
return parseSessionId(value);
|
|
122
|
+
} catch {
|
|
123
|
+
throw new SlashCommandError(`Invalid session ID: ${value}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
import { parseSessionId, type SessionId } from "../ids/runtime-id";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type TuiProjectionPolicy = Readonly<{
|
|
2
|
+
recentTurnLimit: number;
|
|
3
|
+
itemLimitPerTurn: number;
|
|
4
|
+
sessionNoticeLimit: number;
|
|
5
|
+
completedTaskLimit: number;
|
|
6
|
+
}>;
|
|
7
|
+
|
|
8
|
+
export const defaultTuiProjectionPolicy: TuiProjectionPolicy = Object.freeze({
|
|
9
|
+
recentTurnLimit: 8,
|
|
10
|
+
itemLimitPerTurn: 40,
|
|
11
|
+
sessionNoticeLimit: 12,
|
|
12
|
+
completedTaskLimit: 12,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function validateTuiProjectionPolicy(
|
|
16
|
+
policy: TuiProjectionPolicy,
|
|
17
|
+
): TuiProjectionPolicy {
|
|
18
|
+
requireNonNegativeInteger(policy.recentTurnLimit, "recentTurnLimit");
|
|
19
|
+
requireMinimumInteger(policy.itemLimitPerTurn, 2, "itemLimitPerTurn");
|
|
20
|
+
requireNonNegativeInteger(policy.sessionNoticeLimit, "sessionNoticeLimit");
|
|
21
|
+
requireNonNegativeInteger(policy.completedTaskLimit, "completedTaskLimit");
|
|
22
|
+
return policy;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function requireMinimumInteger(value: number, minimum: number, name: string): void {
|
|
26
|
+
if (!Number.isInteger(value) || value < minimum) {
|
|
27
|
+
throw new Error(`TUI projection ${name} must be at least ${minimum}.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function requireNonNegativeInteger(value: number, name: string): void {
|
|
32
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
33
|
+
throw new Error(`TUI projection ${name} must be a non-negative integer.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { EventSink } from "../events/event-sink";
|
|
2
|
+
import type { AgentEvent } from "../events/types";
|
|
3
|
+
import {
|
|
4
|
+
createInitialTuiProjectionState,
|
|
5
|
+
reduceTuiProjection,
|
|
6
|
+
type TuiProjectionState,
|
|
7
|
+
} from "./event-store";
|
|
8
|
+
import {
|
|
9
|
+
defaultTuiProjectionPolicy,
|
|
10
|
+
type TuiProjectionPolicy,
|
|
11
|
+
validateTuiProjectionPolicy,
|
|
12
|
+
} from "./tui-projection-policy";
|
|
13
|
+
|
|
14
|
+
export type TuiProjectionStoreInput = {
|
|
15
|
+
sessionId: string;
|
|
16
|
+
modelName: string;
|
|
17
|
+
workspaceRoot: string;
|
|
18
|
+
policy?: TuiProjectionPolicy;
|
|
19
|
+
initialSnapshot?: TuiProjectionState;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export class TuiProjectionStore implements EventSink {
|
|
23
|
+
readonly name = "tui-projection-store";
|
|
24
|
+
private readonly listeners = new Set<() => void>();
|
|
25
|
+
private readonly policy: TuiProjectionPolicy;
|
|
26
|
+
private snapshot: TuiProjectionState;
|
|
27
|
+
|
|
28
|
+
constructor(input: TuiProjectionStoreInput) {
|
|
29
|
+
this.policy = validateTuiProjectionPolicy(
|
|
30
|
+
input.policy ?? defaultTuiProjectionPolicy,
|
|
31
|
+
);
|
|
32
|
+
this.snapshot =
|
|
33
|
+
input.initialSnapshot === undefined
|
|
34
|
+
? createInitialTuiProjectionState(input)
|
|
35
|
+
: validateInitialSnapshot(input, input.initialSnapshot, this.policy);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
readonly getSnapshot = (): TuiProjectionState => this.snapshot;
|
|
39
|
+
|
|
40
|
+
readonly subscribe = (listener: () => void): (() => void) => {
|
|
41
|
+
this.listeners.add(listener);
|
|
42
|
+
return () => {
|
|
43
|
+
this.listeners.delete(listener);
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
async append(event: AgentEvent): Promise<void> {
|
|
48
|
+
const next = reduceTuiProjection(this.snapshot, event, this.policy);
|
|
49
|
+
if (next === this.snapshot) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.snapshot = next;
|
|
54
|
+
for (const listener of this.listeners) {
|
|
55
|
+
listener();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
hydrate(snapshot: TuiProjectionState): void {
|
|
60
|
+
if (
|
|
61
|
+
this.snapshot.activeTurn !== undefined ||
|
|
62
|
+
this.snapshot.recentTurns.length !== 0 ||
|
|
63
|
+
this.snapshot.notices.length !== 0
|
|
64
|
+
) {
|
|
65
|
+
throw new Error("TUI projection can only be hydrated before live events.");
|
|
66
|
+
}
|
|
67
|
+
this.snapshot = validateInitialSnapshot(
|
|
68
|
+
{
|
|
69
|
+
sessionId: this.snapshot.sessionId,
|
|
70
|
+
modelName: this.snapshot.modelName,
|
|
71
|
+
workspaceRoot: this.snapshot.workspaceRoot,
|
|
72
|
+
},
|
|
73
|
+
snapshot,
|
|
74
|
+
this.policy,
|
|
75
|
+
);
|
|
76
|
+
for (const listener of this.listeners) {
|
|
77
|
+
listener();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateInitialSnapshot(
|
|
83
|
+
input: Pick<TuiProjectionStoreInput, "sessionId" | "modelName" | "workspaceRoot">,
|
|
84
|
+
snapshot: TuiProjectionState,
|
|
85
|
+
policy: TuiProjectionPolicy,
|
|
86
|
+
): TuiProjectionState {
|
|
87
|
+
if (
|
|
88
|
+
snapshot.sessionId !== input.sessionId ||
|
|
89
|
+
snapshot.modelName !== input.modelName ||
|
|
90
|
+
snapshot.workspaceRoot !== input.workspaceRoot
|
|
91
|
+
) {
|
|
92
|
+
throw new Error("Hydrated TUI projection identity does not match its store.");
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
snapshot.activeTurn !== undefined ||
|
|
96
|
+
snapshot.status === "running" ||
|
|
97
|
+
snapshot.backgroundTasks.length !== 0
|
|
98
|
+
) {
|
|
99
|
+
throw new Error("Hydrated TUI projection must be terminal and have no tasks.");
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
snapshot.recentTurns.length > policy.recentTurnLimit ||
|
|
103
|
+
snapshot.notices.length > policy.sessionNoticeLimit ||
|
|
104
|
+
snapshot.recentTurns.some(
|
|
105
|
+
(turn) =>
|
|
106
|
+
turn.status === "running" || turn.items.length > policy.itemLimitPerTurn,
|
|
107
|
+
)
|
|
108
|
+
) {
|
|
109
|
+
throw new Error("Hydrated TUI projection exceeds its bounded policy.");
|
|
110
|
+
}
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
...snapshot,
|
|
113
|
+
recentTurns: Object.freeze(
|
|
114
|
+
snapshot.recentTurns.map((turn) =>
|
|
115
|
+
Object.freeze({ ...turn, items: Object.freeze([...turn.items]) }),
|
|
116
|
+
),
|
|
117
|
+
) as unknown as TuiProjectionState["recentTurns"],
|
|
118
|
+
notices: Object.freeze([
|
|
119
|
+
...snapshot.notices,
|
|
120
|
+
]) as unknown as TuiProjectionState["notices"],
|
|
121
|
+
backgroundTasks: [],
|
|
122
|
+
});
|
|
123
|
+
}
|