@adecore/agents 0.21.1 → 0.22.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/dist/async.js +3 -8
- package/dist/chat/backend.d.ts +4 -0
- package/dist/chat/chat-core.d.ts +5 -2
- package/dist/chat/chat-core.js +8 -7
- package/dist/chat/chat-log.js +2 -2
- package/dist/chat/chat-session.d.ts +11 -2
- package/dist/chat/chat-session.js +141 -13
- package/dist/chat/chat-store.js +4 -7
- package/dist/chat/chat-title.js +4 -8
- package/dist/chat/claude-backend.d.ts +1 -0
- package/dist/chat/claude-backend.js +8 -6
- package/dist/chat/claude-protocol.d.ts +2 -0
- package/dist/chat/claude-protocol.js +21 -1
- package/dist/chat/claude-title.js +1 -1
- package/dist/chat/claude-transcript.d.ts +1 -0
- package/dist/chat/claude-transcript.js +1 -1
- package/dist/chat/codex-backend.d.ts +2 -2
- package/dist/chat/codex-backend.js +5 -8
- package/dist/chat/codex-protocol.js +1 -1
- package/dist/chat/codex-thread.js +4 -5
- package/dist/chat/composer-preferences.js +2 -3
- package/dist/chat/errors.d.ts +1 -1
- package/dist/chat/external-sessions.d.ts +26 -0
- package/dist/chat/external-sessions.js +136 -0
- package/dist/chat/fake-claude.js +73 -0
- package/dist/chat/limit-resume.js +2 -7
- package/dist/chat/thread-search.d.ts +14 -0
- package/dist/chat/thread-search.js +64 -0
- package/dist/chat/visual-store.js +8 -6
- package/dist/context/refusal.js +3 -7
- package/dist/context/verb.js +1 -5
- package/dist/host/agent-host.js +2 -2
- package/dist/host/handlers.js +1 -2
- package/dist/host/wiring.js +1 -1
- package/dist/lineage.js +2 -3
- package/dist/messages/deliver-message.js +6 -11
- package/dist/messages/deliver-notice.js +8 -12
- package/dist/messages/notice-store.js +3 -5
- package/dist/outbox/outbox-worker.js +5 -2
- package/dist/providers/accounts/service.d.ts +2 -1
- package/dist/providers/accounts/service.js +11 -13
- package/dist/providers/catalog.js +2 -1
- package/dist/providers/claude.js +0 -1
- package/dist/providers/detect.js +2 -10
- package/dist/record-directory.js +0 -1
- package/dist/tasks/task-coordinator.d.ts +1 -0
- package/dist/tasks/task-coordinator.js +34 -34
- package/dist/tasks/task-store.js +1 -1
- package/dist/tasks/task-work.js +2 -2
- package/dist/tasks/waiting-child.d.ts +1 -0
- package/dist/tasks/waiting-child.js +8 -6
- package/dist/tasks/wake-parent.js +15 -10
- package/dist/tasks/wiring.js +32 -37
- package/dist/usage/aggregate.js +62 -46
- package/dist/usage/exchange.js +1 -1
- package/dist/usage/limits/monitor.d.ts +2 -2
- package/dist/usage/limits/monitor.js +2 -2
- package/dist/usage/limits/probe.js +1 -1
- package/dist/usage/pricing.js +1 -1
- package/dist/usage/readers/codex.d.ts +1 -0
- package/dist/usage/readers/codex.js +1 -1
- package/dist/usage/roots.js +1 -2
- package/dist/usage/scanner.js +7 -9
- package/dist/usage/usage-service.d.ts +1 -2
- package/dist/usage/usage-service.js +3 -6
- package/package.json +3 -3
- package/src/async.ts +3 -9
- package/src/chat/backend.ts +2 -0
- package/src/chat/chat-core.ts +8 -7
- package/src/chat/chat-log.ts +2 -2
- package/src/chat/chat-session.ts +148 -13
- package/src/chat/chat-store.ts +4 -8
- package/src/chat/chat-title.ts +4 -8
- package/src/chat/claude-backend.ts +9 -6
- package/src/chat/claude-protocol.ts +23 -1
- package/src/chat/claude-title.ts +1 -1
- package/src/chat/claude-transcript.ts +2 -2
- package/src/chat/codex-backend.ts +5 -8
- package/src/chat/codex-protocol.ts +1 -1
- package/src/chat/codex-thread.ts +6 -5
- package/src/chat/composer-preferences.ts +2 -3
- package/src/chat/errors.ts +2 -0
- package/src/chat/external-sessions.ts +177 -0
- package/src/chat/fake-claude.ts +75 -1
- package/src/chat/limit-resume.ts +2 -7
- package/src/chat/thread-search.ts +87 -0
- package/src/chat/thread.ts +1 -1
- package/src/chat/visual-store.ts +9 -6
- package/src/chat/wake-chat.ts +4 -1
- package/src/context/refusal.ts +3 -7
- package/src/context/verb.ts +1 -5
- package/src/host/agent-host.ts +2 -2
- package/src/host/handlers.ts +1 -2
- package/src/host/wiring.ts +1 -1
- package/src/lineage.ts +2 -3
- package/src/messages/deliver-message.ts +6 -11
- package/src/messages/deliver-notice.ts +8 -12
- package/src/messages/notice-store.ts +3 -5
- package/src/outbox/outbox-worker.ts +5 -2
- package/src/providers/accounts/service.ts +12 -13
- package/src/providers/accounts/variables.ts +1 -0
- package/src/providers/catalog.ts +2 -1
- package/src/providers/claude.ts +0 -1
- package/src/providers/detect.ts +2 -11
- package/src/record-directory.ts +0 -1
- package/src/skills.ts +0 -1
- package/src/tasks/task-coordinator.ts +35 -33
- package/src/tasks/task-store.ts +1 -1
- package/src/tasks/task-work.ts +2 -2
- package/src/tasks/waiting-child.ts +10 -7
- package/src/tasks/wake-parent.ts +21 -16
- package/src/tasks/wiring.ts +33 -41
- package/src/usage/aggregate.ts +80 -50
- package/src/usage/exchange.ts +1 -1
- package/src/usage/limits/monitor.ts +4 -4
- package/src/usage/limits/probe.ts +2 -2
- package/src/usage/pricing.ts +1 -1
- package/src/usage/readers/codex.ts +1 -1
- package/src/usage/roots.ts +1 -2
- package/src/usage/scanner.ts +8 -10
- package/src/usage/usage-service.ts +5 -9
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { clipText } from '@adecore/agent-contracts';
|
|
3
|
+
import { isForkOrSubagent } from '../usage/readers/codex.ts';
|
|
4
|
+
import { claudeProjectSlug, spokenText } from './claude-transcript.ts';
|
|
5
|
+
|
|
6
|
+
/* What the listing may do to the file system; a fake in tests, `node:fs` in a host. */
|
|
7
|
+
export interface SessionFs {
|
|
8
|
+
/* The names in a folder; empty when it is not there. */
|
|
9
|
+
readdir(path: string): Promise<string[]>;
|
|
10
|
+
stat(path: string): Promise<{ mtimeMs: number } | null>;
|
|
11
|
+
/* The first `maxBytes` of a file as text; the last line may be cut short. */
|
|
12
|
+
readHead(path: string, maxBytes: number): Promise<string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* A conversation a CLI keeps on disk, started outside the app that lists it. */
|
|
16
|
+
export interface ExternalSession {
|
|
17
|
+
provider: 'claude' | 'codex';
|
|
18
|
+
sessionId: string;
|
|
19
|
+
/* The first thing the person said, on one line. */
|
|
20
|
+
title: string;
|
|
21
|
+
updatedAt: number;
|
|
22
|
+
cwd: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TITLE_LENGTH = 120;
|
|
26
|
+
// Claude writes hook output and attachments ahead of the first prompt, and Codex its instructions.
|
|
27
|
+
const HEAD_BYTES = 256 * 1024;
|
|
28
|
+
const CLAUDE_MAX_FILES = 200;
|
|
29
|
+
|
|
30
|
+
function titleOf(text: string): string {
|
|
31
|
+
return clipText(text.replace(/\s+/g, ' ').trim(), TITLE_LENGTH);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function records(head: string): Array<Record<string, unknown>> {
|
|
35
|
+
const lines = head.split('\n');
|
|
36
|
+
// The head ends wherever the byte limit fell, so its last line may not parse.
|
|
37
|
+
return lines.flatMap((line) => {
|
|
38
|
+
if (line === '') {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const parsed: unknown = JSON.parse(line);
|
|
43
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? [parsed as Record<string, unknown>] : [];
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CLAUDE_BOOKKEEPING = new Set(['summary', 'file-history-snapshot', 'queue-operation']);
|
|
51
|
+
|
|
52
|
+
/* A reminder or the wrapper of a slash command is written as a user line too, but nobody said it. */
|
|
53
|
+
function claudePrompt(entry: Record<string, unknown>): string | null {
|
|
54
|
+
if (CLAUDE_BOOKKEEPING.has(String(entry.type)) || entry.type !== 'user' || entry.isSidechain === true || entry.isMeta === true) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const message = typeof entry.message === 'object' && entry.message !== null ? (entry.message as Record<string, unknown>) : {};
|
|
58
|
+
const said = spokenText(message.content);
|
|
59
|
+
if (said === null || said.trim() === '' || said.startsWith('<command-') || said.startsWith('<local-command-')) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return said;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
* The conversations Claude Code kept for a folder, newest first. Only the folder's own directory is
|
|
67
|
+
* read, and only the `.jsonl` files in it, so the transcripts of subagents in `<session>/subagents` stay out.
|
|
68
|
+
* A file without a prompt somebody said is no conversation.
|
|
69
|
+
*/
|
|
70
|
+
export async function listClaudeSessions({ projectsDir, folder, fs }: { projectsDir: string; folder: string; fs: SessionFs }): Promise<ExternalSession[]> {
|
|
71
|
+
const dir = join(projectsDir, claudeProjectSlug(folder));
|
|
72
|
+
const names = (await fs.readdir(dir)).filter((name) => name.endsWith('.jsonl'));
|
|
73
|
+
const stats = await Promise.all(names.map(async (name) => ({ name, stat: await fs.stat(join(dir, name)) })));
|
|
74
|
+
const newest = stats
|
|
75
|
+
.flatMap(({ name, stat }) => (stat === null ? [] : [{ name, mtimeMs: stat.mtimeMs }]))
|
|
76
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
|
77
|
+
.slice(0, CLAUDE_MAX_FILES);
|
|
78
|
+
const sessions: ExternalSession[] = [];
|
|
79
|
+
for (const { name, mtimeMs } of newest) {
|
|
80
|
+
const prompt = records(await fs.readHead(join(dir, name), HEAD_BYTES))
|
|
81
|
+
.map(claudePrompt)
|
|
82
|
+
.find((said) => said !== null);
|
|
83
|
+
if (prompt !== undefined && prompt !== null) {
|
|
84
|
+
sessions.push({ provider: 'claude', sessionId: name.slice(0, -'.jsonl'.length), title: titleOf(prompt), updatedAt: mtimeMs, cwd: folder });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return sessions;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function codexPrompt(entry: Record<string, unknown>): string | null {
|
|
91
|
+
const payload = typeof entry.payload === 'object' && entry.payload !== null ? (entry.payload as Record<string, unknown>) : null;
|
|
92
|
+
if (payload === null) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (entry.type === 'event_msg' && payload.type === 'user_message' && typeof payload.message === 'string') {
|
|
96
|
+
return payload.message.trim() === '' ? null : payload.message;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* The newest names first; the folders are named by year, month and day, and the files by when they began. */
|
|
102
|
+
function newestFirst(names: string[]): string[] {
|
|
103
|
+
return [...names].sort().reverse();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function rolloutFiles(sessionsDir: string, fs: SessionFs, max: number): Promise<string[]> {
|
|
107
|
+
const files: string[] = [];
|
|
108
|
+
for (const year of newestFirst(await fs.readdir(sessionsDir))) {
|
|
109
|
+
for (const month of newestFirst(await fs.readdir(join(sessionsDir, year)))) {
|
|
110
|
+
for (const day of newestFirst(await fs.readdir(join(sessionsDir, year, month)))) {
|
|
111
|
+
const names = newestFirst(
|
|
112
|
+
(await fs.readdir(join(sessionsDir, year, month, day))).filter((name) => name.startsWith('rollout-') && name.endsWith('.jsonl'))
|
|
113
|
+
);
|
|
114
|
+
for (const name of names) {
|
|
115
|
+
files.push(join(sessionsDir, year, month, day, name));
|
|
116
|
+
if (files.length >= max) {
|
|
117
|
+
return files;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return files;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/*
|
|
127
|
+
* The conversations Codex kept for a folder, newest first, out of the newest `maxFiles` rollouts. A
|
|
128
|
+
* rollout counts when its first line, the session's metadata, names this folder and is no fork or subagent.
|
|
129
|
+
*/
|
|
130
|
+
export async function listCodexSessions({
|
|
131
|
+
sessionsDir,
|
|
132
|
+
folder,
|
|
133
|
+
fs,
|
|
134
|
+
maxFiles = 400
|
|
135
|
+
}: {
|
|
136
|
+
sessionsDir: string;
|
|
137
|
+
folder: string;
|
|
138
|
+
fs: SessionFs;
|
|
139
|
+
maxFiles?: number;
|
|
140
|
+
}): Promise<ExternalSession[]> {
|
|
141
|
+
const sessions: ExternalSession[] = [];
|
|
142
|
+
for (const path of await rolloutFiles(sessionsDir, fs, maxFiles)) {
|
|
143
|
+
const entries = records(await fs.readHead(path, HEAD_BYTES));
|
|
144
|
+
const meta = entries[0];
|
|
145
|
+
const payload =
|
|
146
|
+
meta?.type === 'session_meta' && typeof meta.payload === 'object' && meta.payload !== null ? (meta.payload as Record<string, unknown>) : null;
|
|
147
|
+
if (payload === null || payload.cwd !== folder || typeof payload.id !== 'string' || isForkOrSubagent(payload)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const prompt = entries.map(codexPrompt).find((said) => said !== null);
|
|
151
|
+
if (prompt === undefined || prompt === null) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const stat = await fs.stat(path);
|
|
155
|
+
sessions.push({ provider: 'codex', sessionId: payload.id, title: titleOf(prompt), updatedAt: stat?.mtimeMs ?? 0, cwd: folder });
|
|
156
|
+
}
|
|
157
|
+
return sessions;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* A `node:fs` behind the seam, for a host that lists the real thing. */
|
|
161
|
+
export async function nodeSessionFs(): Promise<SessionFs> {
|
|
162
|
+
const { readdir, stat, open } = await import('node:fs/promises');
|
|
163
|
+
return {
|
|
164
|
+
readdir: async (path) => readdir(path).catch(() => []),
|
|
165
|
+
stat: async (path) => stat(path).catch(() => null),
|
|
166
|
+
readHead: async (path, maxBytes) => {
|
|
167
|
+
const handle = await open(path, 'r');
|
|
168
|
+
try {
|
|
169
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
170
|
+
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
|
171
|
+
return buffer.subarray(0, bytesRead).toString('utf8');
|
|
172
|
+
} finally {
|
|
173
|
+
await handle.close();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
package/src/chat/fake-claude.ts
CHANGED
|
@@ -146,6 +146,40 @@ export function fakeClaude(io: FakeIo): FakeProgram {
|
|
|
146
146
|
// What a background agent asked; its answer comes in after the turn that launched it, whenever a person gives one.
|
|
147
147
|
let backgroundAsk: { requestId: string; command: string } | null = null;
|
|
148
148
|
let slow = false;
|
|
149
|
+
// The two foreground subagents of `two subagents` that have not settled; the turn stays open until both have.
|
|
150
|
+
const openTwo = new Set<string>();
|
|
151
|
+
|
|
152
|
+
const settleTwo = (name: 'a-one' | 'a-two', stopped: boolean): void => {
|
|
153
|
+
const toolUseId = name === 'a-one' ? 'toolu_two_a' : 'toolu_two_b';
|
|
154
|
+
if (!openTwo.delete(name)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (stopped) {
|
|
158
|
+
out({ type: 'system', subtype: 'task_updated', task_id: name, patch: { status: 'killed', end_time: 0 }, session_id: sessionId });
|
|
159
|
+
out({
|
|
160
|
+
type: 'system',
|
|
161
|
+
subtype: 'task_notification',
|
|
162
|
+
task_id: name,
|
|
163
|
+
tool_use_id: toolUseId,
|
|
164
|
+
status: 'stopped',
|
|
165
|
+
output_file: '',
|
|
166
|
+
summary: 'Stopped',
|
|
167
|
+
session_id: sessionId
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
out({
|
|
171
|
+
type: 'user',
|
|
172
|
+
message: {
|
|
173
|
+
role: 'user',
|
|
174
|
+
content: [{ type: 'tool_result', tool_use_id: toolUseId, content: stopped ? 'Stopped' : `${name} done`, is_error: stopped }]
|
|
175
|
+
},
|
|
176
|
+
session_id: sessionId
|
|
177
|
+
});
|
|
178
|
+
if (openTwo.size === 0) {
|
|
179
|
+
assistantText('summarized B');
|
|
180
|
+
result();
|
|
181
|
+
}
|
|
182
|
+
};
|
|
149
183
|
|
|
150
184
|
// The rest of a background agent once its request was answered: its call's result, its report, and the turn the CLI opens about it.
|
|
151
185
|
const finishBackgroundAgent = (command: string, allowed: boolean, message: string | undefined): void => {
|
|
@@ -193,6 +227,43 @@ export function fakeClaude(io: FakeIo): FakeProgram {
|
|
|
193
227
|
io.exit(1);
|
|
194
228
|
return;
|
|
195
229
|
}
|
|
230
|
+
// Two foreground subagents beside each other; the turn ends once both settled: a-two when the test runs what is put off, a-one by a `stop_task`.
|
|
231
|
+
if (text === 'two subagents') {
|
|
232
|
+
openTwo.add('a-one').add('a-two');
|
|
233
|
+
for (const [name, toolUseId] of [
|
|
234
|
+
['a-one', 'toolu_two_a'],
|
|
235
|
+
['a-two', 'toolu_two_b']
|
|
236
|
+
] as const) {
|
|
237
|
+
out({
|
|
238
|
+
type: 'assistant',
|
|
239
|
+
message: {
|
|
240
|
+
id: `msg_${nonce}_${++messageCounter}`,
|
|
241
|
+
model,
|
|
242
|
+
role: 'assistant',
|
|
243
|
+
content: [
|
|
244
|
+
{ type: 'tool_use', id: toolUseId, name: 'Agent', input: { description: name, subagent_type: 'general-purpose', prompt: name } }
|
|
245
|
+
],
|
|
246
|
+
usage
|
|
247
|
+
},
|
|
248
|
+
session_id: sessionId
|
|
249
|
+
});
|
|
250
|
+
out({
|
|
251
|
+
type: 'system',
|
|
252
|
+
subtype: 'task_started',
|
|
253
|
+
task_id: name,
|
|
254
|
+
tool_use_id: toolUseId,
|
|
255
|
+
description: name,
|
|
256
|
+
subagent_type: 'general-purpose',
|
|
257
|
+
is_backgrounded: false,
|
|
258
|
+
spawn_depth: 1,
|
|
259
|
+
task_type: 'local_agent',
|
|
260
|
+
prompt: name,
|
|
261
|
+
session_id: sessionId
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
io.later(() => settleTwo('a-two', false));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
196
267
|
if (text === 'crash loudly') {
|
|
197
268
|
io.err(`${'warming up\n'.repeat(2000)}Error: the fake lost its key\n at handleUser (fake-claude.ts)\n`);
|
|
198
269
|
io.exit(1);
|
|
@@ -952,8 +1023,11 @@ export function fakeClaude(io: FakeIo): FakeProgram {
|
|
|
952
1023
|
} else if (frame.type === 'control_response') {
|
|
953
1024
|
handleControlResponse(frame.response as Record<string, unknown>);
|
|
954
1025
|
} else if (frame.type === 'control_request') {
|
|
955
|
-
const request = frame.request as { subtype: string };
|
|
1026
|
+
const request = frame.request as { subtype: string; task_id?: string };
|
|
956
1027
|
out({ type: 'control_response', response: { subtype: 'success', request_id: frame.request_id, response: {} } });
|
|
1028
|
+
if (request.subtype === 'stop_task' && (request.task_id === 'a-one' || request.task_id === 'a-two')) {
|
|
1029
|
+
settleTwo(request.task_id, true);
|
|
1030
|
+
}
|
|
957
1031
|
if (request.subtype === 'interrupt' && slow) {
|
|
958
1032
|
slow = false;
|
|
959
1033
|
assistantText('[interrupted]');
|
package/src/chat/limit-resume.ts
CHANGED
|
@@ -5,13 +5,8 @@ export const LIMIT_RETRY_DELAYS_MS: readonly number[] = [60_000, 5 * 60_000, 15
|
|
|
5
5
|
|
|
6
6
|
/* The last turn of a thread, when it stopped on a limit. */
|
|
7
7
|
export function limitedTurn(items: readonly ChatItem[]): (ChatTurnItem & { limit: ChatTurnLimit }) | null {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (item.kind === 'turn') {
|
|
11
|
-
return item.state === 'error' && item.limit !== undefined ? (item as ChatTurnItem & { limit: ChatTurnLimit }) : null;
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
return null;
|
|
8
|
+
const last = items.findLast((item) => item.kind === 'turn');
|
|
9
|
+
return last?.kind === 'turn' && last.state === 'error' && last.limit !== undefined ? (last as ChatTurnItem & { limit: ChatTurnLimit }) : null;
|
|
15
10
|
}
|
|
16
11
|
|
|
17
12
|
/* How many turns in a row, up to and including this one, stopped on a limit: the tries a person did not step in between. */
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ChatItem } from '@adecore/agent-contracts';
|
|
2
|
+
|
|
3
|
+
/* One message of a thread that holds the query, cut to a line around the match. */
|
|
4
|
+
export interface ThreadHit {
|
|
5
|
+
itemId: string;
|
|
6
|
+
author: 'person' | 'agent';
|
|
7
|
+
snippet: string;
|
|
8
|
+
/* Where the match sits in `snippet`, in UTF-16 units. */
|
|
9
|
+
matchStart: number;
|
|
10
|
+
matchLength: number;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
turnId: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ThreadSearchOptions {
|
|
16
|
+
limit: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Characters kept on each side of the match.
|
|
20
|
+
const CONTEXT = 60;
|
|
21
|
+
|
|
22
|
+
// Emphasis and code markers carry no meaning in a one-line snippet, where they only read as noise.
|
|
23
|
+
function plain(text: string): string {
|
|
24
|
+
return text.replace(/\*{1,3}|_{2,}|`+/g, '').replace(/\s+/g, ' ');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Moves a cut forward to the start of the next word, never past `limit`, so a snippet does not open on half a word.
|
|
28
|
+
function wordStart(text: string, from: number, limit: number): number {
|
|
29
|
+
if (from === 0 || /\s/.test(text[from - 1]!) || /\s/.test(text[from]!)) {
|
|
30
|
+
return from;
|
|
31
|
+
}
|
|
32
|
+
const space = text.slice(from, limit).search(/\s/);
|
|
33
|
+
return space === -1 ? from : from + space + 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Moves a cut back to the end of the previous word, never before `limit`.
|
|
37
|
+
function wordEnd(text: string, to: number, limit: number): number {
|
|
38
|
+
if (to === text.length || /\s/.test(text[to - 1]!) || /\s/.test(text[to]!)) {
|
|
39
|
+
return to;
|
|
40
|
+
}
|
|
41
|
+
const space = text.slice(limit, to).search(/\s\S*$/);
|
|
42
|
+
return space === -1 ? to : limit + space;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function snippetOf(text: string, index: number, length: number): { snippet: string; matchStart: number; matchLength: number } {
|
|
46
|
+
const from = wordStart(text, Math.max(0, index - CONTEXT), index);
|
|
47
|
+
const to = wordEnd(text, Math.min(text.length, index + length + CONTEXT), index + length);
|
|
48
|
+
const before = `${from > 0 ? '…' : ''}${plain(text.slice(from, index))}`;
|
|
49
|
+
const match = plain(text.slice(index, index + length));
|
|
50
|
+
const after = `${plain(text.slice(index + length, to))}${to < text.length ? '…' : ''}`;
|
|
51
|
+
return { snippet: `${before}${match}${after}`, matchStart: before.length, matchLength: match.length };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
* The messages of a thread that hold `query`, oldest first, one hit per message. Only what a person
|
|
56
|
+
* and the agent said counts: what a subagent said (`parentToolUseId`) is not part of the conversation.
|
|
57
|
+
*/
|
|
58
|
+
export function searchThreadItems(items: readonly ChatItem[], query: string, { limit }: ThreadSearchOptions): ThreadHit[] {
|
|
59
|
+
if (query === '' || limit <= 0) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
|
63
|
+
const hits: ThreadHit[] = [];
|
|
64
|
+
for (const item of items) {
|
|
65
|
+
if (item.kind !== 'user' && item.kind !== 'assistant') {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (item.kind === 'assistant' && item.parentToolUseId) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const found = pattern.exec(item.text);
|
|
72
|
+
if (found === null) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
hits.push({
|
|
76
|
+
itemId: item.id,
|
|
77
|
+
author: item.kind === 'user' ? 'person' : 'agent',
|
|
78
|
+
...snippetOf(item.text, found.index, found[0].length),
|
|
79
|
+
createdAt: item.createdAt,
|
|
80
|
+
turnId: item.turnId
|
|
81
|
+
});
|
|
82
|
+
if (hits.length >= limit) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return hits;
|
|
87
|
+
}
|
package/src/chat/thread.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { AgentStatus, ChatApprovalItem, ChatEvent, ChatHistoryResult, ChatInfo, ChatItem, ChatQuestionItem } from '@adecore/agent-contracts';
|
|
2
3
|
import { ChatError } from './errors.ts';
|
|
3
4
|
import { requestSummaries } from './request-summary.ts';
|
|
4
|
-
import type { AgentStatus, ChatApprovalItem, ChatEvent, ChatHistoryResult, ChatInfo, ChatItem, ChatQuestionItem } from '@adecore/agent-contracts';
|
|
5
5
|
|
|
6
6
|
/*
|
|
7
7
|
* The state of one chat as the client sees it. Every mutation answers the event that describes
|
package/src/chat/visual-store.ts
CHANGED
|
@@ -113,6 +113,13 @@ function tooLarge(bytes: number): never {
|
|
|
113
113
|
);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
function refuseOversized(html: string): void {
|
|
117
|
+
const bytes = Buffer.byteLength(html, 'utf8');
|
|
118
|
+
if (bytes > VISUAL_LIMITS.bytes) {
|
|
119
|
+
tooLarge(bytes);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
116
123
|
/*
|
|
117
124
|
* The visuals of every chat. The list of a chat is one file beside its record under `<home>/chats`;
|
|
118
125
|
* each page is a file in the chat's attachment folder, `<id>.html`, so whatever serves an attachment
|
|
@@ -164,9 +171,7 @@ export class VisualStore {
|
|
|
164
171
|
if (html.trim() === '') {
|
|
165
172
|
refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
|
|
166
173
|
}
|
|
167
|
-
|
|
168
|
-
tooLarge(Buffer.byteLength(html, 'utf8'));
|
|
169
|
-
}
|
|
174
|
+
refuseOversized(html);
|
|
170
175
|
return this.writeWorkspaceFile(chatId, name, html);
|
|
171
176
|
}
|
|
172
177
|
|
|
@@ -178,9 +183,7 @@ export class VisualStore {
|
|
|
178
183
|
async publish(chatId: string, input: VisualInput): Promise<ChatVisual> {
|
|
179
184
|
const { title, html, maxHeight, layout, heights } = checked(input);
|
|
180
185
|
// Before the bootstrap is added, so a page far too large is never copied first.
|
|
181
|
-
|
|
182
|
-
tooLarge(Buffer.byteLength(html, 'utf8'));
|
|
183
|
-
}
|
|
186
|
+
refuseOversized(html);
|
|
184
187
|
const page = Buffer.from(injectVisualBootstrap(html), 'utf8');
|
|
185
188
|
if (page.byteLength > VISUAL_LIMITS.bytes) {
|
|
186
189
|
tooLarge(page.byteLength);
|
package/src/chat/wake-chat.ts
CHANGED
|
@@ -2,7 +2,10 @@ import type { ChatItem } from '@adecore/agent-contracts';
|
|
|
2
2
|
import type { ChatCore } from './chat-core.ts';
|
|
3
3
|
import type { ChatSession } from './chat-session.ts';
|
|
4
4
|
|
|
5
|
-
/*
|
|
5
|
+
/*
|
|
6
|
+
* A chat the host may open a turn in, as the outbox handlers see it. `wake` answers false while a
|
|
7
|
+
* turn or an owed resume is in the way.
|
|
8
|
+
*/
|
|
6
9
|
export interface WakeChat {
|
|
7
10
|
items(): ChatItem[];
|
|
8
11
|
persist?(): Promise<void>;
|
package/src/context/refusal.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* How every no reads: `refused`, the code a script branches on and the sentence a model reads, and
|
|
3
|
-
* under it a line per thing the actor can do instead.
|
|
4
|
-
*
|
|
5
|
-
* two, and everything under the first row is read as advice.
|
|
3
|
+
* under it a line per thing the actor can do instead. A line keeps its tabs but loses its newlines,
|
|
4
|
+
* since everything under the first row is read as advice.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
/* A field that goes into a tab-separated line; a tab or a newline in a title would split the row. */
|
|
@@ -24,10 +23,7 @@ export function refusalBody(code: string, message: string, lines: readonly strin
|
|
|
24
23
|
return [`refused\t${first}`, ...rest].join('\n');
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
/*
|
|
28
|
-
* The same without the word that names it, for a host that adds the prefix in its CLI:
|
|
29
|
-
* The context CLI puts the prefix on, so a CLI of an older build still prints one refusal.
|
|
30
|
-
*/
|
|
26
|
+
/* Without the `refused` prefix, for a host whose CLI adds it, so a CLI of an older build still prints one refusal. */
|
|
31
27
|
export function refusalRows(code: string, message: string, lines: readonly string[] = []): string {
|
|
32
28
|
return rows(code, message, lines).join('\n');
|
|
33
29
|
}
|
package/src/context/verb.ts
CHANGED
|
@@ -382,11 +382,7 @@ export function requiredField(needs: string): z.ZodString {
|
|
|
382
382
|
return z.string({ error: needs }).min(1, needs);
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
-
/*
|
|
386
|
-
* What a refusal lists, or one line saying the set is empty. A refusal that promises the groups of a
|
|
387
|
-
* canvas and then prints nothing reads as a daemon that lost them, where the truth is that there are
|
|
388
|
-
* none, which is a different thing to do something about.
|
|
389
|
-
*/
|
|
385
|
+
/* A refusal that promises a list and prints nothing reads as a host that lost it, where there is none. */
|
|
390
386
|
export function orNote(lines: string[], note: string): string[] {
|
|
391
387
|
return lines.length === 0 ? [`note\t${note}`] : lines;
|
|
392
388
|
}
|
package/src/host/agent-host.ts
CHANGED
|
@@ -24,8 +24,8 @@ function isAgentRequest(type: string): type is AgentRequestType {
|
|
|
24
24
|
|
|
25
25
|
/*
|
|
26
26
|
* The chats of an app that has no daemon: it answers the agent requests over any `FramePort` and
|
|
27
|
-
* sends the agent events back on it, frames checked on arrival. One port is one client.
|
|
28
|
-
* goes on at the next start through its CLI's own session.
|
|
27
|
+
* sends the agent events back on it, frames checked on arrival. One port is one client. A chat
|
|
28
|
+
* closed with the host goes on at the next start through its CLI's own session.
|
|
29
29
|
*/
|
|
30
30
|
export class AgentHost<Core extends ChatCore = ChatCore> {
|
|
31
31
|
readonly chats: Core;
|
package/src/host/handlers.ts
CHANGED
|
@@ -63,7 +63,7 @@ export function chatHandlers(chats: ChatCore, providers: ProviderRegistry): { [T
|
|
|
63
63
|
chats.send(
|
|
64
64
|
payload.chatId,
|
|
65
65
|
payload.text,
|
|
66
|
-
{ delivery: payload.delivery, mentions: payload.mentions, skills: payload.skills, chats: payload.chats },
|
|
66
|
+
{ delivery: payload.delivery, mentions: payload.mentions, skills: payload.skills, chats: payload.chats, quoteSource: payload.quoteSource },
|
|
67
67
|
payload.attachments
|
|
68
68
|
),
|
|
69
69
|
'chat.uiChoice': (payload, clientId) => chats.choose(payload, clientId),
|
|
@@ -116,7 +116,6 @@ export function chatHandlers(chats: ChatCore, providers: ProviderRegistry): { [T
|
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
/* The accounts are the person's settings of this host. */
|
|
120
119
|
export function accountHandlers(accounts: ProviderAccountsService): { [T in AccountRequestType]: AgentHandler<T> } {
|
|
121
120
|
return {
|
|
122
121
|
'accounts.list': () => accounts.snapshot(),
|
package/src/host/wiring.ts
CHANGED
|
@@ -9,12 +9,12 @@ import { VisualStore } from '../chat/visual-store.ts';
|
|
|
9
9
|
import type { ClaudeBackendOptions } from '../chat/claude-backend.ts';
|
|
10
10
|
import { ClaudeTitleReader } from '../chat/claude-title.ts';
|
|
11
11
|
import { DEFAULT_CODEX_CLIENT, type CodexClientInfo } from '../chat/codex-transport.ts';
|
|
12
|
+
import { errorText } from '../error-text.ts';
|
|
12
13
|
import type { AgentEvent } from '../events.ts';
|
|
13
14
|
import { definedEnv } from '../providers/accounts/launch.ts';
|
|
14
15
|
import { ProviderAccountsService } from '../providers/accounts/service.ts';
|
|
15
16
|
import type { AccountsHost } from '../providers/accounts/variables.ts';
|
|
16
17
|
import { createClaudeProvider } from '../providers/claude-provider.ts';
|
|
17
|
-
import { errorText } from '../error-text.ts';
|
|
18
18
|
import { createCodexProvider } from '../providers/codex-provider.ts';
|
|
19
19
|
import { codexRulesPathIn, defaultCodexHome, installCodexRules, type CodexRules } from '../providers/codex-rules.ts';
|
|
20
20
|
import { ProviderRegistry, type ProviderRegistryOptions } from '../providers/registry.ts';
|
package/src/lineage.ts
CHANGED
|
@@ -30,8 +30,8 @@ function openedByAgent(entry: Lineage): boolean {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
* Lives under the host's data folder, outside the agent-writable project, so an agent cannot reset its
|
|
34
|
+
* own depth; on disk, so a restart of the host does not reset it either.
|
|
35
35
|
*/
|
|
36
36
|
export class AgentLineageStore {
|
|
37
37
|
readonly dir: string;
|
|
@@ -140,7 +140,6 @@ export class AgentLineageStore {
|
|
|
140
140
|
return entry?.relation === 'fork' ? entry.openedBy : null;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
/* The forks made from a chat. */
|
|
144
143
|
forksOf(nodeId: string): string[] {
|
|
145
144
|
return [...this.opened.all()].filter((entry) => entry.relation === 'fork' && entry.openedBy === nodeId).map((entry) => entry.nodeId);
|
|
146
145
|
}
|
|
@@ -24,13 +24,11 @@ export interface DeliverMessageDeps {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/*
|
|
27
|
-
* Opens the turn a message earns a chat. The messages
|
|
28
|
-
*
|
|
29
|
-
* own prompt reads them through, so one message is heard once whichever of the two opens the turn.
|
|
27
|
+
* Opens the turn a message earns a chat. The messages wait in the notice store and the turn's preamble
|
|
28
|
+
* takes them (`NoticeNotes`), as a person's own prompt does, so a message is heard once either way.
|
|
30
29
|
*
|
|
31
|
-
* One attempt, never `wait
|
|
32
|
-
*
|
|
33
|
-
* make the host start turns nobody asked for long after the news was worth anything.
|
|
30
|
+
* One attempt, never `wait`: a chat already in a turn reads the message in front of its next one, and
|
|
31
|
+
* holding the entry would start turns nobody asked for long after the news mattered.
|
|
34
32
|
*/
|
|
35
33
|
export function deliverMessageHandler(deps: DeliverMessageDeps) {
|
|
36
34
|
return async (entry: DeliverMessageEntry): Promise<OutboxOutcome> => {
|
|
@@ -46,11 +44,8 @@ export function deliverMessageHandler(deps: DeliverMessageDeps) {
|
|
|
46
44
|
if (chat === null) {
|
|
47
45
|
return;
|
|
48
46
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
* landed (`showNotices`), so anything the turn adds about it only says the same thing again;
|
|
52
|
-
* the label on the turn already names who it came from.
|
|
53
|
-
*/
|
|
47
|
+
// No note and no preamble on the thread: a person read the message as it landed (`showNotices`),
|
|
48
|
+
// and the label on the turn names who it came from.
|
|
54
49
|
chat.wake({
|
|
55
50
|
text: deps.words.prompt(waiting.length),
|
|
56
51
|
label: deps.words.label(waiting),
|
|
@@ -33,11 +33,10 @@ export interface ChatDelivery {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/*
|
|
36
|
-
* Whether the turn a chat is running was itself opened by a message
|
|
37
|
-
*
|
|
38
|
-
* and nothing about that started with a person. It rides on the turn, so it survives a restart.
|
|
36
|
+
* Whether the turn a chat is running was itself opened by a message, which stops two agents that
|
|
37
|
+
* read each other from waking each other forever. It rides on the turn, so it survives a restart.
|
|
39
38
|
* Between turns the last one counts, since work it left running (a background subagent) may still
|
|
40
|
-
* write, and a turn the CLI opened by itself
|
|
39
|
+
* write, and a turn the CLI opened by itself takes the step of the turn before it.
|
|
41
40
|
*/
|
|
42
41
|
export function turnFromMessage(items: readonly ChatItem[], activeTurnId: string | null): boolean {
|
|
43
42
|
const from = activeTurnId === null ? items.findLastIndex((item) => item.kind === 'turn') : items.findIndex((item) => item.id === activeTurnId);
|
|
@@ -76,10 +75,9 @@ export function chatNoticeTargets(chats: Pick<ChatCore, 'get' | 'hasStored'>): C
|
|
|
76
75
|
|
|
77
76
|
/*
|
|
78
77
|
* One message on its way to a chat, and whether the chat owes a turn on it. A chat between turns gets
|
|
79
|
-
* one,
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* host runs something else in, such as a terminal, is the host's to deliver to before this.
|
|
78
|
+
* one, as for a settled task, or the message sits there until someone happens to prompt that chat.
|
|
79
|
+
* One step deep: the turn a message opened wakes nobody with a message of its own. A node that runs
|
|
80
|
+
* something else, such as a terminal, is the host's to deliver to before this.
|
|
83
81
|
*/
|
|
84
82
|
export async function deliverToChat(store: Pick<NoticeStore, 'put'>, targets: ChatNoticeTargets, notice: Omit<Notice, 'createdAt'>): Promise<ChatDelivery> {
|
|
85
83
|
const waiting = await store.put(notice);
|
|
@@ -105,10 +103,8 @@ export interface NoticeChat {
|
|
|
105
103
|
}
|
|
106
104
|
|
|
107
105
|
/*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* An id no chat holds shows nothing and marks nothing, so the chat that opens on that id later still
|
|
111
|
-
* has all of it.
|
|
106
|
+
* Shows a person the message in the chat's thread the moment it lands, not when the model gets round
|
|
107
|
+
* to it. An id no chat holds shows and marks nothing, so a chat that opens on it later still has all of it.
|
|
112
108
|
*/
|
|
113
109
|
export async function showNotices(store: Pick<NoticeStore, 'show'>, chat: NoticeChat, words: Pick<MessageWords, 'shown'>, targetId: string): Promise<void> {
|
|
114
110
|
if (!(await chat.has(targetId))) {
|
|
@@ -7,10 +7,8 @@ import { RecordDirectory } from '../record-directory.ts';
|
|
|
7
7
|
export const MAX_NOTICES = 10;
|
|
8
8
|
|
|
9
9
|
/*
|
|
10
|
-
* A message
|
|
11
|
-
*
|
|
12
|
-
* something that has moved on. The cap alone would not do it: one message in a queue of one waits
|
|
13
|
-
* for ever.
|
|
10
|
+
* A message is news about work happening now ("the build is green"), and a model reading it six hours
|
|
11
|
+
* late would act on something that moved on. The cap alone would leave a queue of one waiting forever.
|
|
14
12
|
*/
|
|
15
13
|
export const NOTICE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
16
14
|
|
|
@@ -131,7 +129,7 @@ export class NoticeStore {
|
|
|
131
129
|
return this.now() - notice.createdAt < NOTICE_MAX_AGE_MS;
|
|
132
130
|
}
|
|
133
131
|
|
|
134
|
-
/*
|
|
132
|
+
/* In memory and on disk both; an empty queue leaves no file behind. */
|
|
135
133
|
private async persist(targetId: string, queue: Notice[]): Promise<void> {
|
|
136
134
|
if (queue.length === 0) {
|
|
137
135
|
await this.queues.remove(targetId);
|