@koalaauto/agentchannel 0.0.0-stage → 3.0.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/LICENSE +202 -0
- package/README.md +154 -2
- package/dist/src/adapters/claude.d.ts +65 -0
- package/dist/src/adapters/claude.js +243 -0
- package/dist/src/adapters/codex.d.ts +74 -0
- package/dist/src/adapters/codex.js +237 -0
- package/dist/src/adapters/fake.d.ts +65 -0
- package/dist/src/adapters/fake.js +105 -0
- package/dist/src/adapters/index.d.ts +11 -0
- package/dist/src/adapters/index.js +26 -0
- package/dist/src/adapters/paths.d.ts +35 -0
- package/dist/src/adapters/paths.js +113 -0
- package/dist/src/adapters/procgroup.d.ts +14 -0
- package/dist/src/adapters/procgroup.js +49 -0
- package/dist/src/adapters/types.d.ts +57 -0
- package/dist/src/adapters/types.js +1 -0
- package/dist/src/args.d.ts +14 -0
- package/dist/src/args.js +257 -0
- package/dist/src/attachments.d.ts +63 -0
- package/dist/src/attachments.js +227 -0
- package/dist/src/attention.d.ts +85 -0
- package/dist/src/attention.js +262 -0
- package/dist/src/avatars.d.ts +40 -0
- package/dist/src/avatars.js +90 -0
- package/dist/src/cli.d.ts +45 -0
- package/dist/src/cli.js +1822 -0
- package/dist/src/client.d.ts +520 -0
- package/dist/src/client.js +449 -0
- package/dist/src/config.d.ts +62 -0
- package/dist/src/config.js +121 -0
- package/dist/src/desknotify.d.ts +154 -0
- package/dist/src/desknotify.js +426 -0
- package/dist/src/doctor.d.ts +75 -0
- package/dist/src/doctor.js +375 -0
- package/dist/src/env.d.ts +31 -0
- package/dist/src/env.js +52 -0
- package/dist/src/import.d.ts +245 -0
- package/dist/src/import.js +1008 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +13 -0
- package/dist/src/invite.d.ts +103 -0
- package/dist/src/invite.js +229 -0
- package/dist/src/invite_person.d.ts +16 -0
- package/dist/src/invite_person.js +75 -0
- package/dist/src/keystore.d.ts +55 -0
- package/dist/src/keystore.js +108 -0
- package/dist/src/mcp.d.ts +62 -0
- package/dist/src/mcp.js +503 -0
- package/dist/src/names.d.ts +30 -0
- package/dist/src/names.js +108 -0
- package/dist/src/netproxy.d.ts +20 -0
- package/dist/src/netproxy.js +36 -0
- package/dist/src/onboard.d.ts +105 -0
- package/dist/src/onboard.js +270 -0
- package/dist/src/perm.d.ts +12 -0
- package/dist/src/perm.js +20 -0
- package/dist/src/prompts.d.ts +166 -0
- package/dist/src/prompts.js +409 -0
- package/dist/src/runner/runner.d.ts +212 -0
- package/dist/src/runner/runner.js +1148 -0
- package/dist/src/runner/state.d.ts +68 -0
- package/dist/src/runner/state.js +80 -0
- package/dist/src/runner/workspace.d.ts +70 -0
- package/dist/src/runner/workspace.js +265 -0
- package/dist/src/service.d.ts +29 -0
- package/dist/src/service.js +101 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +3 -0
- package/dist/src/wiki.d.ts +74 -0
- package/dist/src/wiki.js +222 -0
- package/package.json +48 -4
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createWriteStream, mkdirSync, openAsBlob, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
/** What the hub accepts (it re-checks the type from the file content). */
|
|
6
|
+
export const ATTACH_TYPES = {
|
|
7
|
+
png: 'image/png',
|
|
8
|
+
jpg: 'image/jpeg',
|
|
9
|
+
jpeg: 'image/jpeg',
|
|
10
|
+
webp: 'image/webp',
|
|
11
|
+
gif: 'image/gif',
|
|
12
|
+
mp4: 'video/mp4',
|
|
13
|
+
webm: 'video/webm',
|
|
14
|
+
mov: 'video/quicktime',
|
|
15
|
+
// §17: text and code (≤ 2 MB each, previewed as escaped text) and .html reports
|
|
16
|
+
// (previewed only in a sandboxed iframe; downloaded as text/plain).
|
|
17
|
+
txt: 'text/plain',
|
|
18
|
+
md: 'text/markdown',
|
|
19
|
+
json: 'application/json',
|
|
20
|
+
ts: 'text/plain',
|
|
21
|
+
js: 'text/plain',
|
|
22
|
+
py: 'text/plain',
|
|
23
|
+
go: 'text/plain',
|
|
24
|
+
sh: 'text/plain',
|
|
25
|
+
diff: 'text/plain',
|
|
26
|
+
patch: 'text/plain',
|
|
27
|
+
log: 'text/plain',
|
|
28
|
+
csv: 'text/csv',
|
|
29
|
+
yaml: 'text/plain',
|
|
30
|
+
yml: 'text/plain',
|
|
31
|
+
sql: 'text/plain',
|
|
32
|
+
html: 'text/html',
|
|
33
|
+
htm: 'text/html',
|
|
34
|
+
};
|
|
35
|
+
export const TEXT_MAX_BYTES = 2 * 1024 * 1024;
|
|
36
|
+
const MEDIA_EXT = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif', 'mp4', 'webm', 'mov']);
|
|
37
|
+
export const MAX_ATTACHMENTS = 10;
|
|
38
|
+
/** An `--attach`/`--attach-url` entry that references an object by URL instead of uploading it. */
|
|
39
|
+
export function isLinkRef(p) {
|
|
40
|
+
return /^https:\/\//i.test(p);
|
|
41
|
+
}
|
|
42
|
+
/** A link reference for `attachment_refs` ({url, name}); throws AttachError on anything but a plain https URL. */
|
|
43
|
+
export function linkRef(p) {
|
|
44
|
+
let u;
|
|
45
|
+
try {
|
|
46
|
+
u = new URL(p);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new AttachError(`attachment link ${p} is not a URL`);
|
|
50
|
+
}
|
|
51
|
+
if (u.protocol !== 'https:' || u.username || u.password)
|
|
52
|
+
throw new AttachError(`attachment link ${p}: only https URLs without credentials`);
|
|
53
|
+
const seg = u.pathname.split('/').pop() || u.hostname;
|
|
54
|
+
let name = seg;
|
|
55
|
+
try {
|
|
56
|
+
name = decodeURIComponent(seg);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* keep raw */
|
|
60
|
+
}
|
|
61
|
+
return { url: p, name };
|
|
62
|
+
}
|
|
63
|
+
export class AttachError extends Error {
|
|
64
|
+
}
|
|
65
|
+
const SAFE_NAME = /^[A-Za-z0-9._-]{1,255}$/;
|
|
66
|
+
export function typeOf(path) {
|
|
67
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(path);
|
|
68
|
+
return (m && ATTACH_TYPES[m[1].toLowerCase()]) || '';
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve `--attach` paths. Inside a spawned agent (run token) every file must
|
|
72
|
+
* resolve through realpath inside the working directory, like `--result-file`
|
|
73
|
+
* (S2): an agent cannot upload ~/.config/agentchannel/config.json as a "frame".
|
|
74
|
+
*/
|
|
75
|
+
export function resolveAttachments(paths, io) {
|
|
76
|
+
const list = (paths ?? []).filter((p) => typeof p === 'string' && p.length);
|
|
77
|
+
if (!list.length)
|
|
78
|
+
return [];
|
|
79
|
+
if (list.length > MAX_ATTACHMENTS)
|
|
80
|
+
throw new AttachError(`at most ${MAX_ATTACHMENTS} attachments`);
|
|
81
|
+
const cwd = io.cwd ?? process.cwd();
|
|
82
|
+
return list.map((p) => {
|
|
83
|
+
// §20 v1.1: an https URL is a reference (R2/S3/OSS/TOS presigned or public); nothing is read or uploaded
|
|
84
|
+
if (isLinkRef(p))
|
|
85
|
+
return linkRef(p).url;
|
|
86
|
+
let real;
|
|
87
|
+
try {
|
|
88
|
+
real = realpathSync(resolve(cwd, p));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new AttachError(`cannot read attachment ${p}`);
|
|
92
|
+
}
|
|
93
|
+
if (io.env.ACH_RUN_TOKEN) {
|
|
94
|
+
const rel = relative(realpathSync(cwd), real);
|
|
95
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
96
|
+
throw new AttachError(`refusing to attach ${p}: inside a spawned agent, files must be inside the working directory`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!statSync(real).isFile())
|
|
100
|
+
throw new AttachError(`attachment ${p} is not a file`);
|
|
101
|
+
if (!typeOf(real))
|
|
102
|
+
throw new AttachError(`attachment ${p}: only ${Object.keys(ATTACH_TYPES).join(', ')} files are accepted`);
|
|
103
|
+
const ext = (/\.([A-Za-z0-9]+)$/.exec(real)?.[1] ?? '').toLowerCase();
|
|
104
|
+
if (!MEDIA_EXT.has(ext) && statSync(real).size > TEXT_MAX_BYTES)
|
|
105
|
+
throw new AttachError(`attachment ${p}: text files are limited to 2 MB`);
|
|
106
|
+
return real;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** Multipart body: the JSON body in field "json", files in "attachments". */
|
|
110
|
+
export async function attachmentForm(json, files) {
|
|
111
|
+
const fd = new FormData();
|
|
112
|
+
fd.append('json', JSON.stringify(json ?? {}));
|
|
113
|
+
for (const f of files) {
|
|
114
|
+
const blob = await openAsBlob(f, { type: typeOf(f) || 'application/octet-stream' });
|
|
115
|
+
fd.append('attachments', blob, basename(f));
|
|
116
|
+
}
|
|
117
|
+
return fd;
|
|
118
|
+
}
|
|
119
|
+
const UPLOAD_URL = /^\/api\/ach\/uploads\/[a-z0-9]{15}\/raw$/;
|
|
120
|
+
export function attachmentsOf(v) {
|
|
121
|
+
if (!Array.isArray(v))
|
|
122
|
+
return [];
|
|
123
|
+
return v.filter((a) => {
|
|
124
|
+
if (!a || typeof a.url !== 'string' || typeof a.name !== 'string')
|
|
125
|
+
return false;
|
|
126
|
+
if (a.ref === 'link')
|
|
127
|
+
return /^https:\/\//i.test(a.url);
|
|
128
|
+
if (a.ref === 'upload')
|
|
129
|
+
return UPLOAD_URL.test(a.url);
|
|
130
|
+
return SAFE_NAME.test(a.name);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/** A path-safe local file name for an attachment (references keep their upload id as prefix). */
|
|
134
|
+
export function localName(a) {
|
|
135
|
+
if (!a.ref)
|
|
136
|
+
return a.name;
|
|
137
|
+
const safe = String(a.original || a.name).replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._-]+/, '').slice(0, 120) || 'file';
|
|
138
|
+
return a.ref === 'upload' && a.upload ? `${a.upload}_${safe}` : safe;
|
|
139
|
+
}
|
|
140
|
+
function author(m) {
|
|
141
|
+
if (m.author_type === 'human')
|
|
142
|
+
return `human ${String(m.author_name ?? '')}`.trim();
|
|
143
|
+
if (m.author_type === 'system')
|
|
144
|
+
return 'system';
|
|
145
|
+
return `agent ${String(m.author_name ?? m.author_agent ?? '?')}`;
|
|
146
|
+
}
|
|
147
|
+
/** Every attachment on a task: current handoff first, then messages (oldest first). */
|
|
148
|
+
export function taskFiles(bundle) {
|
|
149
|
+
const out = [];
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
const add = (a, from) => {
|
|
152
|
+
if (seen.has(a.url))
|
|
153
|
+
return;
|
|
154
|
+
seen.add(a.url);
|
|
155
|
+
out.push({ ...a, from });
|
|
156
|
+
};
|
|
157
|
+
const v = bundle.task.handoff_version ?? 1;
|
|
158
|
+
for (const a of attachmentsOf(bundle.handoffAttachments))
|
|
159
|
+
add(a, `handoff v${v}`);
|
|
160
|
+
for (const h of bundle.history)
|
|
161
|
+
for (const a of attachmentsOf(h.attachments))
|
|
162
|
+
add(a, `handoff v${h.version}`);
|
|
163
|
+
for (const m of bundle.messages)
|
|
164
|
+
for (const a of attachmentsOf(m.attachments))
|
|
165
|
+
add(a, `${m.kind} ${m.id} by ${author(m)}`);
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/** Download to `dir/<stored name>` (stored names are unique and path-safe). Skips files already there with the same size. */
|
|
169
|
+
export async function downloadFile(fetchFn, url, headers, dir, a) {
|
|
170
|
+
if (a.ref === 'link')
|
|
171
|
+
throw new AttachError(`${a.original || a.name} is a link (${a.url}), not stored on the hub`);
|
|
172
|
+
const name = localName(a);
|
|
173
|
+
if (!SAFE_NAME.test(name))
|
|
174
|
+
throw new AttachError(`unsafe file name ${name}`);
|
|
175
|
+
mkdirSync(dir, { recursive: true });
|
|
176
|
+
const dest = join(dir, name);
|
|
177
|
+
try {
|
|
178
|
+
if (a.size && statSync(dest).size === a.size)
|
|
179
|
+
return dest;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* not there yet */
|
|
183
|
+
}
|
|
184
|
+
let res = await fetchFn(url, { method: 'GET', headers, ...(a.ref === 'upload' ? { redirect: 'manual' } : {}) });
|
|
185
|
+
// direct uploads: the hub answers with a presigned bucket URL; fetch it without our credentials
|
|
186
|
+
if (a.ref === 'upload' && res.status >= 300 && res.status < 400 && res.headers.get('location')) {
|
|
187
|
+
res = await fetchFn(String(res.headers.get('location')), { method: 'GET' });
|
|
188
|
+
}
|
|
189
|
+
if (!res.ok || !res.body)
|
|
190
|
+
throw new AttachError(`download ${a.original || a.name}: ${res.status} ${res.statusText}`);
|
|
191
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(dest, { mode: 0o600 }));
|
|
192
|
+
return dest;
|
|
193
|
+
}
|
|
194
|
+
export function humanSize(n) {
|
|
195
|
+
if (!n)
|
|
196
|
+
return '?';
|
|
197
|
+
if (n < 1024)
|
|
198
|
+
return `${n} B`;
|
|
199
|
+
if (n < 1024 * 1024)
|
|
200
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
201
|
+
if (n < 1024 ** 3)
|
|
202
|
+
return `${(n / 1048576).toFixed(1)} MB`;
|
|
203
|
+
return `${(n / 1024 ** 3).toFixed(2)} GB`;
|
|
204
|
+
}
|
|
205
|
+
/** The upload target for a hub route: task:<id>, post:<id>, channel:<name>, or new_task (POST /tasks). */
|
|
206
|
+
export function uploadTarget(path) {
|
|
207
|
+
const p = path.replace(/^https?:\/\/[^/]+/, '').replace(/^\/api\/ach/, '').split('?')[0];
|
|
208
|
+
let m = /^\/tasks\/([^/]+)\/[a-z-]+$/.exec(p);
|
|
209
|
+
if (m)
|
|
210
|
+
return `task:${decodeURIComponent(m[1])}`;
|
|
211
|
+
if (p === '/tasks')
|
|
212
|
+
return 'new_task';
|
|
213
|
+
m = /^\/posts\/([^/]+)\/replies$/.exec(p);
|
|
214
|
+
if (m)
|
|
215
|
+
return `post:${decodeURIComponent(m[1])}`;
|
|
216
|
+
m = /^\/channels\/([^/]+)\/posts$/.exec(p);
|
|
217
|
+
if (m)
|
|
218
|
+
return `channel:${decodeURIComponent(m[1])}`;
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
/** Files that should go straight to the bucket: media over the hub's threshold. */
|
|
222
|
+
export function bigFiles(files, cfg) {
|
|
223
|
+
if (!cfg || !cfg.direct_upload)
|
|
224
|
+
return [];
|
|
225
|
+
const min = cfg.direct_threshold_mb * 1048576;
|
|
226
|
+
return files.filter((f) => !isLinkRef(f) && MEDIA_EXT.has((/\.([A-Za-z0-9]+)$/.exec(f)?.[1] ?? '').toLowerCase()) && statSync(f).size > min);
|
|
227
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export type Request = (method: string, path: string) => Promise<any>;
|
|
2
|
+
/** Kinds in notice order. */
|
|
3
|
+
export declare const ATTENTION_KINDS: readonly ["question", "decision", "mention", "reply", "task", "item"];
|
|
4
|
+
export type AttentionKind = (typeof ATTENTION_KINDS)[number];
|
|
5
|
+
export interface Attention {
|
|
6
|
+
total: number;
|
|
7
|
+
counts: Partial<Record<AttentionKind, number>>;
|
|
8
|
+
/** Stable ids of the items (what was already noticed is never noticed again). */
|
|
9
|
+
ids: string[];
|
|
10
|
+
}
|
|
11
|
+
/** A hub item type / notification event -> a notice kind. Pure. */
|
|
12
|
+
export declare function attentionKind(type: unknown): AttentionKind;
|
|
13
|
+
/**
|
|
14
|
+
* Normalizes what the hub says needs the owner. Accepts GET /me/attention
|
|
15
|
+
* (`{needs_reply_count, by_kind: {decision, question, mention, reply}}`, or a
|
|
16
|
+
* list `{items:[{id,type}]}`) and, as a fallback,
|
|
17
|
+
* GET /me/notifications?all=1 (rows with reply_needed / needs_me). Pure.
|
|
18
|
+
*/
|
|
19
|
+
export declare function normalizeAttention(r: any, fromNotifications?: boolean): Attention;
|
|
20
|
+
/** What needs the owner now (agent key: the owner's view). */
|
|
21
|
+
export declare function fetchAttention(request: Request): Promise<Attention>;
|
|
22
|
+
/** The one line. Counts only, never content. Pure. */
|
|
23
|
+
export declare function noticeText(a: Attention, lang?: 'en' | 'zh'): string;
|
|
24
|
+
export declare const NOTICE_GAP_MS: number;
|
|
25
|
+
export interface AttentionNotifierOptions {
|
|
26
|
+
fetch: () => Promise<Attention>;
|
|
27
|
+
/** Shows the line; resolves false when it could not (unsupported: stays silent). */
|
|
28
|
+
deliver: (text: string) => Promise<boolean>;
|
|
29
|
+
lang?: 'en' | 'zh';
|
|
30
|
+
gapMs?: number;
|
|
31
|
+
now?: () => number;
|
|
32
|
+
log?: (s: string) => void;
|
|
33
|
+
}
|
|
34
|
+
export declare class AttentionNotifier {
|
|
35
|
+
private o;
|
|
36
|
+
private noticed;
|
|
37
|
+
private lastCounts;
|
|
38
|
+
private lastAt;
|
|
39
|
+
private timer;
|
|
40
|
+
private busy;
|
|
41
|
+
readonly sent: string[];
|
|
42
|
+
constructor(o: AttentionNotifierOptions);
|
|
43
|
+
private now;
|
|
44
|
+
/** Something changed: notice new items now, or once at the end of the rate window. */
|
|
45
|
+
check(): Promise<string | null>;
|
|
46
|
+
private run;
|
|
47
|
+
stop(): void;
|
|
48
|
+
}
|
|
49
|
+
export interface ChangeFeedOptions {
|
|
50
|
+
request: Request;
|
|
51
|
+
onChange: () => unknown;
|
|
52
|
+
/** Long-poll wait (s), ≤ 25. */
|
|
53
|
+
waitS?: number;
|
|
54
|
+
/** Fallback polling interval. */
|
|
55
|
+
pollMs?: number;
|
|
56
|
+
sleep?: (ms: number) => Promise<void>;
|
|
57
|
+
log?: (s: string) => void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Follows GET /api/ach/changes (long poll). Falls back to polling (onChange
|
|
61
|
+
* every pollMs) when the hub refuses the route (403/404: an older hub, or
|
|
62
|
+
* person sessions only) — permanently — or after an error (one interval, then
|
|
63
|
+
* the long poll again).
|
|
64
|
+
*/
|
|
65
|
+
export declare class ChangeFeed {
|
|
66
|
+
private o;
|
|
67
|
+
mode: 'longpoll' | 'poll';
|
|
68
|
+
private stopped;
|
|
69
|
+
private cursor;
|
|
70
|
+
constructor(o: ChangeFeedOptions);
|
|
71
|
+
stop(): void;
|
|
72
|
+
private fire;
|
|
73
|
+
/** Runs until stop(). `rounds` bounds the loop (tests). */
|
|
74
|
+
run(rounds?: number): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
export type Exec = (cmd: string, args: string[]) => Promise<{
|
|
77
|
+
code: number;
|
|
78
|
+
stdout: string;
|
|
79
|
+
stderr: string;
|
|
80
|
+
}>;
|
|
81
|
+
export declare const defaultExec: Exec;
|
|
82
|
+
/** The Codex session this `ach mcp` runs in, when known (env set by Codex, or ACH_CODEX_THREAD_ID). */
|
|
83
|
+
export declare function codexThreadId(env: NodeJS.ProcessEnv): string | undefined;
|
|
84
|
+
/** `codex queue --thread <id> --message <notice>`: the notice as a queued message in that session. */
|
|
85
|
+
export declare function codexQueue(text: string, env: NodeJS.ProcessEnv, exec?: Exec): Promise<boolean>;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reminders, never content. Two pieces shared by `ach mcp` (an open Claude
|
|
3
|
+
* Code / Codex session) and `ach run --notify` (the background helper):
|
|
4
|
+
*
|
|
5
|
+
* - ChangeFeed: follows the hub's change stream. GET /api/ach/changes long-poll
|
|
6
|
+
* (wait ≈ 25 s); when the hub refuses it (older hub: person sessions only) or
|
|
7
|
+
* it errors, falls back to polling every 15 s.
|
|
8
|
+
* - AttentionNotifier: on a change, counts what needs the owner (questions,
|
|
9
|
+
* decisions, mentions...) and, when something new arrived, delivers ONE line
|
|
10
|
+
* ("AgentChannel: 2 items need you (1 question, 1 decision) — say 'check my
|
|
11
|
+
* inbox'"). At most one notice per 10 minutes; changes inside the window are
|
|
12
|
+
* coalesced into one notice at its end. Message bodies are never fetched or
|
|
13
|
+
* sent here: content comes only when the person asks (the inbox tools).
|
|
14
|
+
*/
|
|
15
|
+
import { execFile } from 'node:child_process';
|
|
16
|
+
import { HubError } from './client.js';
|
|
17
|
+
/** Kinds in notice order. */
|
|
18
|
+
export const ATTENTION_KINDS = ['question', 'decision', 'mention', 'reply', 'task', 'item'];
|
|
19
|
+
/** A hub item type / notification event -> a notice kind. Pure. */
|
|
20
|
+
export function attentionKind(type) {
|
|
21
|
+
const t = String(type ?? '').toLowerCase();
|
|
22
|
+
if (/decision/.test(t))
|
|
23
|
+
return 'decision';
|
|
24
|
+
if (/question|escalat|ask|memory_held/.test(t))
|
|
25
|
+
return 'question';
|
|
26
|
+
if (/mention|assigned/.test(t))
|
|
27
|
+
return 'mention';
|
|
28
|
+
if (/repl|change_request|changes_requested/.test(t))
|
|
29
|
+
return 'reply';
|
|
30
|
+
if (/task/.test(t))
|
|
31
|
+
return 'task';
|
|
32
|
+
return 'item';
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Normalizes what the hub says needs the owner. Accepts GET /me/attention
|
|
36
|
+
* (`{needs_reply_count, by_kind: {decision, question, mention, reply}}`, or a
|
|
37
|
+
* list `{items:[{id,type}]}`) and, as a fallback,
|
|
38
|
+
* GET /me/notifications?all=1 (rows with reply_needed / needs_me). Pure.
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeAttention(r, fromNotifications = false) {
|
|
41
|
+
const counts = {};
|
|
42
|
+
const ids = [];
|
|
43
|
+
let items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
|
|
44
|
+
if (fromNotifications)
|
|
45
|
+
items = items.filter((x) => x && (x.reply_needed || x.pending));
|
|
46
|
+
else
|
|
47
|
+
items = items.filter((x) => x && x.pending !== false);
|
|
48
|
+
for (const x of items) {
|
|
49
|
+
const k = attentionKind(x.type ?? x.kind ?? x.event);
|
|
50
|
+
counts[k] = (counts[k] ?? 0) + 1;
|
|
51
|
+
ids.push(String(x.id ?? x.event_id ?? x.message_id ?? `${k}:${ids.length}`));
|
|
52
|
+
}
|
|
53
|
+
const byKind = r?.by_kind ?? r?.counts;
|
|
54
|
+
if (!items.length && byKind && typeof byKind === 'object') {
|
|
55
|
+
// GET /me/attention: counts only (no ids); "new" = a count went up (AttentionNotifier)
|
|
56
|
+
for (const [key, n] of Object.entries(byKind)) {
|
|
57
|
+
const c = Number(n);
|
|
58
|
+
if (!(c > 0))
|
|
59
|
+
continue;
|
|
60
|
+
const k = attentionKind(key);
|
|
61
|
+
counts[k] = (counts[k] ?? 0) + c;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const total = Object.values(counts).reduce((a, b) => a + (b ?? 0), 0);
|
|
65
|
+
return { total, counts, ids };
|
|
66
|
+
}
|
|
67
|
+
/** What needs the owner now (agent key: the owner's view). */
|
|
68
|
+
export async function fetchAttention(request) {
|
|
69
|
+
try {
|
|
70
|
+
return normalizeAttention(await request('GET', '/api/ach/me/attention'));
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
if (!(e instanceof HubError) || (e.status !== 404 && e.status !== 403 && e.status !== 405))
|
|
74
|
+
throw e;
|
|
75
|
+
}
|
|
76
|
+
return normalizeAttention(await request('GET', '/api/ach/me/notifications?all=1&limit=100'), true);
|
|
77
|
+
}
|
|
78
|
+
const EN = {
|
|
79
|
+
question: ['question', 'questions'],
|
|
80
|
+
decision: ['decision', 'decisions'],
|
|
81
|
+
mention: ['mention', 'mentions'],
|
|
82
|
+
reply: ['reply', 'replies'],
|
|
83
|
+
task: ['task', 'tasks'],
|
|
84
|
+
item: ['other', 'other'],
|
|
85
|
+
};
|
|
86
|
+
const ZH = { question: '个问题', decision: '个决定', mention: '次提及', reply: '条回复', task: '个任务', item: '项其他' };
|
|
87
|
+
/** The one line. Counts only, never content. Pure. */
|
|
88
|
+
export function noticeText(a, lang = 'en') {
|
|
89
|
+
const parts = ATTENTION_KINDS.filter((k) => (a.counts[k] ?? 0) > 0).map((k) => {
|
|
90
|
+
const n = a.counts[k];
|
|
91
|
+
return lang === 'zh' ? `${n} ${ZH[k]}` : `${n} ${EN[k][n === 1 ? 0 : 1]}`;
|
|
92
|
+
});
|
|
93
|
+
if (lang === 'zh')
|
|
94
|
+
return `AgentChannel:有 ${a.total} 件事等你处理(${parts.join(',')})— 说「查看我的收件箱」`;
|
|
95
|
+
return `AgentChannel: ${a.total} ${a.total === 1 ? 'item needs' : 'items need'} you (${parts.join(', ')}) — say 'check my inbox'`;
|
|
96
|
+
}
|
|
97
|
+
export const NOTICE_GAP_MS = 10 * 60_000;
|
|
98
|
+
export class AttentionNotifier {
|
|
99
|
+
o;
|
|
100
|
+
noticed = new Set();
|
|
101
|
+
lastCounts = {};
|
|
102
|
+
lastAt = -Infinity;
|
|
103
|
+
timer = null;
|
|
104
|
+
busy = null;
|
|
105
|
+
sent = [];
|
|
106
|
+
constructor(o) {
|
|
107
|
+
this.o = o;
|
|
108
|
+
}
|
|
109
|
+
now() {
|
|
110
|
+
return (this.o.now ?? Date.now)();
|
|
111
|
+
}
|
|
112
|
+
/** Something changed: notice new items now, or once at the end of the rate window. */
|
|
113
|
+
check() {
|
|
114
|
+
if (this.busy)
|
|
115
|
+
return this.busy;
|
|
116
|
+
this.busy = this.run().finally(() => {
|
|
117
|
+
this.busy = null;
|
|
118
|
+
});
|
|
119
|
+
return this.busy;
|
|
120
|
+
}
|
|
121
|
+
async run() {
|
|
122
|
+
let a;
|
|
123
|
+
try {
|
|
124
|
+
a = await this.o.fetch();
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
this.o.log?.(`notice: ${String(e?.message ?? e).slice(0, 200)}`);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
// items that went away may come back as new ones later: keep only current ids
|
|
131
|
+
const freshIds = a.ids.filter((id) => !this.noticed.has(id));
|
|
132
|
+
const rose = ATTENTION_KINDS.some((k) => (a.counts[k] ?? 0) > (this.lastCounts[k] ?? 0));
|
|
133
|
+
if (a.total <= 0 || (!freshIds.length && !(a.ids.length === 0 && rose))) {
|
|
134
|
+
// fewer items now: a later increase is new again
|
|
135
|
+
this.lastCounts = { ...a.counts };
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const wait = this.lastAt + (this.o.gapMs ?? NOTICE_GAP_MS) - this.now();
|
|
139
|
+
if (wait > 0) {
|
|
140
|
+
// coalesce: one check when the window ends
|
|
141
|
+
if (!this.timer) {
|
|
142
|
+
this.timer = setTimeout(() => {
|
|
143
|
+
this.timer = null;
|
|
144
|
+
void this.check();
|
|
145
|
+
}, wait);
|
|
146
|
+
this.timer.unref?.();
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const text = noticeText(a, this.o.lang ?? 'en');
|
|
151
|
+
this.lastAt = this.now();
|
|
152
|
+
for (const id of a.ids)
|
|
153
|
+
this.noticed.add(id);
|
|
154
|
+
this.lastCounts = { ...a.counts };
|
|
155
|
+
let ok = false;
|
|
156
|
+
try {
|
|
157
|
+
ok = await this.o.deliver(text);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
ok = false;
|
|
161
|
+
}
|
|
162
|
+
if (ok)
|
|
163
|
+
this.sent.push(text);
|
|
164
|
+
return ok ? text : null;
|
|
165
|
+
}
|
|
166
|
+
stop() {
|
|
167
|
+
if (this.timer)
|
|
168
|
+
clearTimeout(this.timer);
|
|
169
|
+
this.timer = null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms).unref?.());
|
|
173
|
+
/**
|
|
174
|
+
* Follows GET /api/ach/changes (long poll). Falls back to polling (onChange
|
|
175
|
+
* every pollMs) when the hub refuses the route (403/404: an older hub, or
|
|
176
|
+
* person sessions only) — permanently — or after an error (one interval, then
|
|
177
|
+
* the long poll again).
|
|
178
|
+
*/
|
|
179
|
+
export class ChangeFeed {
|
|
180
|
+
o;
|
|
181
|
+
mode = 'longpoll';
|
|
182
|
+
stopped = false;
|
|
183
|
+
cursor = '';
|
|
184
|
+
constructor(o) {
|
|
185
|
+
this.o = o;
|
|
186
|
+
}
|
|
187
|
+
stop() {
|
|
188
|
+
this.stopped = true;
|
|
189
|
+
}
|
|
190
|
+
async fire() {
|
|
191
|
+
try {
|
|
192
|
+
await this.o.onChange();
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
/* the consumer logs its own errors */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Runs until stop(). `rounds` bounds the loop (tests). */
|
|
199
|
+
async run(rounds = Infinity) {
|
|
200
|
+
const sleep = this.o.sleep ?? realSleep;
|
|
201
|
+
const poll = this.o.pollMs ?? 15_000;
|
|
202
|
+
const wait = Math.min(25, this.o.waitS ?? 25);
|
|
203
|
+
let errors = 0;
|
|
204
|
+
for (let i = 0; i < rounds && !this.stopped; i++) {
|
|
205
|
+
if (this.mode === 'poll') {
|
|
206
|
+
await this.fire();
|
|
207
|
+
await sleep(poll);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const t0 = Date.now();
|
|
211
|
+
try {
|
|
212
|
+
const q = new URLSearchParams({ wait: String(wait) });
|
|
213
|
+
if (this.cursor)
|
|
214
|
+
q.set('since', this.cursor);
|
|
215
|
+
const r = await this.o.request('GET', `/api/ach/changes?${q}`);
|
|
216
|
+
errors = 0;
|
|
217
|
+
const first = !this.cursor;
|
|
218
|
+
if (r?.cursor)
|
|
219
|
+
this.cursor = String(r.cursor);
|
|
220
|
+
if (r?.changed || first)
|
|
221
|
+
await this.fire();
|
|
222
|
+
// a hub that ignores `wait` must not be hammered
|
|
223
|
+
if (Date.now() - t0 < 1000)
|
|
224
|
+
await sleep(1000);
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
if (e instanceof HubError && (e.status === 403 || e.status === 404 || e.status === 405)) {
|
|
228
|
+
this.mode = 'poll';
|
|
229
|
+
this.o.log?.(`change stream not available (${e.status}); polling every ${Math.round(poll / 1000)} s`);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (e instanceof HubError && e.status === 401) {
|
|
233
|
+
this.o.log?.('change stream: key refused (401); stopping');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
errors++;
|
|
237
|
+
if (errors === 1)
|
|
238
|
+
this.o.log?.(`change stream: ${String(e?.message ?? e).slice(0, 200)}; retrying`);
|
|
239
|
+
await this.fire();
|
|
240
|
+
await sleep(poll);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
export const defaultExec = (cmd, args) => new Promise((resolve) => {
|
|
246
|
+
execFile(cmd, args, { timeout: 15_000 }, (err, stdout, stderr) => {
|
|
247
|
+
resolve({ code: err ? (typeof err.code === 'number' ? err.code : 127) : 0, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') });
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
/** The Codex session this `ach mcp` runs in, when known (env set by Codex, or ACH_CODEX_THREAD_ID). */
|
|
251
|
+
export function codexThreadId(env) {
|
|
252
|
+
const v = env.ACH_CODEX_THREAD_ID || env.CODEX_THREAD_ID || env.CODEX_SESSION_ID;
|
|
253
|
+
return v && /^[A-Za-z0-9_.:-]{1,200}$/.test(v) ? v : undefined;
|
|
254
|
+
}
|
|
255
|
+
/** `codex queue --thread <id> --message <notice>`: the notice as a queued message in that session. */
|
|
256
|
+
export async function codexQueue(text, env, exec = defaultExec) {
|
|
257
|
+
const thread = codexThreadId(env);
|
|
258
|
+
if (!thread)
|
|
259
|
+
return false;
|
|
260
|
+
const r = await exec(env.ACH_CODEX_BIN || 'codex', ['queue', '--thread', thread, '--message', text]);
|
|
261
|
+
return r.code === 0;
|
|
262
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Client, FetchLike } from './client.js';
|
|
2
|
+
import type { Exec } from './import.js';
|
|
3
|
+
/**
|
|
4
|
+
* Feishu avatars are fetched on the admin's machine (lark-cli is logged in
|
|
5
|
+
* there, the hub is not) and uploaded to the hub, which caches them; the panel
|
|
6
|
+
* only ever loads avatars from the hub. GitHub and Gravatar are fetched by the
|
|
7
|
+
* hub itself (POST /api/ach/admin/avatars/refresh, and weekly).
|
|
8
|
+
*/
|
|
9
|
+
export declare const AVATAR_MAX_BYTES: number;
|
|
10
|
+
export declare const AVATAR_SOURCES: string[];
|
|
11
|
+
/** png | jpeg | gif | webp from the first bytes, else '' (never SVG). */
|
|
12
|
+
export declare function imageKind(b: Uint8Array): string;
|
|
13
|
+
/** The best avatar URL in a Feishu user object (search or contact result). */
|
|
14
|
+
export declare function feishuAvatarUrl(u: any): string;
|
|
15
|
+
/** Look a person up in Feishu by email (or open_id) with lark-cli; returns the avatar URL or ''. */
|
|
16
|
+
export declare function feishuAvatarFor(exec: Exec, p: {
|
|
17
|
+
email?: string;
|
|
18
|
+
feishu_open_id?: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
}): Promise<string>;
|
|
21
|
+
/** Download an image (<= 200 KB, PNG/JPEG/GIF/WebP). */
|
|
22
|
+
export declare function downloadImage(url: string, fetchFn: FetchLike): Promise<{
|
|
23
|
+
bytes: Uint8Array;
|
|
24
|
+
ext: string;
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* Upload Feishu avatars for these people (hub ids). Returns how many were
|
|
28
|
+
* uploaded; problems are logged, never fatal (an avatar is cosmetic).
|
|
29
|
+
*/
|
|
30
|
+
export declare function syncFeishuAvatars(admin: Client, people: {
|
|
31
|
+
id: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
email?: string;
|
|
34
|
+
feishu_open_id?: string;
|
|
35
|
+
}[], o: {
|
|
36
|
+
exec: Exec;
|
|
37
|
+
fetch?: FetchLike;
|
|
38
|
+
log: (s: string) => void;
|
|
39
|
+
}): Promise<number>;
|
|
40
|
+
export declare function parsePriority(s: string): string[];
|