@gajae-code/coding-agent 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/types/cli/notify-cli.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +39 -2
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +1 -1
- package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
- package/dist/types/lsp/types.d.ts +2 -0
- package/dist/types/notifications/attachment-registry.d.ts +17 -0
- package/dist/types/notifications/chat-adapters.d.ts +9 -0
- package/dist/types/notifications/config.d.ts +9 -1
- package/dist/types/notifications/engine.d.ts +59 -0
- package/dist/types/notifications/managed-daemon.d.ts +48 -0
- package/dist/types/notifications/telegram-daemon.d.ts +19 -0
- package/dist/types/notifications/threaded-inbound.d.ts +19 -0
- package/dist/types/notifications/threaded-render.d.ts +6 -1
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/tools/fetch.d.ts +23 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/telegram-send.d.ts +32 -0
- package/dist/types/web/insane/bridge.d.ts +103 -0
- package/dist/types/web/insane/url-guard.d.ts +22 -0
- package/dist/types/web/search/provider.d.ts +18 -1
- package/dist/types/web/search/providers/insane.d.ts +53 -0
- package/dist/types/web/search/providers/text-citations.d.ts +23 -0
- package/dist/types/web/search/types.d.ts +12 -4
- package/package.json +10 -8
- package/scripts/verify-insane-vendor.ts +132 -0
- package/src/cli/args.ts +1 -1
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/notify-cli.ts +152 -5
- package/src/cli.ts +1 -3
- package/src/commands/team.ts +1 -1
- package/src/config/settings-schema.ts +30 -1
- package/src/defaults/gjc/skills/ralplan/SKILL.md +11 -4
- package/src/edit/modes/replace.ts +1 -1
- package/src/extensibility/shared-events.ts +1 -0
- package/src/gjc-runtime/launch-tmux.ts +27 -5
- package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
- package/src/gjc-runtime/ralplan-runtime.ts +2 -2
- package/src/gjc-runtime/tmux-common.ts +8 -0
- package/src/gjc-runtime/tmux-sessions.ts +8 -1
- package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
- package/src/gjc-runtime/workflow-manifest.ts +7 -2
- package/src/hashline/hash.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +9 -8
- package/src/lsp/config.ts +16 -3
- package/src/lsp/defaults.json +7 -0
- package/src/lsp/types.ts +2 -0
- package/src/modes/controllers/event-controller.ts +15 -0
- package/src/modes/interactive-mode.ts +46 -2
- package/src/modes/utils/context-usage.ts +2 -2
- package/src/notifications/attachment-registry.ts +23 -0
- package/src/notifications/chat-adapters.ts +147 -0
- package/src/notifications/config.ts +23 -2
- package/src/notifications/engine.ts +100 -0
- package/src/notifications/index.ts +224 -45
- package/src/notifications/managed-daemon.ts +163 -0
- package/src/notifications/telegram-daemon.ts +235 -14
- package/src/notifications/threaded-inbound.ts +60 -4
- package/src/notifications/threaded-render.ts +20 -2
- package/src/session/agent-session.ts +82 -51
- package/src/tools/ask.ts +3 -2
- package/src/tools/fetch.ts +78 -1
- package/src/tools/index.ts +3 -0
- package/src/tools/telegram-send.ts +137 -0
- package/src/web/insane/bridge.ts +350 -0
- package/src/web/insane/url-guard.ts +155 -0
- package/src/web/search/provider.ts +77 -18
- package/src/web/search/providers/anthropic.ts +70 -3
- package/src/web/search/providers/codex.ts +1 -119
- package/src/web/search/providers/gemini.ts +99 -0
- package/src/web/search/providers/insane.ts +551 -0
- package/src/web/search/providers/openai-compatible.ts +66 -32
- package/src/web/search/providers/text-citations.ts +111 -0
- package/src/web/search/types.ts +13 -2
- package/vendor/insane-search/LICENSE +21 -0
- package/vendor/insane-search/MANIFEST.json +24 -0
- package/vendor/insane-search/engine/__init__.py +23 -0
- package/vendor/insane-search/engine/__main__.py +128 -0
- package/vendor/insane-search/engine/bias_check.py +183 -0
- package/vendor/insane-search/engine/executor.py +254 -0
- package/vendor/insane-search/engine/fetch_chain.py +725 -0
- package/vendor/insane-search/engine/learning.py +175 -0
- package/vendor/insane-search/engine/phase0.py +214 -0
- package/vendor/insane-search/engine/safety.py +91 -0
- package/vendor/insane-search/engine/templates/package.json +11 -0
- package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
- package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
- package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
- package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
- package/vendor/insane-search/engine/tests/test_u1.py +200 -0
- package/vendor/insane-search/engine/tests/test_u4.py +131 -0
- package/vendor/insane-search/engine/tests/test_u5.py +163 -0
- package/vendor/insane-search/engine/tests/test_u7.py +124 -0
- package/vendor/insane-search/engine/transport.py +211 -0
- package/vendor/insane-search/engine/url_transforms.py +98 -0
- package/vendor/insane-search/engine/validators.py +331 -0
- package/vendor/insane-search/engine/waf_detector.py +214 -0
- package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
|
@@ -24,11 +24,13 @@ import * as fs from "node:fs";
|
|
|
24
24
|
import * as os from "node:os";
|
|
25
25
|
import * as path from "node:path";
|
|
26
26
|
import { promisify } from "node:util";
|
|
27
|
+
import type { ImageContent, TextContent } from "@gajae-code/ai";
|
|
27
28
|
import { NotificationServer } from "@gajae-code/natives";
|
|
28
29
|
import { logger } from "@gajae-code/utils";
|
|
29
30
|
import { Settings } from "../config/settings";
|
|
30
31
|
import type { ExtensionCommandContext, ExtensionContext, ExtensionFactory } from "../extensibility/extensions";
|
|
31
32
|
import { registerAskAnswerSource } from "../tools/ask-answer-registry";
|
|
33
|
+
import { registerTelegramFileSink } from "./attachment-registry";
|
|
32
34
|
import {
|
|
33
35
|
getNotificationConfig,
|
|
34
36
|
isGloballyConfigured,
|
|
@@ -136,12 +138,19 @@ interface SessionRuntime {
|
|
|
136
138
|
pendingInteractive: Map<string, PendingInteractiveAsk>;
|
|
137
139
|
/** Deregisters this session's ask answer source. */
|
|
138
140
|
disposeAnswerSource: () => void;
|
|
141
|
+
/** Deregisters this session's Telegram file sink. */
|
|
142
|
+
disposeFileSink: () => void;
|
|
139
143
|
redact: boolean;
|
|
140
144
|
sessionTag: string;
|
|
141
145
|
/** Whether the agent loop is currently running (drives the typing indicator). */
|
|
142
146
|
busy: boolean;
|
|
143
147
|
/** Inbound Telegram update ids injected but not yet consumed by a turn. */
|
|
144
148
|
pendingInbound: Set<number>;
|
|
149
|
+
/** Latest assistant text of the in-flight turn (from message_update). */
|
|
150
|
+
currentTurnText?: string;
|
|
151
|
+
/** Assistant text already flushed before an ask this turn (turn-scoped dedupe
|
|
152
|
+
* so turn_end does not re-emit the pre-ask lead-in). Reset each turn. */
|
|
153
|
+
preAskFlushedText?: string;
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
interface ResolvedSettings {
|
|
@@ -152,6 +161,16 @@ interface ResolvedSettings {
|
|
|
152
161
|
|
|
153
162
|
const defaultConfig: NotificationConfig = {
|
|
154
163
|
enabled: false,
|
|
164
|
+
botToken: undefined,
|
|
165
|
+
chatId: undefined,
|
|
166
|
+
discord: {
|
|
167
|
+
botToken: undefined,
|
|
168
|
+
channelId: undefined,
|
|
169
|
+
},
|
|
170
|
+
slack: {
|
|
171
|
+
botToken: undefined,
|
|
172
|
+
channelId: undefined,
|
|
173
|
+
},
|
|
155
174
|
redact: false,
|
|
156
175
|
verbosity: "lean",
|
|
157
176
|
idleTimeoutMs: 60_000,
|
|
@@ -223,6 +242,56 @@ function mapAnswerToGate(
|
|
|
223
242
|
return { selected: [] };
|
|
224
243
|
}
|
|
225
244
|
|
|
245
|
+
/** Register the interactive `ask` answer source for a session (the ask tool
|
|
246
|
+
* races the local UI against a remote reply). Returns the deregister disposer. */
|
|
247
|
+
function registerInteractiveAnswerSource(
|
|
248
|
+
id: string,
|
|
249
|
+
server: NotificationServer,
|
|
250
|
+
pendingInteractive: Map<string, PendingInteractiveAsk>,
|
|
251
|
+
redact: boolean,
|
|
252
|
+
tag: string,
|
|
253
|
+
): () => void {
|
|
254
|
+
return registerAskAnswerSource(id, {
|
|
255
|
+
awaitAnswer(question, options, signal) {
|
|
256
|
+
if (signal?.aborted) return Promise.resolve(undefined);
|
|
257
|
+
const askId = `ask:${crypto.randomUUID()}`;
|
|
258
|
+
try {
|
|
259
|
+
server.registerAsk(
|
|
260
|
+
JSON.stringify(
|
|
261
|
+
notificationActionPayload(
|
|
262
|
+
{ id: askId, kind: "ask", sessionId: id, question, options },
|
|
263
|
+
{ redact, sessionTag: tag },
|
|
264
|
+
),
|
|
265
|
+
),
|
|
266
|
+
true,
|
|
267
|
+
);
|
|
268
|
+
} catch (e) {
|
|
269
|
+
logger.warn(`notifications: registerAsk failed: ${String(e)}`);
|
|
270
|
+
return Promise.resolve(undefined);
|
|
271
|
+
}
|
|
272
|
+
return new Promise<string | undefined>(resolve => {
|
|
273
|
+
pendingInteractive.set(askId, { resolve, options });
|
|
274
|
+
signal?.addEventListener("abort", () => {
|
|
275
|
+
if (!pendingInteractive.delete(askId)) return;
|
|
276
|
+
// Local UI answered: mark the remote action resolved-locally.
|
|
277
|
+
try {
|
|
278
|
+
server.resolveLocal(askId, undefined);
|
|
279
|
+
} catch {}
|
|
280
|
+
resolve(undefined);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Extract the session id from a `<timestamp>_<uuid>.jsonl` session file path. */
|
|
288
|
+
function sessionIdFromFile(file: string | undefined): string | undefined {
|
|
289
|
+
if (!file) return undefined;
|
|
290
|
+
const base = path.basename(file).replace(/\.jsonl$/, "");
|
|
291
|
+
const underscore = base.indexOf("_");
|
|
292
|
+
return underscore >= 0 ? base.slice(underscore + 1) : undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
226
295
|
export const createNotificationsExtension: ExtensionFactory = api => {
|
|
227
296
|
const runtimes = new Map<string, SessionRuntime>();
|
|
228
297
|
const disabledSessions = new Set<string>();
|
|
@@ -234,6 +303,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
234
303
|
runtimes.delete(id);
|
|
235
304
|
try {
|
|
236
305
|
rt.disposeAnswerSource();
|
|
306
|
+
rt.disposeFileSink();
|
|
237
307
|
} catch {}
|
|
238
308
|
// Resolve any still-pending interactive asks so the ask tool is not left hanging.
|
|
239
309
|
for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
|
|
@@ -266,6 +336,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
266
336
|
const pendingInteractive = new Map<string, PendingInteractiveAsk>();
|
|
267
337
|
const tag = sessionTag(id);
|
|
268
338
|
const redact = cfg.redact;
|
|
339
|
+
let runtime: SessionRuntime | undefined;
|
|
269
340
|
|
|
270
341
|
// The SDK can always answer now (interactive via the answer source, or the
|
|
271
342
|
// unattended gate), so the endpoint advertises a resolver.
|
|
@@ -312,23 +383,34 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
312
383
|
// thread (forwarded by the daemon over the WS, fail-closed at the daemon).
|
|
313
384
|
server.onInbound((err, inbound) => {
|
|
314
385
|
if (err || !inbound) return;
|
|
315
|
-
if (inbound.kind === "user_message"
|
|
386
|
+
if (inbound.kind === "user_message") {
|
|
316
387
|
// Inject as a user turn (steers/continues the agent; the resulting
|
|
317
388
|
// turn streams back via the turn_end handler even when not idle).
|
|
318
389
|
// Record the update id so it can be acked as "consumed" on the next
|
|
319
390
|
// turn_start, and steer (vs start a fresh turn) when already busy.
|
|
320
|
-
const
|
|
321
|
-
|
|
391
|
+
const text = inbound.text ?? "";
|
|
392
|
+
const images = inbound.images ?? [];
|
|
393
|
+
if (!text && images.length === 0) return;
|
|
394
|
+
if (runtime && typeof inbound.updateId === "number") runtime.pendingInbound.add(inbound.updateId);
|
|
395
|
+
const content: string | (TextContent | ImageContent)[] =
|
|
396
|
+
images.length > 0
|
|
397
|
+
? [
|
|
398
|
+
...(text ? [{ type: "text", text } as TextContent] : []),
|
|
399
|
+
...images.map(
|
|
400
|
+
img =>
|
|
401
|
+
({ type: "image", data: img.data, mimeType: img.mime ?? "image/jpeg" }) as ImageContent,
|
|
402
|
+
),
|
|
403
|
+
]
|
|
404
|
+
: text;
|
|
322
405
|
try {
|
|
323
|
-
api.sendUserMessage(
|
|
406
|
+
api.sendUserMessage(content, runtime?.busy ? { deliverAs: "steer" } : undefined);
|
|
324
407
|
} catch (e) {
|
|
325
408
|
logger.warn(`notifications: sendUserMessage failed: ${String(e)}`);
|
|
326
409
|
}
|
|
327
410
|
return;
|
|
328
411
|
}
|
|
329
412
|
if (inbound.kind === "config_command") {
|
|
330
|
-
|
|
331
|
-
if (rt && typeof inbound.redact === "boolean") rt.redact = inbound.redact;
|
|
413
|
+
if (runtime && typeof inbound.redact === "boolean") runtime.redact = inbound.redact;
|
|
332
414
|
}
|
|
333
415
|
});
|
|
334
416
|
|
|
@@ -336,48 +418,37 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
336
418
|
const endpoint = await server.start();
|
|
337
419
|
|
|
338
420
|
// Interactive answer source: the ask tool races the local UI against this.
|
|
339
|
-
const disposeAnswerSource =
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
return new Promise<string | undefined>(resolve => {
|
|
358
|
-
pendingInteractive.set(askId, { resolve, options });
|
|
359
|
-
signal?.addEventListener("abort", () => {
|
|
360
|
-
if (!pendingInteractive.delete(askId)) return;
|
|
361
|
-
// Local UI answered: mark the remote action resolved-locally.
|
|
362
|
-
try {
|
|
363
|
-
server.resolveLocal(askId, undefined);
|
|
364
|
-
} catch {}
|
|
365
|
-
resolve(undefined);
|
|
366
|
-
});
|
|
367
|
-
});
|
|
368
|
-
},
|
|
421
|
+
const disposeAnswerSource = registerInteractiveAnswerSource(id, server, pendingInteractive, redact, tag);
|
|
422
|
+
const disposeFileSink = registerTelegramFileSink(id, async file => {
|
|
423
|
+
try {
|
|
424
|
+
const data = await fs.promises.readFile(file.path);
|
|
425
|
+
server.pushFrame(
|
|
426
|
+
JSON.stringify({
|
|
427
|
+
type: "file_attachment",
|
|
428
|
+
sessionId: id,
|
|
429
|
+
name: path.basename(file.path),
|
|
430
|
+
data: data.toString("base64"),
|
|
431
|
+
caption: file.caption,
|
|
432
|
+
}),
|
|
433
|
+
);
|
|
434
|
+
return { ok: true };
|
|
435
|
+
} catch (e) {
|
|
436
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
437
|
+
}
|
|
369
438
|
});
|
|
370
439
|
|
|
371
|
-
|
|
440
|
+
runtime = {
|
|
372
441
|
server,
|
|
373
442
|
idleSeq: 0,
|
|
374
443
|
pendingInteractive,
|
|
375
444
|
disposeAnswerSource,
|
|
445
|
+
disposeFileSink,
|
|
376
446
|
redact,
|
|
377
447
|
sessionTag: tag,
|
|
378
448
|
busy: false,
|
|
379
449
|
pendingInbound: new Set<number>(),
|
|
380
|
-
}
|
|
450
|
+
};
|
|
451
|
+
runtimes.set(id, runtime);
|
|
381
452
|
logger.info(`notifications: serving session ${id} at ${endpoint.url} (unattended=${unattended})`);
|
|
382
453
|
|
|
383
454
|
if (settingsAvailable && settings && isGloballyConfigured(cfg)) {
|
|
@@ -507,6 +578,82 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
507
578
|
await startSession(ctx);
|
|
508
579
|
});
|
|
509
580
|
|
|
581
|
+
// A session id change within the same process needs reason-aware handling.
|
|
582
|
+
// `/new` and fork CONTINUE the same terminal thread (e.g. plan "approve and
|
|
583
|
+
// execute" clears into a fresh session), so re-key the existing runtime
|
|
584
|
+
// old→new WITHOUT recreating the NotificationServer: the server, its endpoint
|
|
585
|
+
// discovery file, and the daemon's forum topic are all keyed by the original
|
|
586
|
+
// session id and the daemon routes by socket, so the existing topic is reused
|
|
587
|
+
// and the next identity frame renames it in place instead of spawning a new
|
|
588
|
+
// thread. `resume`, by contrast, loads a DIFFERENT, already-persisted session
|
|
589
|
+
// that owns its own topic — tear the previous runtime down and start fresh
|
|
590
|
+
// under the resumed id so the daemon attaches to (or recreates) that
|
|
591
|
+
// session's own discovery + topic rather than hijacking this terminal's.
|
|
592
|
+
api.on("session_switch", async (event, ctx) => {
|
|
593
|
+
const newId = sessionId(ctx);
|
|
594
|
+
const prevId = sessionIdFromFile(event.previousSessionFile);
|
|
595
|
+
if (!prevId || prevId === newId) return;
|
|
596
|
+
|
|
597
|
+
if (event.reason === "resume") {
|
|
598
|
+
stopSession(prevId);
|
|
599
|
+
await startSession(ctx);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// `/new` / fork: re-key in place and rename the existing topic.
|
|
604
|
+
if (disabledSessions.delete(prevId)) disabledSessions.add(newId);
|
|
605
|
+
const rt = runtimes.get(prevId);
|
|
606
|
+
if (!rt || runtimes.has(newId)) return;
|
|
607
|
+
runtimes.delete(prevId);
|
|
608
|
+
runtimes.set(newId, rt);
|
|
609
|
+
// Re-bind the interactive ask answer source: the ask tool resolves the
|
|
610
|
+
// source by the current session id, which just changed.
|
|
611
|
+
try {
|
|
612
|
+
rt.disposeAnswerSource();
|
|
613
|
+
rt.disposeFileSink();
|
|
614
|
+
} catch {}
|
|
615
|
+
rt.disposeAnswerSource = registerInteractiveAnswerSource(
|
|
616
|
+
newId,
|
|
617
|
+
rt.server,
|
|
618
|
+
rt.pendingInteractive,
|
|
619
|
+
rt.redact,
|
|
620
|
+
rt.sessionTag,
|
|
621
|
+
);
|
|
622
|
+
rt.disposeFileSink = registerTelegramFileSink(newId, async file => {
|
|
623
|
+
try {
|
|
624
|
+
const data = await fs.promises.readFile(file.path);
|
|
625
|
+
rt.server.pushFrame(
|
|
626
|
+
JSON.stringify({
|
|
627
|
+
type: "file_attachment",
|
|
628
|
+
sessionId: newId,
|
|
629
|
+
name: path.basename(file.path),
|
|
630
|
+
data: data.toString("base64"),
|
|
631
|
+
caption: file.caption,
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
return { ok: true };
|
|
635
|
+
} catch (e) {
|
|
636
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
// Rename the existing topic now when the new session already has a name; a
|
|
640
|
+
// fresh unnamed session is renamed on its next agent_end re-assert, which
|
|
641
|
+
// avoids a transient rename to bare "repo/branch".
|
|
642
|
+
if (ctx.sessionManager.getSessionName()) {
|
|
643
|
+
try {
|
|
644
|
+
rt.server.pushFrame(
|
|
645
|
+
JSON.stringify({
|
|
646
|
+
type: "identity_header",
|
|
647
|
+
sessionId: newId,
|
|
648
|
+
...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
|
|
649
|
+
}),
|
|
650
|
+
);
|
|
651
|
+
} catch (e) {
|
|
652
|
+
logger.warn(`notifications: identity_header (switch) failed: ${String(e)}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
510
657
|
// Drive the live typing indicator: mark busy when the agent loop starts so
|
|
511
658
|
// the daemon shows "typing…" in the thread while the agent is thinking,
|
|
512
659
|
// before any turn output exists. Cleared on `agent_end` below.
|
|
@@ -620,18 +767,42 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
620
767
|
// Redaction suppresses streamed content (only the one-time identity header
|
|
621
768
|
// survives redaction). The daemon coalesces/throttles these via its shared
|
|
622
769
|
// rate-limit pool before sending to Telegram.
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
if (!rt) return;
|
|
627
|
-
|
|
628
|
-
const text = summaryFromMessage(event.message, 3500);
|
|
629
|
-
if (!text) return;
|
|
770
|
+
// Push the in-flight turn's assistant text as a finalized turn_stream, deduped
|
|
771
|
+
// against what was already flushed for this turn (the pre-ask lead-in).
|
|
772
|
+
const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined): void => {
|
|
773
|
+
if (!text || text === rt.preAskFlushedText) return;
|
|
774
|
+
rt.preAskFlushedText = text;
|
|
630
775
|
try {
|
|
631
776
|
rt.server.pushFrame(JSON.stringify({ type: "turn_stream", sessionId: id, phase: "finalized", text }));
|
|
632
777
|
} catch (e) {
|
|
633
778
|
logger.warn(`notifications: pushFrame (turn) failed: ${String(e)}`);
|
|
634
779
|
}
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
// Emit the assistant text that precedes an ask BEFORE the ask's action_needed
|
|
783
|
+
// is broadcast, so the remote (e.g. Telegram) shows the lead-in first instead
|
|
784
|
+
// of only after the ask resolves at turn_end. The text is captured on
|
|
785
|
+
// message_end (which, like tool_execution_start, is on the awaited extension
|
|
786
|
+
// path and ordered before it — unlike message_update, which is queued async),
|
|
787
|
+
// then flushed here before the ask tool's execute calls registerAsk.
|
|
788
|
+
api.on("tool_execution_start", (event, ctx) => {
|
|
789
|
+
if (event.toolName !== "ask") return;
|
|
790
|
+
const id = sessionId(ctx);
|
|
791
|
+
const rt = runtimes.get(id);
|
|
792
|
+
if (!rt || rt.redact) return;
|
|
793
|
+
flushTurnText(rt, id, rt.currentTurnText);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
api.on("turn_end", (event, ctx) => {
|
|
797
|
+
const id = sessionId(ctx);
|
|
798
|
+
const rt = runtimes.get(id);
|
|
799
|
+
if (!rt) return;
|
|
800
|
+
const text = rt.redact ? undefined : summaryFromMessage(event.message, 3500);
|
|
801
|
+
if (text) flushTurnText(rt, id, text);
|
|
802
|
+
// Reset per-turn streaming state so the next turn starts fresh and a later
|
|
803
|
+
// turn with identical text is not falsely deduped.
|
|
804
|
+
rt.currentTurnText = undefined;
|
|
805
|
+
rt.preAskFlushedText = undefined;
|
|
635
806
|
});
|
|
636
807
|
|
|
637
808
|
// Stream agent-produced images (computer/browser/tool screenshots) as
|
|
@@ -640,6 +811,14 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
640
811
|
const id = sessionId(ctx);
|
|
641
812
|
const rt = runtimes.get(id);
|
|
642
813
|
if (!rt || rt.redact) return;
|
|
814
|
+
// Capture the in-flight ASSISTANT text here (message_end is on the awaited
|
|
815
|
+
// extension path and ordered before tool_execution_start) so the pre-ask
|
|
816
|
+
// flush can emit it before the ask prompt. Role-scoped: message_end also
|
|
817
|
+
// fires for the user prompt, which must never be mirrored back as turn output.
|
|
818
|
+
if ((event.message as { role?: unknown }).role === "assistant") {
|
|
819
|
+
const turnText = summaryFromMessage(event.message, 3500);
|
|
820
|
+
if (turnText) rt.currentTurnText = turnText;
|
|
821
|
+
}
|
|
643
822
|
for (const img of imageAttachmentsFromMessage(event.message)) {
|
|
644
823
|
try {
|
|
645
824
|
rt.server.pushFrame(
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { Settings } from "../config/settings";
|
|
3
|
+
import { RateLimitPool } from "./rate-limit-pool";
|
|
4
|
+
import {
|
|
5
|
+
CLIENT_PING_PONG_CAPABILITY,
|
|
6
|
+
HEARTBEAT_INTERVAL_MS,
|
|
7
|
+
HEARTBEAT_TTL_MS,
|
|
8
|
+
NOTIFICATION_PROTOCOL_VERSION,
|
|
9
|
+
} from "./telegram-daemon";
|
|
10
|
+
import { readEndpoint } from "./telegram-reference";
|
|
11
|
+
import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
|
|
12
|
+
|
|
13
|
+
export interface ManagedNotificationDaemonFs {
|
|
14
|
+
readdir(path: string): Promise<string[]>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ManagedSessionSocket {
|
|
18
|
+
sessionId: string;
|
|
19
|
+
token: string;
|
|
20
|
+
ws: WebSocket;
|
|
21
|
+
pending: Map<string, { sessionId: string; actionId: string }>;
|
|
22
|
+
capable: boolean;
|
|
23
|
+
lastPongAt: number;
|
|
24
|
+
awaitingNonce: string | undefined;
|
|
25
|
+
pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ManagedNotificationDaemonOptions {
|
|
29
|
+
settings: Settings;
|
|
30
|
+
fs: ManagedNotificationDaemonFs;
|
|
31
|
+
WebSocketImpl?: typeof WebSocket;
|
|
32
|
+
now?: () => number;
|
|
33
|
+
setIntervalImpl?: typeof setInterval;
|
|
34
|
+
clearIntervalImpl?: typeof clearInterval;
|
|
35
|
+
rateLimitPool?: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export abstract class ManagedNotificationDaemon {
|
|
39
|
+
readonly sessions = new Map<string, ManagedSessionSocket>();
|
|
40
|
+
readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
|
|
41
|
+
|
|
42
|
+
protected constructor(protected readonly opts: ManagedNotificationDaemonOptions) {
|
|
43
|
+
this.pool = opts.rateLimitPool ?? new RateLimitPool<{ send: ThreadedSend; topicId?: string }>({ now: opts.now });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async scanRoots(): Promise<void> {
|
|
47
|
+
const rootState = await this.readRoots();
|
|
48
|
+
for (const root of rootState) {
|
|
49
|
+
const dir = path.join(root, "notifications");
|
|
50
|
+
let files: string[];
|
|
51
|
+
try {
|
|
52
|
+
files = await this.opts.fs.readdir(dir);
|
|
53
|
+
} catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for (const file of files.filter(item => item.endsWith(".json"))) {
|
|
57
|
+
const sessionId = path.basename(file, ".json");
|
|
58
|
+
if (this.sessions.has(sessionId)) continue;
|
|
59
|
+
try {
|
|
60
|
+
const endpoint = readEndpoint(path.join(dir, file));
|
|
61
|
+
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
connectSession(sessionId: string, url: string, token: string): ManagedSessionSocket {
|
|
68
|
+
const WS = this.opts.WebSocketImpl ?? WebSocket;
|
|
69
|
+
const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
|
|
70
|
+
const session: ManagedSessionSocket = {
|
|
71
|
+
sessionId,
|
|
72
|
+
token,
|
|
73
|
+
ws,
|
|
74
|
+
pending: new Map(),
|
|
75
|
+
capable: false,
|
|
76
|
+
lastPongAt: 0,
|
|
77
|
+
awaitingNonce: undefined,
|
|
78
|
+
pingTimer: undefined,
|
|
79
|
+
};
|
|
80
|
+
this.sessions.set(sessionId, session);
|
|
81
|
+
ws.addEventListener("open", () => this.sendHello(session));
|
|
82
|
+
ws.addEventListener("message", ev => {
|
|
83
|
+
if (this.sessions.get(sessionId) !== session) return;
|
|
84
|
+
void this.handleSessionMessage(session, JSON.parse(String(ev.data))).catch(() => undefined);
|
|
85
|
+
});
|
|
86
|
+
ws.addEventListener("close", () => this.dropSession(session));
|
|
87
|
+
return session;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
protected async handleSessionMessage(session: ManagedSessionSocket, msg: Record<string, unknown>): Promise<boolean> {
|
|
91
|
+
if (msg.type === "hello") {
|
|
92
|
+
const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
93
|
+
if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
|
|
94
|
+
session.capable = true;
|
|
95
|
+
this.startLiveness(session);
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (msg.type === "pong") {
|
|
100
|
+
if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
|
|
101
|
+
session.awaitingNonce = undefined;
|
|
102
|
+
session.lastPongAt = (this.opts.now ?? Date.now)();
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
protected renderFrame(frame: Record<string, unknown>): ThreadedSend | undefined {
|
|
110
|
+
return renderThreadedFrame(frame);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
protected dropSession(session: ManagedSessionSocket): void {
|
|
114
|
+
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
115
|
+
if (session.pingTimer) {
|
|
116
|
+
clearIntervalImpl(session.pingTimer);
|
|
117
|
+
session.pingTimer = undefined;
|
|
118
|
+
}
|
|
119
|
+
if (this.sessions.get(session.sessionId) === session) this.sessions.delete(session.sessionId);
|
|
120
|
+
if (session.ws.readyState !== WebSocket.CLOSED) {
|
|
121
|
+
try {
|
|
122
|
+
session.ws.close();
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
protected abstract readRoots(): Promise<string[]>;
|
|
128
|
+
|
|
129
|
+
private sendHello(session: ManagedSessionSocket): void {
|
|
130
|
+
if (session.ws.readyState !== WebSocket.OPEN) return;
|
|
131
|
+
try {
|
|
132
|
+
session.ws.send(
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
type: "hello",
|
|
135
|
+
protocolVersion: NOTIFICATION_PROTOCOL_VERSION,
|
|
136
|
+
capabilities: [CLIENT_PING_PONG_CAPABILITY],
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private startLiveness(session: ManagedSessionSocket): void {
|
|
143
|
+
if (session.pingTimer) return;
|
|
144
|
+
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
145
|
+
const now = () => (this.opts.now ?? Date.now)();
|
|
146
|
+
session.lastPongAt = now();
|
|
147
|
+
session.pingTimer = setIntervalImpl(() => {
|
|
148
|
+
if (this.sessions.get(session.sessionId) !== session) return;
|
|
149
|
+
const t = now();
|
|
150
|
+
if (t - session.lastPongAt >= HEARTBEAT_TTL_MS) {
|
|
151
|
+
this.dropSession(session);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (session.ws.readyState === WebSocket.OPEN) {
|
|
155
|
+
const nonce = `${session.sessionId}:${t}:${Math.random().toString(36).slice(2)}`;
|
|
156
|
+
session.awaitingNonce = nonce;
|
|
157
|
+
try {
|
|
158
|
+
session.ws.send(JSON.stringify({ type: "ping", nonce }));
|
|
159
|
+
} catch {}
|
|
160
|
+
}
|
|
161
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
162
|
+
}
|
|
163
|
+
}
|