@saadnvd1/agent-os 0.3.672 → 0.3.678
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 +2 -1
- package/app/api/bus/read/route.ts +34 -0
- package/app/api/files/upload-temp/route.test.ts +144 -0
- package/app/api/files/upload-temp/route.ts +52 -21
- package/app/api/search/conversations/context/route.ts +26 -0
- package/app/api/search/conversations/route.test.ts +139 -0
- package/app/api/search/conversations/route.ts +25 -0
- package/app/api/sessions/[id]/route.ts +2 -0
- package/app/api/sessions/route.ts +6 -2
- package/app/globals.css +25 -0
- package/components/Bookmarks/BookmarksDialog.tsx +2 -8
- package/components/Chat/ChatPanel.tsx +9 -2
- package/components/Chat/FileChip.tsx +67 -0
- package/components/Chat/Messages.tsx +8 -0
- package/components/Chat/Queue.tsx +6 -0
- package/components/Chat/composer/Attachments.tsx +16 -2
- package/components/Chat/composer/ComposerBody.tsx +35 -14
- package/components/Chat/composer/Toolbar.tsx +11 -8
- package/components/Chat/composer/editor.test.ts +1 -1
- package/components/Chat/composer/paste.ts +5 -4
- package/components/Chat/composer/useAttachFiles.ts +142 -0
- package/components/Chat/composer/useComposerEditor.ts +3 -3
- package/components/Chat/useDraft.ts +9 -2
- package/components/Chat/useGoToItem.ts +11 -0
- package/components/CommandPalette/CommandPalette.tsx +23 -156
- package/components/CommandPalette/CommandsBody.tsx +216 -0
- package/components/CommandPalette/conversations/ConversationSearch.tsx +246 -0
- package/components/CommandPalette/conversations/EmptyState.tsx +95 -0
- package/components/CommandPalette/conversations/Preview.tsx +129 -0
- package/components/CommandPalette/conversations/ResultList.tsx +91 -0
- package/components/CommandPalette/conversations/Snippet.tsx +24 -0
- package/components/CommandPalette/conversations/format.ts +30 -0
- package/components/CommandPalette/conversations/recent.test.ts +20 -0
- package/components/CommandPalette/conversations/recent.ts +35 -0
- package/components/CommandPalette/conversations/useOpenHit.ts +30 -0
- package/components/Draft/DraftPanel.tsx +7 -4
- package/components/Terminal/index.tsx +6 -5
- package/data/chat/useChat.ts +10 -3
- package/data/client-config/queries.ts +18 -0
- package/data/search/index.ts +65 -0
- package/data/search/keys.ts +7 -0
- package/data/sessions/queries.ts +2 -1
- package/hooks/useDebouncedValue.ts +11 -0
- package/lib/agents/brief.ts +1 -0
- package/lib/attachments/check.ts +50 -0
- package/lib/attachments/message.ts +49 -0
- package/lib/attachments/policy.ts +116 -0
- package/lib/attachments/store.ts +92 -0
- package/lib/attachments/types.ts +8 -0
- package/lib/bus/condense.ts +242 -0
- package/lib/bus/read-scope.ts +109 -0
- package/lib/bus/read.ts +181 -0
- package/lib/chat/client-send.ts +6 -2
- package/lib/chat/driver.ts +4 -1
- package/lib/chat/drivers/claude.ts +2 -0
- package/lib/chat/events.ts +13 -1
- package/lib/chat/pending.ts +2 -1
- package/lib/chat/queued.ts +24 -7
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/worker/host.ts +9 -3
- package/lib/chat/worker/protocol.ts +2 -0
- package/lib/config/client-config-types.ts +14 -3
- package/lib/config/client-config.ts +1 -1
- package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
- package/lib/db/migrations/20261019T1500_conversation_search.ts +12 -0
- package/lib/file-upload.ts +61 -27
- package/lib/intake/intake.ts +2 -17
- package/lib/intake/sniff.ts +1 -1
- package/lib/orchestrator/read.ts +18 -4
- package/lib/palette/registry.ts +4 -0
- package/lib/resurrect/index.ts +2 -3
- package/lib/search/conversations/backfill.ts +117 -0
- package/lib/search/conversations/buildMatchQuery.ts +31 -0
- package/lib/search/conversations/index.ts +9 -0
- package/lib/search/conversations/messageContext.ts +45 -0
- package/lib/search/conversations/schema.ts +71 -0
- package/lib/search/conversations/searchConversations.ts +151 -0
- package/lib/search/conversations/types.ts +45 -0
- package/lib/sessions/launch.ts +12 -6
- package/lib/sidebar/row-actions.ts +19 -8
- package/lib/sidebar/row-menu.ts +1 -1
- package/lib/tasks/restorable.ts +16 -0
- package/package.json +1 -1
- package/scripts/bench-conversation-search.ts +73 -0
- package/server.ts +3 -0
- package/stores/chatJump.ts +10 -0
- package/stores/palette.ts +22 -4
- package/components/Chat/composer/useImageDrop.ts +0 -52
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { db } from "../db";
|
|
2
|
+
import type { Session } from "../db";
|
|
3
|
+
import { getProject } from "../projects";
|
|
4
|
+
import { getWorkspace } from "../workspaces";
|
|
5
|
+
import { previousNames } from "../session-names";
|
|
6
|
+
import { resolveRef, type Candidate } from "./resolve";
|
|
7
|
+
|
|
8
|
+
export type ReadableSession = Session & { project_name: string | null };
|
|
9
|
+
|
|
10
|
+
export interface ReaderScope {
|
|
11
|
+
workspaceId: string | null;
|
|
12
|
+
projectId: string | null;
|
|
13
|
+
label: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// What the asking session may read: its workspace, or its project when it
|
|
17
|
+
// has none. The boundary the orchestrator's tools use.
|
|
18
|
+
export function readerScope(callerId: string): ReaderScope {
|
|
19
|
+
const caller = db
|
|
20
|
+
.prepare(`SELECT * FROM session_rows WHERE id = ?`)
|
|
21
|
+
.get(callerId) as Session | undefined;
|
|
22
|
+
if (!caller) throw new Error("aos read runs from inside an AgentOS session");
|
|
23
|
+
const project = caller.project_id ? getProject(caller.project_id) : null;
|
|
24
|
+
const workspaceId =
|
|
25
|
+
caller.workspace_id ?? (project && project.workspace_id) ?? null;
|
|
26
|
+
if (workspaceId)
|
|
27
|
+
return {
|
|
28
|
+
workspaceId,
|
|
29
|
+
projectId: null,
|
|
30
|
+
label: `workspace ${getWorkspace(workspaceId)?.name ?? workspaceId}`,
|
|
31
|
+
};
|
|
32
|
+
if (!project || project.is_uncategorized)
|
|
33
|
+
throw new Error(`${caller.name} isn't in a project`);
|
|
34
|
+
return {
|
|
35
|
+
workspaceId: null,
|
|
36
|
+
projectId: project.id,
|
|
37
|
+
label: `project ${project.name}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SELECT = `SELECT s.*, p.name AS project_name FROM session_rows s
|
|
42
|
+
LEFT JOIN projects p ON p.id = s.project_id
|
|
43
|
+
WHERE s.peer_mirror = 0 AND s.held_by_device IS NULL`;
|
|
44
|
+
|
|
45
|
+
const isLive = (s: Session) =>
|
|
46
|
+
!s.archived_at && (!s.task_status || s.task_status === "running");
|
|
47
|
+
|
|
48
|
+
function candidates(rows: ReadableSession[]): Candidate[] {
|
|
49
|
+
const old = previousNames();
|
|
50
|
+
return rows.map((s) => ({
|
|
51
|
+
id: s.id,
|
|
52
|
+
name: s.name,
|
|
53
|
+
projectName: s.project_name,
|
|
54
|
+
tmuxName: s.tmux_name,
|
|
55
|
+
previousNames: old.get(s.id) ?? [],
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function inScope(s: ReadableSession, scope: ReaderScope): boolean {
|
|
60
|
+
if (s.role) return false;
|
|
61
|
+
if (scope.workspaceId) {
|
|
62
|
+
const p = s.project_id ? getProject(s.project_id) : null;
|
|
63
|
+
return !!p && p.workspace_id === scope.workspaceId;
|
|
64
|
+
}
|
|
65
|
+
return s.project_id === scope.projectId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type Readable =
|
|
69
|
+
| { ok: true; session: ReadableSession; note?: string }
|
|
70
|
+
| { ok: false; external: boolean; error: string };
|
|
71
|
+
|
|
72
|
+
// A session in the caller's scope by name, old name, project/name, id or id
|
|
73
|
+
// prefix. Done, dropped and archived ones count, but a live session wins a
|
|
74
|
+
// name over a finished one.
|
|
75
|
+
export function resolveReadable(callerId: string, ref: string): Readable {
|
|
76
|
+
const scope = readerScope(callerId);
|
|
77
|
+
const all = (db.prepare(SELECT).all() as ReadableSession[]).sort((a, b) =>
|
|
78
|
+
a.updated_at < b.updated_at ? 1 : -1
|
|
79
|
+
);
|
|
80
|
+
const mine = all.filter((s) => inScope(s, scope));
|
|
81
|
+
let none = "";
|
|
82
|
+
for (const group of [mine.filter(isLive), mine.filter((s) => !isLive(s))]) {
|
|
83
|
+
const r = resolveRef(ref, candidates(group));
|
|
84
|
+
if (r.ok) {
|
|
85
|
+
const session = group.find((s) => s.id === r.id)!;
|
|
86
|
+
if (session.id === callerId)
|
|
87
|
+
return { ok: false, external: false, error: "that's this session" };
|
|
88
|
+
return { ok: true, session, note: r.note };
|
|
89
|
+
}
|
|
90
|
+
if (r.reason === "ambiguous")
|
|
91
|
+
return { ok: false, external: false, error: r.error };
|
|
92
|
+
none = r.error;
|
|
93
|
+
}
|
|
94
|
+
const other = resolveRef(
|
|
95
|
+
ref,
|
|
96
|
+
candidates(all.filter((s) => !mine.includes(s)))
|
|
97
|
+
);
|
|
98
|
+
if (other.ok) {
|
|
99
|
+
const s = all.find((x) => x.id === other.id)!;
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
external: false,
|
|
103
|
+
error: s.role
|
|
104
|
+
? `${s.name} is an orchestrator: it isn't readable. Message it with aos send`
|
|
105
|
+
: `"${ref}" isn't in your ${scope.label}; aos read reaches only sessions there`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { ok: false, external: true, error: `${none}. Run: aos peers` };
|
|
109
|
+
}
|
package/lib/bus/read.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { db } from "../db";
|
|
4
|
+
import type { Session } from "../db";
|
|
5
|
+
import { lastItems, listItems } from "../chat/store";
|
|
6
|
+
import { untrusted } from "../orchestrator/untrusted";
|
|
7
|
+
import { readExternal, readResolved } from "../orchestrator/read";
|
|
8
|
+
import { findExternalSession } from "../orchestrator/external-sessions";
|
|
9
|
+
import { pieceOfSession, planPathOf } from "../plans/features";
|
|
10
|
+
import { hostLink } from "../hosts/remote-api";
|
|
11
|
+
import {
|
|
12
|
+
readerScope,
|
|
13
|
+
resolveReadable,
|
|
14
|
+
type ReadableSession,
|
|
15
|
+
} from "./read-scope";
|
|
16
|
+
import {
|
|
17
|
+
READ_DEFAULT_TURNS,
|
|
18
|
+
TERMINAL_LINES,
|
|
19
|
+
TERMINAL_LINES_ALL,
|
|
20
|
+
condenseItems,
|
|
21
|
+
grepTurns,
|
|
22
|
+
renderTurns,
|
|
23
|
+
type Rendered,
|
|
24
|
+
} from "./condense";
|
|
25
|
+
|
|
26
|
+
export interface ReadOpts {
|
|
27
|
+
turns?: number;
|
|
28
|
+
lines?: number;
|
|
29
|
+
all?: boolean;
|
|
30
|
+
grep?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReadResult {
|
|
34
|
+
// Ours, not another session's text: who, what state, how much.
|
|
35
|
+
header: string;
|
|
36
|
+
// Another session's text, fenced and redacted.
|
|
37
|
+
text: string;
|
|
38
|
+
session: { id: string | null; name: string; project: string | null };
|
|
39
|
+
task: { prompt: string | null; plan: string | null } | null;
|
|
40
|
+
turns: { from: number; to: number; total: number } | null;
|
|
41
|
+
truncated: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PLAN_FILE = ".agentos/plan.md";
|
|
45
|
+
const PROMPT_CAP = 1500;
|
|
46
|
+
// Items fetched per turn asked for, when the whole chat isn't needed.
|
|
47
|
+
const ITEMS_PER_TURN = 400;
|
|
48
|
+
|
|
49
|
+
export class ReadError extends Error {
|
|
50
|
+
constructor(
|
|
51
|
+
message: string,
|
|
52
|
+
readonly status: number
|
|
53
|
+
) {
|
|
54
|
+
super(message);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stateOf(s: Session): string {
|
|
59
|
+
if (s.archived_at) return "archived";
|
|
60
|
+
return s.task_status ?? "running";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function planOf(s: Session): string | null {
|
|
64
|
+
if (s.worktree_path) {
|
|
65
|
+
const own = path.join(s.worktree_path, PLAN_FILE);
|
|
66
|
+
if (fs.existsSync(own)) return own;
|
|
67
|
+
}
|
|
68
|
+
const piece = pieceOfSession(s.id);
|
|
69
|
+
const fromFeature = piece ? planPathOf(piece.feature_id) : null;
|
|
70
|
+
return fromFeature && fs.existsSync(fromFeature) ? fromFeature : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function taskHeader(s: Session, all: boolean) {
|
|
74
|
+
if (!s.task_prompt) return null;
|
|
75
|
+
const p = s.task_prompt.trim();
|
|
76
|
+
return {
|
|
77
|
+
prompt: !all && p.length > PROMPT_CAP ? `${p.slice(0, PROMPT_CAP)}…` : p,
|
|
78
|
+
plan: planOf(s),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function userCount(sessionId: string): number {
|
|
83
|
+
return (
|
|
84
|
+
db
|
|
85
|
+
.prepare(
|
|
86
|
+
`SELECT COUNT(*) AS n FROM chat_items WHERE session_id = ? AND kind = 'user'`
|
|
87
|
+
)
|
|
88
|
+
.get(sessionId) as { n: number }
|
|
89
|
+
).n;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function chatTurns(s: Session, o: ReadOpts) {
|
|
93
|
+
const total = userCount(s.id);
|
|
94
|
+
if (o.all || o.grep) return { turns: condenseItems(listItems(s.id)), total };
|
|
95
|
+
const asked = o.lines
|
|
96
|
+
? Math.ceil(o.lines / 4) + 1
|
|
97
|
+
: (o.turns ?? READ_DEFAULT_TURNS);
|
|
98
|
+
const items = lastItems(s.id, Math.min(asked * ITEMS_PER_TURN, 4000));
|
|
99
|
+
const inWindow = items.filter((i) => i.kind === "user").length;
|
|
100
|
+
return { turns: condenseItems(items, Math.max(total - inWindow, 0)), total };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// What a session in the caller's workspace said and did, condensed.
|
|
104
|
+
export async function readPeer(
|
|
105
|
+
callerId: string,
|
|
106
|
+
ref: string,
|
|
107
|
+
o: ReadOpts = {}
|
|
108
|
+
): Promise<ReadResult> {
|
|
109
|
+
let found;
|
|
110
|
+
try {
|
|
111
|
+
found = resolveReadable(callerId, ref);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
throw new ReadError(
|
|
114
|
+
error instanceof Error ? error.message : String(error),
|
|
115
|
+
403
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (!found.ok) {
|
|
119
|
+
const scope = readerScope(callerId);
|
|
120
|
+
const external =
|
|
121
|
+
found.external && scope.workspaceId
|
|
122
|
+
? await findExternalSession(scope.workspaceId, ref).catch(() => null)
|
|
123
|
+
: null;
|
|
124
|
+
if (external) {
|
|
125
|
+
const n = o.lines ?? (o.all ? TERMINAL_LINES_ALL : TERMINAL_LINES);
|
|
126
|
+
return {
|
|
127
|
+
header: `${external.ref} (external terminal)`,
|
|
128
|
+
text: await readExternal(external, n),
|
|
129
|
+
session: { id: null, name: external.ref, project: null },
|
|
130
|
+
task: null,
|
|
131
|
+
turns: null,
|
|
132
|
+
truncated: false,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
throw new ReadError(found.error, found.external ? 404 : 403);
|
|
136
|
+
}
|
|
137
|
+
const s: ReadableSession = found.session;
|
|
138
|
+
const who = { id: s.id, name: s.name, project: s.project_name };
|
|
139
|
+
const label = `${s.project_name ? `${s.project_name}/` : ""}${s.name}`;
|
|
140
|
+
const task = taskHeader(s, !!o.all);
|
|
141
|
+
const note = found.note ? ` (${found.note})` : "";
|
|
142
|
+
|
|
143
|
+
if (s.view !== "chat" || hostLink(s.host_id)) {
|
|
144
|
+
const n =
|
|
145
|
+
o.lines ?? (o.all || o.grep ? TERMINAL_LINES_ALL : TERMINAL_LINES);
|
|
146
|
+
return {
|
|
147
|
+
header: `${label}${note}: ${s.view}, ${stateOf(s)}`,
|
|
148
|
+
text: await readResolved(s, n, o.grep),
|
|
149
|
+
session: who,
|
|
150
|
+
task,
|
|
151
|
+
turns: null,
|
|
152
|
+
truncated: false,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const { turns, total } = chatTurns(s, o);
|
|
157
|
+
const r: Rendered = o.grep
|
|
158
|
+
? grepTurns(turns, o.grep, { all: o.all })
|
|
159
|
+
: renderTurns(turns, o);
|
|
160
|
+
const taskText = task
|
|
161
|
+
? `Task brief:\n${task.prompt}${task.plan ? `\nPlan: ${task.plan}` : ""}\n\n`
|
|
162
|
+
: "";
|
|
163
|
+
const body = r.text
|
|
164
|
+
? r.text
|
|
165
|
+
: o.grep
|
|
166
|
+
? `no turns match "${o.grep}"`
|
|
167
|
+
: "(no messages yet)";
|
|
168
|
+
const range = r.to
|
|
169
|
+
? `turns ${r.from === r.to ? r.to : `${r.from}-${r.to}`} of ${total}`
|
|
170
|
+
: `${total} turns`;
|
|
171
|
+
const more =
|
|
172
|
+
r.truncated && !o.all ? "; more with --turns N, --grep or --all" : "";
|
|
173
|
+
return {
|
|
174
|
+
header: `${label}${note} (chat, ${stateOf(s)}), ${o.grep ? `matches for "${o.grep}", ` : ""}${range}${more}`,
|
|
175
|
+
text: untrusted(s.name, `${taskText}${body}`),
|
|
176
|
+
session: who,
|
|
177
|
+
task,
|
|
178
|
+
turns: { from: r.from, to: r.to, total },
|
|
179
|
+
truncated: r.truncated,
|
|
180
|
+
};
|
|
181
|
+
}
|
package/lib/chat/client-send.ts
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { resolveFiles } from "../attachments/store";
|
|
2
|
+
import type { ChatFile, ChatImage } from "./events";
|
|
2
3
|
import type { CompactFirst } from "./stale";
|
|
3
4
|
|
|
4
|
-
// What a browser's send may carry: its text and
|
|
5
|
+
// What a browser's send may carry: its text, images and files, and whether the
|
|
5
6
|
// reader chose to compact a stale chat first. Who a message is from (`from`,
|
|
6
7
|
// `peer`, `origin`) is set by the server alone, never by a client. Its own
|
|
7
8
|
// id for the message is kept only in a shape no server id takes.
|
|
8
9
|
export function clientSend(msg: unknown): {
|
|
9
10
|
text: string;
|
|
10
11
|
images?: ChatImage[];
|
|
12
|
+
files?: ChatFile[];
|
|
11
13
|
compact?: CompactFirst;
|
|
12
14
|
id?: string;
|
|
13
15
|
} {
|
|
14
16
|
const m = (msg ?? {}) as {
|
|
15
17
|
text?: unknown;
|
|
16
18
|
images?: unknown;
|
|
19
|
+
files?: unknown;
|
|
17
20
|
compact?: unknown;
|
|
18
21
|
clientId?: unknown;
|
|
19
22
|
};
|
|
20
23
|
return {
|
|
21
24
|
text: typeof m.text === "string" ? m.text : "",
|
|
22
25
|
images: Array.isArray(m.images) ? (m.images as ChatImage[]) : undefined,
|
|
26
|
+
files: resolveFiles(m.files),
|
|
23
27
|
...(m.compact === true && { compact: "yes" as const }),
|
|
24
28
|
...(isClientId(m.clientId) && { id: m.clientId }),
|
|
25
29
|
};
|
package/lib/chat/driver.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ApprovalDecision,
|
|
7
7
|
ChatAccess,
|
|
8
8
|
ChatCommand,
|
|
9
|
+
ChatFile,
|
|
9
10
|
ChatImage,
|
|
10
11
|
ChatItem,
|
|
11
12
|
ChatModel,
|
|
@@ -88,7 +89,9 @@ export interface ChatConversation {
|
|
|
88
89
|
send(
|
|
89
90
|
text: string,
|
|
90
91
|
images?: ChatImage[],
|
|
91
|
-
|
|
92
|
+
// `files`: saved attachments; their paths are already in `text`, and a
|
|
93
|
+
// driver may also hand some to the model directly.
|
|
94
|
+
options?: { now?: boolean; files?: ChatFile[] }
|
|
92
95
|
): string | undefined;
|
|
93
96
|
// A command the driver answers itself, without starting a turn (/mcp):
|
|
94
97
|
// the items to show, or null to send the text as a message.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { documentBlocks } from "../../attachments/message";
|
|
1
2
|
import { sessionBaseEnv } from "../../agents/server-env";
|
|
2
3
|
import { randomUUID } from "crypto";
|
|
3
4
|
import {
|
|
@@ -320,6 +321,7 @@ export const claudeDriver: ChatDriver = {
|
|
|
320
321
|
data: img.data,
|
|
321
322
|
},
|
|
322
323
|
})),
|
|
324
|
+
...documentBlocks(sendOptions?.files),
|
|
323
325
|
{ type: "text" as const, text },
|
|
324
326
|
];
|
|
325
327
|
const checkpoint = randomUUID();
|
package/lib/chat/events.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { CallUsage } from "../usage/types";
|
|
2
2
|
import type { SearchResult } from "./find";
|
|
3
|
+
import type { ChatFile } from "../attachments/types";
|
|
4
|
+
|
|
5
|
+
export type { ChatFile };
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* The provider-neutral shape of a chat conversation. Every driver (Claude
|
|
@@ -152,6 +155,8 @@ export type ChatItem =
|
|
|
152
155
|
kind: "user";
|
|
153
156
|
text: string;
|
|
154
157
|
images?: ChatImage[];
|
|
158
|
+
// Saved in the session's attachments folder, by reference.
|
|
159
|
+
files?: ChatFile[];
|
|
155
160
|
// Who sent it through the agent bus, when not typed here.
|
|
156
161
|
from?: string;
|
|
157
162
|
// Sent by another agent session: shown as its body, while the agent
|
|
@@ -282,6 +287,7 @@ export interface QueuedMessage {
|
|
|
282
287
|
id: string;
|
|
283
288
|
text: string;
|
|
284
289
|
imageCount?: number;
|
|
290
|
+
fileCount?: number;
|
|
285
291
|
createdAt: number;
|
|
286
292
|
}
|
|
287
293
|
|
|
@@ -413,7 +419,13 @@ export type ChatServerMessage =
|
|
|
413
419
|
// What a browser sends.
|
|
414
420
|
export type ChatClientMessage =
|
|
415
421
|
// `clientId` (`user-c-…`) becomes the message's id: a resend is a no-op.
|
|
416
|
-
| {
|
|
422
|
+
| {
|
|
423
|
+
type: "send";
|
|
424
|
+
text: string;
|
|
425
|
+
images?: ChatImage[];
|
|
426
|
+
files?: ChatFile[];
|
|
427
|
+
clientId?: string;
|
|
428
|
+
}
|
|
417
429
|
| { type: "queue_edit"; id: string; text: string }
|
|
418
430
|
| { type: "queue_move"; id: string; by: -1 | 1 }
|
|
419
431
|
| { type: "queue_delete"; id: string }
|
package/lib/chat/pending.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChatImage, ChatState } from "./events";
|
|
1
|
+
import type { ChatFile, ChatImage, ChatState } from "./events";
|
|
2
2
|
|
|
3
3
|
// A message the composer sent that isn't in the chat or its queue yet. It
|
|
4
4
|
// shows as a bubble at once, and stays (as failed, with Retry) if it never
|
|
@@ -7,6 +7,7 @@ export interface PendingSend {
|
|
|
7
7
|
id: string;
|
|
8
8
|
text: string;
|
|
9
9
|
images?: ChatImage[];
|
|
10
|
+
files?: ChatFile[];
|
|
10
11
|
compact?: boolean;
|
|
11
12
|
// "sending": not taken in yet. "accepted": the server has it and is
|
|
12
13
|
// starting the agent.
|
package/lib/chat/queued.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { db } from "../db";
|
|
2
2
|
import { HELD_SQL } from "./hold";
|
|
3
|
-
import type { ChatImage, QueuedMessage, SentBy } from "./events";
|
|
3
|
+
import type { ChatFile, ChatImage, QueuedMessage, SentBy } from "./events";
|
|
4
4
|
|
|
5
5
|
// Messages written while a turn runs, kept in SQLite so a reload or a
|
|
6
6
|
// restart never loses them. The worker sends them in order when the turn
|
|
@@ -15,6 +15,7 @@ interface Row {
|
|
|
15
15
|
id: string;
|
|
16
16
|
text: string;
|
|
17
17
|
images: string | null;
|
|
18
|
+
files: string | null;
|
|
18
19
|
sent_by: string | null;
|
|
19
20
|
created_at: number;
|
|
20
21
|
}
|
|
@@ -25,19 +26,21 @@ export function listQueue(sessionId: string): QueuedMessage[] {
|
|
|
25
26
|
return (
|
|
26
27
|
db
|
|
27
28
|
.prepare(
|
|
28
|
-
`SELECT id, text, image_count, created_at
|
|
29
|
+
`SELECT id, text, image_count, files, created_at
|
|
29
30
|
FROM chat_queue WHERE session_id = ? ORDER BY position, created_at`
|
|
30
31
|
)
|
|
31
32
|
.all(sessionId) as {
|
|
32
33
|
id: string;
|
|
33
34
|
text: string;
|
|
34
35
|
image_count: number;
|
|
36
|
+
files: string | null;
|
|
35
37
|
created_at: number;
|
|
36
38
|
}[]
|
|
37
39
|
).map((r) => ({
|
|
38
40
|
id: r.id,
|
|
39
41
|
text: r.text,
|
|
40
42
|
imageCount: r.image_count || undefined,
|
|
43
|
+
fileCount: r.files ? (JSON.parse(r.files) as unknown[]).length : undefined,
|
|
41
44
|
createdAt: r.created_at,
|
|
42
45
|
}));
|
|
43
46
|
}
|
|
@@ -45,7 +48,12 @@ export function listQueue(sessionId: string): QueuedMessage[] {
|
|
|
45
48
|
// At the back. A retried send with the same id is only queued once.
|
|
46
49
|
export function enqueue(
|
|
47
50
|
sessionId: string,
|
|
48
|
-
m: {
|
|
51
|
+
m: {
|
|
52
|
+
id: string;
|
|
53
|
+
text: string;
|
|
54
|
+
images?: ChatImage[];
|
|
55
|
+
files?: ChatFile[];
|
|
56
|
+
} & SentBy
|
|
49
57
|
): void {
|
|
50
58
|
const images = m.images?.length ? JSON.stringify(m.images) : null;
|
|
51
59
|
const sentBy: SentBy = { from: m.from, peer: m.peer, origin: m.origin };
|
|
@@ -59,8 +67,8 @@ export function enqueue(
|
|
|
59
67
|
if (n >= MAX_QUEUED)
|
|
60
68
|
throw new Error(`The queue is full (${MAX_QUEUED} messages)`);
|
|
61
69
|
db.prepare(
|
|
62
|
-
`INSERT OR IGNORE INTO chat_queue (id, session_id, position, text, images, image_count, sent_by, created_at)
|
|
63
|
-
VALUES (?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM chat_queue WHERE session_id = ?), ?, ?, ?, ?, ?)`
|
|
70
|
+
`INSERT OR IGNORE INTO chat_queue (id, session_id, position, text, images, image_count, files, sent_by, created_at)
|
|
71
|
+
VALUES (?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM chat_queue WHERE session_id = ?), ?, ?, ?, ?, ?, ?)`
|
|
64
72
|
).run(
|
|
65
73
|
m.id,
|
|
66
74
|
sessionId,
|
|
@@ -68,6 +76,7 @@ export function enqueue(
|
|
|
68
76
|
m.text,
|
|
69
77
|
images,
|
|
70
78
|
m.images?.length ?? 0,
|
|
79
|
+
m.files?.length ? JSON.stringify(m.files) : null,
|
|
71
80
|
m.from || m.peer || m.origin ? JSON.stringify(sentBy) : null,
|
|
72
81
|
Date.now()
|
|
73
82
|
);
|
|
@@ -80,13 +89,20 @@ export function claimNext(
|
|
|
80
89
|
sessionId: string,
|
|
81
90
|
// That message only, if it's still there.
|
|
82
91
|
id?: string
|
|
83
|
-
):
|
|
92
|
+
):
|
|
93
|
+
| ({
|
|
94
|
+
id: string;
|
|
95
|
+
text: string;
|
|
96
|
+
images?: ChatImage[];
|
|
97
|
+
files?: ChatFile[];
|
|
98
|
+
} & SentBy)
|
|
99
|
+
| null {
|
|
84
100
|
const row = db
|
|
85
101
|
.prepare(
|
|
86
102
|
`DELETE FROM chat_queue WHERE session_id = @sid AND NOT ${HELD_SQL} AND id = (
|
|
87
103
|
SELECT id FROM chat_queue WHERE session_id = @sid AND (@id IS NULL OR id = @id)
|
|
88
104
|
ORDER BY position, created_at LIMIT 1
|
|
89
|
-
) RETURNING id, text, images, sent_by, created_at`
|
|
105
|
+
) RETURNING id, text, images, files, sent_by, created_at`
|
|
90
106
|
)
|
|
91
107
|
.get({ sid: sessionId, id: id ?? null }) as Row | undefined;
|
|
92
108
|
if (!row) return null;
|
|
@@ -94,6 +110,7 @@ export function claimNext(
|
|
|
94
110
|
id: row.id,
|
|
95
111
|
text: row.text,
|
|
96
112
|
images: row.images ? (JSON.parse(row.images) as ChatImage[]) : undefined,
|
|
113
|
+
files: row.files ? (JSON.parse(row.files) as ChatFile[]) : undefined,
|
|
97
114
|
...(row.sent_by ? (JSON.parse(row.sent_by) as SentBy) : {}),
|
|
98
115
|
};
|
|
99
116
|
}
|
package/lib/chat/runner.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { CompactFirst } from "./stale";
|
|
|
19
19
|
import type {
|
|
20
20
|
ApprovalDecision,
|
|
21
21
|
ChatContext,
|
|
22
|
+
ChatFile,
|
|
22
23
|
ChatImage,
|
|
23
24
|
ChatServerMessage,
|
|
24
25
|
ChatState,
|
|
@@ -405,7 +406,12 @@ function refuseAway(sessionId: string): void {
|
|
|
405
406
|
// started): the message waits in the queue instead of starting a turn.
|
|
406
407
|
function queuedIfHeld(
|
|
407
408
|
sessionId: string,
|
|
408
|
-
m: {
|
|
409
|
+
m: {
|
|
410
|
+
id: string;
|
|
411
|
+
text: string;
|
|
412
|
+
images?: ChatImage[];
|
|
413
|
+
files?: ChatFile[];
|
|
414
|
+
} & SentBy
|
|
409
415
|
): boolean {
|
|
410
416
|
refuseAway(sessionId);
|
|
411
417
|
if (!chatHold(sessionId)) return false;
|
|
@@ -429,6 +435,7 @@ export async function sendChat(
|
|
|
429
435
|
input: {
|
|
430
436
|
text: string;
|
|
431
437
|
images?: ChatImage[];
|
|
438
|
+
files?: ChatFile[];
|
|
432
439
|
// Typed in the composer: waits in the queue while a turn runs.
|
|
433
440
|
queue?: boolean;
|
|
434
441
|
// Compact a stale chat first (lib/chat/stale).
|
|
@@ -438,7 +445,7 @@ export async function sendChat(
|
|
|
438
445
|
} & SentBy
|
|
439
446
|
): Promise<void> {
|
|
440
447
|
const text = input.text.trim();
|
|
441
|
-
if (!text && !input.images?.length) return;
|
|
448
|
+
if (!text && !input.images?.length && !input.files?.length) return;
|
|
442
449
|
if (input.id && taken(sessionId, input.id)) return;
|
|
443
450
|
refuseAway(sessionId);
|
|
444
451
|
const id = input.id ?? `user-${Date.now()}-${randomUUID().slice(0, 5)}`;
|
|
@@ -449,6 +456,7 @@ export async function sendChat(
|
|
|
449
456
|
id,
|
|
450
457
|
text,
|
|
451
458
|
images: input.images,
|
|
459
|
+
files: input.files,
|
|
452
460
|
...sentBy(input),
|
|
453
461
|
});
|
|
454
462
|
emitQueue(sessionId);
|
|
@@ -463,6 +471,7 @@ export async function sendChat(
|
|
|
463
471
|
id,
|
|
464
472
|
text,
|
|
465
473
|
images: input.images,
|
|
474
|
+
files: input.files,
|
|
466
475
|
...sentBy(input),
|
|
467
476
|
queue: input.queue,
|
|
468
477
|
compact: input.compact,
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -10,10 +10,12 @@ import { agentEnv } from "../../agents/launch";
|
|
|
10
10
|
import { sessionRunningBrief } from "../../project-config/session";
|
|
11
11
|
import { BUS_BRIEF } from "../../agents/brief";
|
|
12
12
|
import { resolveModelForAgent } from "../../model-catalog";
|
|
13
|
+
import { agentText } from "../../attachments/message";
|
|
13
14
|
import { chatDriverFor } from "../drivers";
|
|
14
15
|
import type { ChatConversation } from "../driver";
|
|
15
16
|
import type {
|
|
16
17
|
ChatContext,
|
|
18
|
+
ChatFile,
|
|
17
19
|
ChatImage,
|
|
18
20
|
ChatItem,
|
|
19
21
|
ChatState,
|
|
@@ -560,6 +562,7 @@ export class ChatHost {
|
|
|
560
562
|
id: string;
|
|
561
563
|
text: string;
|
|
562
564
|
images?: ChatImage[];
|
|
565
|
+
files?: ChatFile[];
|
|
563
566
|
} & SentBy,
|
|
564
567
|
now = false
|
|
565
568
|
): void {
|
|
@@ -577,7 +580,7 @@ export class ChatHost {
|
|
|
577
580
|
if (now) this.nowPending++;
|
|
578
581
|
this.stopping = null;
|
|
579
582
|
const note = this.refreshedNote ?? (this.restartedNote && RESTARTED_NOTE);
|
|
580
|
-
const text = note ? `${note}\n\n${m.text}` : m.text;
|
|
583
|
+
const text = agentText(note ? `${note}\n\n${m.text}` : m.text, m.files);
|
|
581
584
|
if (this.refreshedNote) {
|
|
582
585
|
this.refreshedNote = null;
|
|
583
586
|
db.prepare(`UPDATE sessions SET chat_refreshed = 0 WHERE id = ?`).run(
|
|
@@ -592,13 +595,16 @@ export class ChatHost {
|
|
|
592
595
|
}
|
|
593
596
|
if (/^\/compact\b/.test(m.text.trimStart())) this.setCompacting(true);
|
|
594
597
|
const checkpoint = now
|
|
595
|
-
? this.conversation.send(text, m.images, { now })
|
|
596
|
-
:
|
|
598
|
+
? this.conversation.send(text, m.images, { now, files: m.files })
|
|
599
|
+
: m.files?.length
|
|
600
|
+
? this.conversation.send(text, m.images, { files: m.files })
|
|
601
|
+
: this.conversation.send(text, m.images);
|
|
597
602
|
const user: ChatItem = {
|
|
598
603
|
id: m.id,
|
|
599
604
|
kind: "user",
|
|
600
605
|
text: m.text,
|
|
601
606
|
images: m.images,
|
|
607
|
+
files: m.files,
|
|
602
608
|
from: m.from,
|
|
603
609
|
peer: m.peer,
|
|
604
610
|
origin: m.origin,
|
|
@@ -9,6 +9,7 @@ import path from "path";
|
|
|
9
9
|
import type {
|
|
10
10
|
ApprovalDecision,
|
|
11
11
|
ChatAccess,
|
|
12
|
+
ChatFile,
|
|
12
13
|
ChatImage,
|
|
13
14
|
ChatItem,
|
|
14
15
|
ChatState,
|
|
@@ -32,6 +33,7 @@ export type WorkerCommand =
|
|
|
32
33
|
id: string;
|
|
33
34
|
text: string;
|
|
34
35
|
images?: ChatImage[];
|
|
36
|
+
files?: ChatFile[];
|
|
35
37
|
from?: string;
|
|
36
38
|
peer?: PeerMessage;
|
|
37
39
|
origin?: ChatOrigin;
|
|
@@ -28,10 +28,23 @@ export interface ClientConfig {
|
|
|
28
28
|
runTriggers: Record<string, string>;
|
|
29
29
|
features: ClientFeatures;
|
|
30
30
|
intake: IntakeLimits;
|
|
31
|
-
attachments:
|
|
31
|
+
attachments: AttachmentConfig;
|
|
32
32
|
voice: VoiceLimits;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// What a chat accepts, served from lib/attachments/policy.ts. Sizes in bytes.
|
|
36
|
+
export interface AttachmentConfig {
|
|
37
|
+
max: number;
|
|
38
|
+
maxBytes: number;
|
|
39
|
+
maxImageBytes: number;
|
|
40
|
+
pdfInlineBytes: number;
|
|
41
|
+
types: {
|
|
42
|
+
ext: string;
|
|
43
|
+
mediaType: string;
|
|
44
|
+
kind: "image" | "pdf" | "sheet" | "doc" | "text";
|
|
45
|
+
}[];
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
// Server kill switches for the phone's new sends.
|
|
36
49
|
export interface ClientFeatures {
|
|
37
50
|
shakeToReport: boolean;
|
|
@@ -66,8 +79,6 @@ export const INTAKE_LIMITS: IntakeLimits = {
|
|
|
66
79
|
logMaxLines: 200,
|
|
67
80
|
};
|
|
68
81
|
|
|
69
|
-
export const ATTACHMENT_LIMITS = { max: 8 };
|
|
70
|
-
|
|
71
82
|
export const VOICE_LIMITS: VoiceLimits = {
|
|
72
83
|
maxPhrases: 100,
|
|
73
84
|
holdMinMs: 250,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ATTACHMENT_LIMITS } from "../attachments/policy";
|
|
1
2
|
import { ACCESS_LEVELS, DEFAULT_NEW_CHAT_ACCESS } from "../chat/access-levels";
|
|
2
3
|
import { CHAT_ACCESS } from "../chat/events";
|
|
3
4
|
import {
|
|
@@ -7,7 +8,6 @@ import {
|
|
|
7
8
|
import { TASK_STATE_LABELS } from "../tasks/state-labels";
|
|
8
9
|
import { cachedCheck } from "../updates/check";
|
|
9
10
|
import {
|
|
10
|
-
ATTACHMENT_LIMITS,
|
|
11
11
|
CLIENT_CONFIG_VERSION,
|
|
12
12
|
INTAKE_LIMITS,
|
|
13
13
|
VOICE_LIMITS,
|