agent-relay-server 0.40.0 → 0.41.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.
@@ -136,7 +136,8 @@ export interface ProviderAdapter {
136
136
  provider: string;
137
137
  spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
138
138
  shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
139
- compact?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
139
+ compact?(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown> | void>;
140
+ compactSupportsInstructions?: boolean;
140
141
  clearContext?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
141
142
  // Normalize the session so far into the provider-agnostic SessionEvent stream the
142
143
  // Insights context-ratio signal (#183/#184) reduces. Called by the runner's
@@ -147,6 +148,10 @@ export interface ProviderAdapter {
147
148
  // ignore it and return their accumulated log. Return null when there is nothing to
148
149
  // measure. Best-effort: may be omitted by providers without a session view yet.
149
150
  collectSessionEvents?(process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<SessionEvent[] | null>;
151
+ // Full searchable transcript/archive source for destructive boundaries. The runner
152
+ // slices the returned stream by cursor, so adapters should return the session-so-far
153
+ // view when they have one.
154
+ collectSessionArchiveSegment?(process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<string | null>;
150
155
  // Interrupt the in-flight turn without ending the session (ESC for Claude's
151
156
  // tmux pane, turn/interrupt for the Codex app-server). Provider-independent at
152
157
  // the runner boundary; each adapter does what its provider actually supports.
@@ -185,7 +190,7 @@ export function profileAllowsRelayFeature(config: RunnerSpawnConfig, feature: ke
185
190
  return config.agentProfile?.relay?.[feature] !== false;
186
191
  }
187
192
 
188
- export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id — relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own — find them later with relay_find_agents spawnedBy:me. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
193
+ export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_compact_and_resume, relay_recall, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id — relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_compact_and_resume is for clean-seam self-resume after a context advisory: pass workingState and optional ruledOut; Relay owns the objective envelope. relay_recall searches your own archived pre-compaction segments by keyword when a discarded detail is needed. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own — find them later with relay_find_agents spawnedBy:me. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
189
194
 
190
195
  // #306 — deliver the FULL message body by default. Only a pathological body beyond this
191
196
  // high cap truncates (with a get-message hint) so it can't nuke an agent's context; the 99%
package/src/bus.ts CHANGED
@@ -12,6 +12,7 @@ import { commandAuthorizationResource, dispatchCommand } from "./services/dispat
12
12
  import { ServiceAuthError } from "./services/errors";
13
13
  import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
14
14
  import { applyCommandToRecipe } from "./recipe-runner";
15
+ import { enqueueContinuationInjectionAfterClear } from "./self-resume";
15
16
  import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
16
17
  import {
17
18
  BusProtocolError,
@@ -219,6 +220,7 @@ function handleCommandFrame(
219
220
  // Production command completions arrive here over the bus, not the HTTP
220
221
  // PATCH route — arm the stall watch for finished compact/clear commands.
221
222
  noteCompactionCommandCompleted(updated.type, updated.status, updated.id, updated.target);
223
+ enqueueContinuationInjectionAfterClear(updated);
222
224
  }
223
225
  sendCommandResult(ws, frameId, "succeeded", updated ? { command: updated } : undefined);
224
226
  return;
@@ -1,5 +1,7 @@
1
1
  import { getDb, ValidationError } from "./db";
2
2
  import { cleanEnum, cleanString, cleanStringArray } from "./validation";
3
+ import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from "./context-threshold-config";
4
+ import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
3
5
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
4
6
  import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
5
7
  import type {
@@ -12,6 +14,7 @@ import type {
12
14
  ManagedAgentState,
13
15
  ManagedAgentStatus,
14
16
  NotificationsConfig,
17
+ SelfResumeConfig,
15
18
  SpawnApprovalMode,
16
19
  SpawnPolicy,
17
20
  SpawnProvider,
@@ -29,6 +32,8 @@ const INSIGHTS_NAMESPACE = "insights";
29
32
  const INSIGHTS_KEY = "default";
30
33
  const NOTIFICATIONS_NAMESPACE = "notifications";
31
34
  const NOTIFICATIONS_KEY = "default";
35
+ const SELF_RESUME_NAMESPACE = "self-resume";
36
+ const SELF_RESUME_KEY = "default";
32
37
  const WORKSPACE_NAMESPACE = "workspace";
33
38
  const WORKSPACE_KEY = "default";
34
39
  const LANDING_NAMESPACE = "landing";
@@ -475,17 +480,11 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
475
480
  // Relay-driven lifecycle push notifications (#239 event bus). Default-on; the
476
481
  // operator can flip the master switch or individual events off via the generic
477
482
  // config route. Push messages wake recipients, so they must be suppressible.
478
- const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = {
479
- enabled: true,
480
- branchLanded: true,
481
- agentReady: true,
482
- agentExited: true,
483
- agentSpawnFailed: true,
484
- };
483
+ const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = { enabled: true, branchLanded: true, agentReady: true, agentExited: true, agentSpawnFailed: true, contextThreshold: DEFAULT_CONTEXT_THRESHOLD_CONFIG };
485
484
 
486
485
  function validateNotificationsConfig(value: unknown): NotificationsConfig {
487
486
  if (!isRecord(value)) throw new ValidationError("notifications config value must be an object");
488
- const bool = (key: keyof NotificationsConfig): boolean =>
487
+ const bool = (key: "enabled" | "branchLanded" | "agentReady" | "agentExited" | "agentSpawnFailed"): boolean =>
489
488
  value[key] === undefined ? NOTIFICATIONS_CONFIG_DEFAULTS[key] : cleanBoolean(value[key], key);
490
489
  return {
491
490
  enabled: bool("enabled"),
@@ -493,6 +492,7 @@ function validateNotificationsConfig(value: unknown): NotificationsConfig {
493
492
  agentReady: bool("agentReady"),
494
493
  agentExited: bool("agentExited"),
495
494
  agentSpawnFailed: bool("agentSpawnFailed"),
495
+ contextThreshold: validateContextThresholdConfig(value.contextThreshold),
496
496
  };
497
497
  }
498
498
 
@@ -537,6 +537,7 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
537
537
  if (namespace === STEWARD_NAMESPACE) return validateStewardConfig(value);
538
538
  if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
539
539
  if (namespace === NOTIFICATIONS_NAMESPACE) return validateNotificationsConfig(value);
540
+ if (namespace === SELF_RESUME_NAMESPACE) return validateSelfResumeConfig(value);
540
541
  if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
541
542
  if (namespace === LANDING_NAMESPACE) return validateLandingConfig(value);
542
543
  if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
@@ -682,6 +683,17 @@ export function setInsightsConfig(value: unknown, updatedBy?: string): ConfigEnt
682
683
  return setConfig(INSIGHTS_NAMESPACE, INSIGHTS_KEY, value as InsightsConfig, updatedBy);
683
684
  }
684
685
 
686
+ /** Relay-owned self-resume defaults (#316), merged over defaults (always usable). */
687
+ export function getSelfResumeConfig(): SelfResumeConfig {
688
+ const entry = getConfig<Partial<SelfResumeConfig>>(SELF_RESUME_NAMESPACE, SELF_RESUME_KEY);
689
+ if (!entry) return { ...DEFAULT_SELF_RESUME_CONFIG, budget: { ...DEFAULT_SELF_RESUME_CONFIG.budget } };
690
+ return validateSelfResumeConfig({
691
+ ...DEFAULT_SELF_RESUME_CONFIG,
692
+ ...entry.value,
693
+ budget: { ...DEFAULT_SELF_RESUME_CONFIG.budget, ...(entry.value.budget ?? {}) },
694
+ });
695
+ }
696
+
685
697
  /** Global workspace config, merged over defaults (always returns a usable value). */
686
698
  export function getWorkspaceConfig(): WorkspaceConfig {
687
699
  const entry = getConfig<Partial<WorkspaceConfig>>(WORKSPACE_NAMESPACE, WORKSPACE_KEY);
@@ -0,0 +1,110 @@
1
+ import { getNotificationsConfig } from "./config-store";
2
+ import { getDb } from "./db/connection.ts";
3
+ import { emitRelayEvent } from "./events";
4
+ import { notifySystemMessage } from "./notify";
5
+ import type { AgentCard, Command, ContextState } from "./types";
6
+
7
+ interface ContextAdvisoryInput {
8
+ agentId: string;
9
+ tokens: number;
10
+ percent: number;
11
+ windowTokens: number;
12
+ band: number;
13
+ hardBackstopPercent: number;
14
+ }
15
+
16
+ /**
17
+ * #315 — context pressure is a relay-native signal: durable event for observers,
18
+ * plus an agent-facing system push when lifecycle notifications are enabled.
19
+ */
20
+ function notifyContextAdvisory(input: ContextAdvisoryInput): void {
21
+ const payload = {
22
+ kind: "agent.context_threshold",
23
+ agentId: input.agentId,
24
+ tokens: input.tokens,
25
+ percent: input.percent,
26
+ windowTokens: input.windowTokens,
27
+ band: input.band,
28
+ hardBackstopPercent: input.hardBackstopPercent,
29
+ };
30
+
31
+ emitRelayEvent({
32
+ type: "agent.context_threshold",
33
+ source: "server",
34
+ subject: input.agentId,
35
+ data: payload,
36
+ });
37
+
38
+ const config = getNotificationsConfig();
39
+ if (!config.enabled || !config.contextThreshold.enabled) return;
40
+
41
+ notifySystemMessage(input.agentId, {
42
+ subject: "Context threshold reached",
43
+ body: `Context advisory: ${formatTokens(input.tokens)} / ${formatTokens(input.windowTokens)} tokens used (${input.percent}% of window, ${input.band}% band). Compact at a clean seam before the ${input.hardBackstopPercent}% hard backstop may request compaction.`,
44
+ payload,
45
+ replyExpected: false,
46
+ });
47
+ }
48
+
49
+ function contextAdvisoryBand(utilization: number, advisoryUtilization: number, bandSize: number): number | null {
50
+ if (!Number.isFinite(utilization) || utilization < advisoryUtilization) return null;
51
+ const percent = Math.min(100, Math.max(0, utilization * 100));
52
+ const step = Math.max(1, bandSize * 100);
53
+ return Math.min(100, Math.floor(percent / step) * step);
54
+ }
55
+
56
+ export function handleContextPressure(input: {
57
+ agent: AgentCard;
58
+ now: () => number;
59
+ hasActiveCommand: (target: string, type: string) => boolean;
60
+ compact: (reason: string) => Command | null;
61
+ }): boolean {
62
+ const { agent, now } = input;
63
+ const context = agent.context;
64
+ if (!context || agent.status === "busy") return false;
65
+ if (context.lifecycleState === "compacting" || context.lifecycleState === "hibernating") return false;
66
+ if (typeof context.utilization !== "number" || !Number.isFinite(context.utilization)) return false;
67
+ const threshold = getNotificationsConfig().contextThreshold;
68
+ const band = contextAdvisoryBand(context.utilization, threshold.advisoryUtilization, threshold.bandSize);
69
+ if (band === null) {
70
+ if (context.lastContextAdvisoryBand !== undefined || context.lastContextAdvisoryAt !== undefined) {
71
+ persistContextState(agent.id, clearContextAdvisory(context));
72
+ }
73
+ return false;
74
+ }
75
+ if (context.utilization >= threshold.hardBackstopUtilization && context.lastContextAdvisoryAt !== undefined && agent.providerCapabilities?.context?.compact === true && !input.hasActiveCommand(agent.id, "agent.compact")) {
76
+ input.compact("context-hard-backstop");
77
+ return true;
78
+ }
79
+ if (context.lastContextAdvisoryBand === band) return false;
80
+ const tokens = finiteNumber(context.tokensUsed);
81
+ const windowTokens = finiteNumber(context.tokensMax);
82
+ if (tokens === undefined || windowTokens === undefined) return false;
83
+ notifyContextAdvisory({
84
+ agentId: agent.id,
85
+ tokens,
86
+ percent: Math.round(context.utilization * 100),
87
+ windowTokens,
88
+ band,
89
+ hardBackstopPercent: Math.round(threshold.hardBackstopUtilization * 100),
90
+ });
91
+ persistContextState(agent.id, { ...context, lastContextAdvisoryBand: band, lastContextAdvisoryAt: now() });
92
+ return true;
93
+ }
94
+
95
+ function clearContextAdvisory(context: ContextState): ContextState {
96
+ const { lastContextAdvisoryBand: _band, lastContextAdvisoryAt: _at, ...rest } = context;
97
+ return rest;
98
+ }
99
+
100
+ function persistContextState(agentId: string, context: ContextState): void {
101
+ getDb().query("UPDATE agents SET context_state = ? WHERE id = ?").run(JSON.stringify(context), agentId);
102
+ }
103
+
104
+ function finiteNumber(value: unknown): number | undefined {
105
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
106
+ }
107
+
108
+ function formatTokens(value: number): string {
109
+ return Math.round(value).toLocaleString("en-US");
110
+ }
@@ -0,0 +1,40 @@
1
+ import { isRecord } from "agent-relay-sdk";
2
+ import { ValidationError } from "./db/connection.ts";
3
+ import type { NotificationsConfig } from "./types";
4
+
5
+ export const DEFAULT_CONTEXT_THRESHOLD_CONFIG: NotificationsConfig["contextThreshold"] = {
6
+ enabled: true,
7
+ advisoryUtilization: 0.7,
8
+ hardBackstopUtilization: 0.9,
9
+ bandSize: 0.1,
10
+ };
11
+
12
+ export function validateContextThresholdConfig(value: unknown): NotificationsConfig["contextThreshold"] {
13
+ const input = isRecord(value) ? value : {};
14
+ const bandSize = cleanFraction(input.bandSize, "contextThreshold.bandSize", DEFAULT_CONTEXT_THRESHOLD_CONFIG.bandSize);
15
+ if (bandSize <= 0) throw new ValidationError("contextThreshold.bandSize must be greater than 0");
16
+ const advisoryUtilization = cleanFraction(input.advisoryUtilization, "contextThreshold.advisoryUtilization", DEFAULT_CONTEXT_THRESHOLD_CONFIG.advisoryUtilization);
17
+ const hardBackstopUtilization = cleanFraction(input.hardBackstopUtilization, "contextThreshold.hardBackstopUtilization", DEFAULT_CONTEXT_THRESHOLD_CONFIG.hardBackstopUtilization);
18
+ if (hardBackstopUtilization <= advisoryUtilization) {
19
+ throw new ValidationError("contextThreshold.hardBackstopUtilization must be greater than contextThreshold.advisoryUtilization");
20
+ }
21
+ return {
22
+ enabled: input.enabled === undefined ? DEFAULT_CONTEXT_THRESHOLD_CONFIG.enabled : cleanBoolean(input.enabled, "contextThreshold.enabled"),
23
+ advisoryUtilization,
24
+ hardBackstopUtilization,
25
+ bandSize,
26
+ };
27
+ }
28
+
29
+ function cleanBoolean(value: unknown, field: string): boolean {
30
+ if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
31
+ return value;
32
+ }
33
+
34
+ function cleanFraction(value: unknown, field: string, fallback: number): number {
35
+ if (value === undefined || value === null) return fallback;
36
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
37
+ throw new ValidationError(`${field} must be a number between 0 and 1`);
38
+ }
39
+ return value;
40
+ }
package/src/db/agents.ts CHANGED
@@ -15,11 +15,13 @@ import {
15
15
  import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
16
16
  import { matchAgents } from "../agent-ref";
17
17
  import { evaluatePoolBindings, expectedChannelAgentId, upsertChannelForAgent } from "./channels.ts";
18
+ import { seedContinuationObjectiveFromSpawnPrompt } from "./continuations.ts";
18
19
  import { ValidationError, getDb } from "./connection.ts";
19
20
  import { rowToAgent, rowToContextSnapshot } from "./mappers.ts";
20
21
  import { closeOpenPairsForAgent } from "./pairs.ts";
21
22
  import { TASK_SELECT, insertTaskEvent } from "./tasks.ts";
22
23
  import { electWorkspaceStewards, electWorkspaceStewardsForAgent } from "./workspaces.ts";
24
+ import { DEFAULT_SELF_RESUME_CONFIG } from "../self-resume-config";
23
25
  import type {
24
26
  AgentCard,
25
27
  ActivityEvent,
@@ -153,6 +155,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
153
155
  const labelProvided = Object.prototype.hasOwnProperty.call(input, "label");
154
156
  const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
155
157
  const instanceProvided = Boolean(input.instanceId);
158
+ const contextState = mergeRelayContextMarkers(input.id, input.context);
156
159
  const stmt = getDb().query(`
157
160
  INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, provider_capabilities, context_state, meta, spawned_by, last_seen, created_at)
158
161
  VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $providerCapabilities, $contextState, $meta, $spawnedBy, $now, $now)
@@ -195,14 +198,15 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
195
198
  $instanceProvided: instanceProvided ? 1 : 0,
196
199
  $initialEpoch: instanceProvided ? 1 : 0,
197
200
  $providerCapabilities: input.providerCapabilities ? JSON.stringify(input.providerCapabilities) : null,
198
- $contextState: input.context ? JSON.stringify(input.context) : null,
201
+ $contextState: contextState ? JSON.stringify(contextState) : null,
199
202
  $meta: JSON.stringify(input.meta ?? {}),
200
203
  $spawnedBy: input.spawnedBy ?? null,
201
204
  $now: now,
202
205
  });
203
- if (input.context) recordContextSnapshot(input.id, input.context, now);
206
+ if (contextState) recordContextSnapshot(input.id, contextState, now);
204
207
 
205
208
  const agent = getAgent(input.id)!;
209
+ seedContinuationObjectiveFromSpawnPrompt(agent.id, stringValue(agent.meta?.spawnRequestId), DEFAULT_SELF_RESUME_CONFIG, now);
206
210
  if (agent.kind === "channel") upsertChannelForAgent(agent);
207
211
  evaluatePoolBindings();
208
212
  // A (re)joining agent may revive a dormant repo steward — re-elect for the
@@ -335,6 +339,7 @@ export function heartbeat(
335
339
  if (!validateAgentSession(id, guard).ok) return false;
336
340
  const now = Date.now();
337
341
  if (runtime?.providerCapabilities || runtime?.context) {
342
+ const contextState = mergeRelayContextMarkers(id, runtime.context);
338
343
  const result = getDb()
339
344
  .query(`
340
345
  UPDATE agents SET
@@ -347,10 +352,10 @@ export function heartbeat(
347
352
  .run(
348
353
  now,
349
354
  runtime.providerCapabilities ? JSON.stringify(runtime.providerCapabilities) : null,
350
- runtime.context ? JSON.stringify(runtime.context) : null,
355
+ contextState ? JSON.stringify(contextState) : null,
351
356
  id,
352
357
  );
353
- if (result.changes > 0 && runtime.context) recordContextSnapshot(id, runtime.context, now);
358
+ if (result.changes > 0 && contextState) recordContextSnapshot(id, contextState, now);
354
359
  return result.changes > 0;
355
360
  }
356
361
  const result = getDb()
@@ -412,6 +417,18 @@ export function recordContextSnapshot(agentId: string, context: ContextState, no
412
417
  pruneContextSnapshots(agentId, now - DAY_MS);
413
418
  }
414
419
 
420
+ function mergeRelayContextMarkers(agentId: string, next: ContextState | undefined): ContextState | undefined {
421
+ if (!next) return undefined;
422
+ const row = getDb().query("SELECT context_state FROM agents WHERE id = ?").get(agentId) as { context_state?: string | null } | undefined;
423
+ const previous = row?.context_state ? parseJson<ContextState | undefined>(row.context_state, undefined) : undefined;
424
+ if (!previous) return next;
425
+ return {
426
+ ...next,
427
+ lastContextAdvisoryBand: next.lastContextAdvisoryBand ?? previous.lastContextAdvisoryBand,
428
+ lastContextAdvisoryAt: next.lastContextAdvisoryAt ?? previous.lastContextAdvisoryAt,
429
+ };
430
+ }
431
+
415
432
 
416
433
  export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
417
434
  const now = Date.now();
@@ -548,4 +565,3 @@ export function runtimeTokenJtisFromMeta(meta: Record<string, unknown>): string[
548
565
  }
549
566
 
550
567
  // --- Tasks ---
551
-
@@ -0,0 +1,179 @@
1
+ import { MAX_BODY_BYTES } from "../config";
2
+ import { getContinuationEnvelope } from "./continuations.ts";
3
+ import { getDb, ValidationError } from "./connection.ts";
4
+
5
+ export interface ContinuationArchive {
6
+ id: number;
7
+ ref: string;
8
+ agentId: string;
9
+ generation: number;
10
+ segment: string;
11
+ createdAt: number;
12
+ }
13
+
14
+ export interface ContinuationArchiveSearchResult extends ContinuationArchive {
15
+ score: number | null;
16
+ searchMode: "fts5" | "like";
17
+ }
18
+
19
+ interface ContinuationArchiveRow {
20
+ id: number;
21
+ agent_id: string;
22
+ generation: number;
23
+ segment: string;
24
+ created_at: number;
25
+ score?: number | null;
26
+ }
27
+
28
+ const MAX_RECALL_LIMIT = 20;
29
+
30
+ export function ensureContinuationArchiveSearchSchema(): void {
31
+ if (process.env.AGENT_RELAY_DISABLE_CONTINUATION_ARCHIVE_FTS === "1") return;
32
+ try {
33
+ getDb().query(`
34
+ CREATE VIRTUAL TABLE IF NOT EXISTS continuation_archive_segments
35
+ USING fts5(segment, content='continuation_archives', content_rowid='id')
36
+ `).run();
37
+ getDb().query(`
38
+ CREATE TRIGGER IF NOT EXISTS continuation_archives_ai AFTER INSERT ON continuation_archives BEGIN
39
+ INSERT INTO continuation_archive_segments(rowid, segment) VALUES (new.id, new.segment);
40
+ END
41
+ `).run();
42
+ getDb().query(`
43
+ CREATE TRIGGER IF NOT EXISTS continuation_archives_ad AFTER DELETE ON continuation_archives BEGIN
44
+ INSERT INTO continuation_archive_segments(continuation_archive_segments, rowid, segment)
45
+ VALUES('delete', old.id, old.segment);
46
+ END
47
+ `).run();
48
+ getDb().query(`
49
+ CREATE TRIGGER IF NOT EXISTS continuation_archives_au AFTER UPDATE ON continuation_archives BEGIN
50
+ INSERT INTO continuation_archive_segments(continuation_archive_segments, rowid, segment)
51
+ VALUES('delete', old.id, old.segment);
52
+ INSERT INTO continuation_archive_segments(rowid, segment) VALUES (new.id, new.segment);
53
+ END
54
+ `).run();
55
+ getDb().query("INSERT INTO continuation_archive_segments(continuation_archive_segments) VALUES('rebuild')").run();
56
+ } catch {
57
+ // FTS5 is expected in bun:sqlite, but recall stays available through LIKE when
58
+ // a custom SQLite build omits it.
59
+ }
60
+ }
61
+
62
+ export function archiveContinuationSegment(input: {
63
+ agentId: string;
64
+ segment: string;
65
+ generation?: number;
66
+ now?: number;
67
+ }): ContinuationArchive {
68
+ const agentId = input.agentId.trim();
69
+ if (!agentId) throw new ValidationError("agentId required");
70
+ const segment = input.segment.trim();
71
+ if (!segment) throw new ValidationError("segment required");
72
+ if (new TextEncoder().encode(segment).byteLength > MAX_BODY_BYTES) throw new ValidationError(`segment exceeds ${MAX_BODY_BYTES} bytes`);
73
+ const generation = input.generation ?? nextContinuationArchiveGeneration(agentId);
74
+ if (!Number.isSafeInteger(generation) || generation < 0) throw new ValidationError("generation must be a non-negative integer");
75
+ const createdAt = input.now ?? Date.now();
76
+ const info = getDb().query(`
77
+ INSERT INTO continuation_archives (agent_id, generation, segment, created_at)
78
+ VALUES (?, ?, ?, ?)
79
+ `).run(agentId, generation, segment, createdAt);
80
+ return getContinuationArchive(Number(info.lastInsertRowid))!;
81
+ }
82
+
83
+ export function getContinuationArchive(id: number): ContinuationArchive | null {
84
+ const row = getDb().query("SELECT * FROM continuation_archives WHERE id = ?").get(id) as ContinuationArchiveRow | undefined;
85
+ return row ? rowToArchive(row) : null;
86
+ }
87
+
88
+ export function searchContinuationArchives(input: {
89
+ agentId: string;
90
+ query: string;
91
+ limit?: number;
92
+ }): ContinuationArchiveSearchResult[] {
93
+ const agentId = input.agentId.trim();
94
+ if (!agentId) throw new ValidationError("agentId required");
95
+ const query = input.query.trim();
96
+ if (!query) throw new ValidationError("query required");
97
+ const limit = clampLimit(input.limit);
98
+ const fts = ftsQuery(query);
99
+ if (continuationArchiveSearchMode() === "fts5" && fts) {
100
+ try {
101
+ const rows = getDb().query(`
102
+ SELECT a.*, bm25(continuation_archive_segments) AS score
103
+ FROM continuation_archive_segments
104
+ JOIN continuation_archives a ON a.id = continuation_archive_segments.rowid
105
+ WHERE continuation_archive_segments MATCH ? AND a.agent_id = ?
106
+ ORDER BY score ASC, a.created_at DESC, a.id DESC
107
+ LIMIT ?
108
+ `).all(fts, agentId, limit) as ContinuationArchiveRow[];
109
+ return rows.map((row) => ({ ...rowToArchive(row), score: row.score ?? null, searchMode: "fts5" as const }));
110
+ } catch {
111
+ // Bad MATCH syntax should not make recall fail; the LIKE fallback is less
112
+ // precise but deterministic.
113
+ }
114
+ }
115
+ return searchContinuationArchivesLike(agentId, query, limit);
116
+ }
117
+
118
+ export function continuationArchiveSearchMode(): "fts5" | "like" {
119
+ if (process.env.AGENT_RELAY_DISABLE_CONTINUATION_ARCHIVE_FTS === "1") return "like";
120
+ const row = getDb().query(`
121
+ SELECT 1 FROM sqlite_master
122
+ WHERE type = 'table' AND name = 'continuation_archive_segments'
123
+ `).get();
124
+ return row ? "fts5" : "like";
125
+ }
126
+
127
+ function nextContinuationArchiveGeneration(agentId: string): number {
128
+ const row = getDb().query(`
129
+ SELECT max(generation) AS generation FROM continuation_archives WHERE agent_id = ?
130
+ `).get(agentId) as { generation: number | null } | undefined;
131
+ const archiveGeneration = typeof row?.generation === "number" ? row.generation : 0;
132
+ const envelopeGeneration = getContinuationEnvelope(agentId)?.generation ?? 0;
133
+ return Math.max(archiveGeneration, envelopeGeneration) + 1;
134
+ }
135
+
136
+ function searchContinuationArchivesLike(agentId: string, query: string, limit: number): ContinuationArchiveSearchResult[] {
137
+ const terms = likeTerms(query);
138
+ if (terms.length === 0) return [];
139
+ const clauses = terms.map(() => "lower(segment) LIKE ? ESCAPE '\\'").join(" AND ");
140
+ const rows = getDb().query(`
141
+ SELECT *, NULL AS score FROM continuation_archives
142
+ WHERE agent_id = ? AND ${clauses}
143
+ ORDER BY created_at DESC, id DESC
144
+ LIMIT ?
145
+ `).all(agentId, ...terms.map((term) => `%${escapeLike(term)}%`), limit) as ContinuationArchiveRow[];
146
+ return rows.map((row) => ({ ...rowToArchive(row), score: null, searchMode: "like" as const }));
147
+ }
148
+
149
+ function clampLimit(value: number | undefined): number {
150
+ if (value === undefined) return 5;
151
+ if (!Number.isSafeInteger(value) || value <= 0) throw new ValidationError("limit must be a positive integer");
152
+ return Math.min(value, MAX_RECALL_LIMIT);
153
+ }
154
+
155
+ function ftsQuery(query: string): string {
156
+ return likeTerms(query)
157
+ .slice(0, 16)
158
+ .map((term) => `"${term.replace(/"/g, '""')}"`)
159
+ .join(" ");
160
+ }
161
+
162
+ function likeTerms(query: string): string[] {
163
+ return [...new Set((query.toLowerCase().match(/[\p{L}\p{N}_-]+/gu) ?? []).filter(Boolean))];
164
+ }
165
+
166
+ function escapeLike(value: string): string {
167
+ return value.replace(/[\\%_]/g, (match) => `\\${match}`);
168
+ }
169
+
170
+ function rowToArchive(row: ContinuationArchiveRow): ContinuationArchive {
171
+ return {
172
+ id: row.id,
173
+ ref: String(row.id),
174
+ agentId: row.agent_id,
175
+ generation: row.generation,
176
+ segment: row.segment,
177
+ createdAt: row.created_at,
178
+ };
179
+ }