letagents 0.12.19 → 0.12.21

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.
@@ -0,0 +1,9 @@
1
+ import type { SqliteRoutingDatabase } from "./sqlite-thread-routing.mjs";
2
+ export type LocalSupervisedMessageRow = {
3
+ room_id: string; number: number; sender: string; text: string; source: string | null;
4
+ publisher_agent_key: string | null; thread_root_number: number | null; reply_to_number: number | null;
5
+ control_authorized: number | null; timestamp: string; sync_key: string | null;
6
+ };
7
+ export function ensureLocalSupervisedRoutingSchema(db: SqliteRoutingDatabase): void;
8
+ export function captureLocalSupervisedRouting(db: SqliteRoutingDatabase, row: LocalSupervisedMessageRow): void;
9
+ export function runLocalSupervisedMessageWrite<T>(db: SqliteRoutingDatabase, roomId: string, threadRootNumber: number | null, work: () => T): Promise<T>;
@@ -0,0 +1,93 @@
1
+ import { createGlobalAgentAddressResolver, decideAgentMessageActivation, humanConversationFallback } from "./activation-routing.mjs";
2
+ import { readProjectedLocalThreadRoutingAgentKeys, ensureRequestedRootsProjected, runLocalSqliteWriteTransactionAsync, LocalThreadRoutingProjectionChangedError } from "./sqlite-thread-routing.mjs";
3
+ import { parseSupervisedReplySourceNumber } from "./message-contracts.mjs";
4
+ export function ensureLocalSupervisedRoutingSchema(db) {
5
+ db.exec(`CREATE TABLE IF NOT EXISTS local_supervisor_message_routes (
6
+ room_id TEXT NOT NULL, message_id TEXT NOT NULL, routes_json TEXT NOT NULL,
7
+ PRIMARY KEY(room_id, message_id)
8
+ ) STRICT`);
9
+ }
10
+ function recentRecipient(db, message) {
11
+ const recent = db.prepare(`SELECT * FROM local_chat_messages WHERE room_id=? AND number<?
12
+ ORDER BY number DESC LIMIT 50`).all(message.room_id, message.number)
13
+ .filter(row => Date.parse(String(row.timestamp)) >= Date.parse(message.timestamp) - 30 * 60_000);
14
+ const human = recent.find(row => row.source === "browser" && !row.publisher_agent_key
15
+ && row.control_authorized === 1 && row.thread_root_number === null);
16
+ if (!human)
17
+ return null;
18
+ const captured = db.prepare("SELECT routes_json FROM local_supervisor_message_routes WHERE room_id=? AND message_id=?")
19
+ .get(message.room_id, `msg_${human.number}`);
20
+ if (!captured)
21
+ return null;
22
+ const recipients = Object.entries(JSON.parse(String(captured.routes_json)))
23
+ .filter(([, route]) => route.decision === "activate").map(([key]) => key);
24
+ if (recipients.length === 1)
25
+ return recipients[0];
26
+ const answered = new Set();
27
+ for (const reply of recent) {
28
+ const key = String(reply.publisher_agent_key || "");
29
+ const prefix = `local-supervised:${key}:`;
30
+ const receipt = String(reply.sync_key || "");
31
+ if (reply.source === "agent" && Number(reply.number) > Number(human.number)
32
+ && recipients.includes(key) && (reply.reply_to_number === human.number
33
+ || (receipt.startsWith(prefix)
34
+ && parseSupervisedReplySourceNumber(receipt.slice(prefix.length)) === human.number)))
35
+ answered.add(key);
36
+ }
37
+ return answered.size === 1 ? [...answered][0] : null;
38
+ }
39
+ /** Capture all recipients atomically with the message, including the empty-room case. */
40
+ export function captureLocalSupervisedRouting(db, row) {
41
+ const reply = row.reply_to_number ? db.prepare("SELECT * FROM local_chat_messages WHERE room_id=? AND number=?")
42
+ .get(row.room_id, row.reply_to_number) : null;
43
+ const registered = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='local_supervisor_grants'").get();
44
+ const identities = registered
45
+ ? db.prepare("SELECT agent_key,display_name FROM local_supervisor_grants WHERE room_id=? AND revoked_at IS NULL ORDER BY entry_id")
46
+ .all(row.room_id).map(grant => ({ agent_key: String(grant.agent_key), display_name: String(grant.display_name),
47
+ actor_label: String(grant.display_name), agent_instance_id: null, agent_session_id: null, session_kind: "worker" })) : [];
48
+ const id = `msg_${row.number}`;
49
+ const rootId = `msg_${row.thread_root_number ?? row.number}`;
50
+ const threadReply = row.thread_root_number !== null && row.thread_root_number !== row.number;
51
+ const message = { id, text: row.text, sender: row.sender, source: row.source,
52
+ thread_root_id: threadReply ? rootId : null, reply_to: reply ? { id: `msg_${reply.number}`, sender: reply.sender } : null };
53
+ const address = createGlobalAgentAddressResolver(identities)(message);
54
+ const participants = threadReply ? readProjectedLocalThreadRoutingAgentKeys(db, row.room_id, [row.thread_root_number], identities.map(identity => ({ agentKey: identity.agent_key, displayName: identity.display_name, actorLabel: identity.actor_label })))
55
+ .get(row.thread_root_number) ?? new Set() : new Set();
56
+ const fallback = humanConversationFallback({ source: row.source,
57
+ publisherAccountId: row.control_authorized === 1 ? "local-owner" : null, publisherAgentKey: row.publisher_agent_key,
58
+ explicitlyAddressed: address.broadcast || address.hasAgentMention || threadReply || Boolean(reply),
59
+ registeredAgentKeys: identities.map(identity => identity.agent_key),
60
+ recentAgentKey: identities.length > 2 ? recentRecipient(db, row) : null });
61
+ const routes = {};
62
+ for (const identity of identities) {
63
+ const key = identity.agent_key;
64
+ let decision = decideAgentMessageActivation(message, identity, {
65
+ selfMessageIds: new Set(row.source === "agent" && row.publisher_agent_key === key ? [id] : []),
66
+ explicitMentionMessageIds: new Set(address.explicitMentionKeys.has(key) ? [id] : []),
67
+ replyTargetMessageIds: new Set(!threadReply && (reply?.publisher_agent_key
68
+ ? reply.publisher_agent_key === key : address.replyTargetKeys.has(key)) ? [id] : []),
69
+ threadParticipantRootIds: new Set(participants.has(key) ? [rootId] : []),
70
+ });
71
+ if (row.source === "agent" && !row.publisher_agent_key)
72
+ decision = { decision: "silent", reason: "unaddressed", addressed: false };
73
+ if (decision.reason === "unaddressed" && fallback?.agentKeys.includes(key)) {
74
+ decision = { decision: "activate", reason: fallback.reason, addressed: true };
75
+ }
76
+ routes[key] = decision;
77
+ }
78
+ db.prepare("INSERT INTO local_supervisor_message_routes(room_id,message_id,routes_json) VALUES(?,?,?)")
79
+ .run(row.room_id, id, JSON.stringify(routes));
80
+ }
81
+
82
+ /** Repair outside the write lock; a concurrent invalidation rolls back the entire send. */
83
+ export async function runLocalSupervisedMessageWrite(db, roomId, threadRootNumber, work) {
84
+ const deadline = performance.now() + 2_000;
85
+ for (;;) {
86
+ if (threadRootNumber) await ensureRequestedRootsProjected(db, roomId, [threadRootNumber]);
87
+ try {
88
+ return await runLocalSqliteWriteTransactionAsync(db, work);
89
+ } catch (error) {
90
+ if (!(error instanceof LocalThreadRoutingProjectionChangedError) || performance.now() >= deadline) throw error;
91
+ }
92
+ }
93
+ }
@@ -30,3 +30,5 @@ export type ParsedAccountAgentRouting =
30
30
  export function parseAccountAgentRoutingEnvelope(
31
31
  routing: unknown,
32
32
  ): ParsedAccountAgentRouting | undefined;
33
+
34
+ export function parseSupervisedReplySourceNumber(clientMessageId: string | null): number | null;
@@ -107,3 +107,14 @@ export function parseAccountAgentRoutingEnvelope(routing) {
107
107
  controlAuthorized: routing.control_authorized === true,
108
108
  };
109
109
  }
110
+
111
+ /** Only daemon publication identities can link an answer to its source receipt. */
112
+ export function parseSupervisedReplySourceNumber(clientMessageId) {
113
+ if (!clientMessageId) return null;
114
+ const parts = clientMessageId.split(":");
115
+ if (parts[0] !== "supervised-room" || parts.at(-2) !== "reply" || parts.at(-1) !== "v1") return null;
116
+ const body = parts.slice(1, -2);
117
+ if (body.length !== 2 && body.length !== 3) return null;
118
+ const source = body.at(-1);
119
+ return source ? parsePositivePgIntegerScopedId(source, "msg") : null;
120
+ }
@@ -0,0 +1,3 @@
1
+ export const LOCAL_ROOM_API_ORIGIN: "letagents-local://rooms";
2
+ export function isLocalRoomApi(value: unknown): boolean;
3
+ export function roomApiOrigin(value: string): string;
@@ -0,0 +1,12 @@
1
+ /** An in-process storage adapter, never an HTTP endpoint or cloud credential audience. */
2
+ export const LOCAL_ROOM_API_ORIGIN = "letagents-local://rooms";
3
+
4
+ export function isLocalRoomApi(value) {
5
+ return value === LOCAL_ROOM_API_ORIGIN;
6
+ }
7
+
8
+ /** Preserve the existing HTTP normalization while retaining the explicit local authority. */
9
+ export function roomApiOrigin(value) {
10
+ if (isLocalRoomApi(value)) return LOCAL_ROOM_API_ORIGIN;
11
+ return new URL(value).origin;
12
+ }
@@ -0,0 +1,25 @@
1
+ export type KnowledgeType = 'memory' | 'attention';
2
+ export type MemoryCategory = 'goal' | 'decision' | 'constraint' | 'term' | 'reference';
3
+ export type AttentionCategory = 'question' | 'decision' | 'approval' | 'review';
4
+ export interface KnowledgeActor { id: string; label: string; kind: 'human' | 'agent' }
5
+ export interface KnowledgeInput {
6
+ category: MemoryCategory | AttentionCategory; title: string; body: string;
7
+ recommendation?: string; unblocks?: string; source_url?: string; source_message_id?: string;
8
+ }
9
+ export interface KnowledgeRecord extends Required<KnowledgeInput> {
10
+ id: string; room_id: string; type: KnowledgeType; version: number;
11
+ author: KnowledgeActor; updated_by: KnowledgeActor; created_at: string; updated_at: string; archived: boolean;
12
+ response: { body: string; actor: KnowledgeActor; at: string } | null;
13
+ }
14
+ export interface KnowledgePage { records: KnowledgeRecord[]; truncated: boolean }
15
+ export interface KnowledgeRevisionInput extends Partial<KnowledgeInput> { expected_version: number; response?: string; archived?: boolean }
16
+ export const MEMORY_CATEGORIES: MemoryCategory[];
17
+ export const ATTENTION_CATEGORIES: AttentionCategory[];
18
+ export class RoomKnowledgeError extends Error { status: number; constructor(message: string, status?: number) }
19
+ export function parseKnowledgeInput(type: KnowledgeType, value: unknown): Required<KnowledgeInput>;
20
+ export function knowledgeId(value: unknown): string;
21
+ export function createKnowledgeRecord(room_id: string, type: KnowledgeType, input: KnowledgeInput & { client_id: string }, author: KnowledgeActor, now?: string): KnowledgeRecord;
22
+ export function reviseKnowledgeRecord(record: KnowledgeRecord, input: KnowledgeRevisionInput, actor: KnowledgeActor, now?: string): KnowledgeRecord;
23
+ export function assertKnowledgeReplay(existing: KnowledgeRecord, candidate: KnowledgeRecord): void;
24
+
25
+ export function formatAttentionResponse(record: KnowledgeRecord): string;
@@ -0,0 +1,74 @@
1
+ export const MEMORY_CATEGORIES = ['goal', 'decision', 'constraint', 'term', 'reference'];
2
+ export const ATTENTION_CATEGORIES = ['question', 'decision', 'approval', 'review'];
3
+
4
+ export class RoomKnowledgeError extends Error {
5
+ constructor(message, status = 400) { super(message); this.status = status; }
6
+ }
7
+
8
+ function text(value, name, max, required = false) {
9
+ if (value == null && !required) return '';
10
+ if (typeof value !== 'string' || value.length > max || (required && !value.trim())) {
11
+ throw new RoomKnowledgeError(`${name} must be ${required ? 'nonempty text' : 'text'} of at most ${max} characters.`);
12
+ }
13
+ return value.trim();
14
+ }
15
+
16
+ export function parseKnowledgeInput(type, value) {
17
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RoomKnowledgeError('A record is required.');
18
+ const categories = type === 'memory' ? MEMORY_CATEGORIES : type === 'attention' ? ATTENTION_CATEGORIES : [];
19
+ if (!categories.includes(value.category)) throw new RoomKnowledgeError('Choose a valid category.');
20
+ const source_url = text(value.source_url, 'Source URL', 2048);
21
+ if (source_url) {
22
+ let url;
23
+ try { url = new URL(source_url); } catch { throw new RoomKnowledgeError('Source URL must be an HTTP or HTTPS link.'); }
24
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new RoomKnowledgeError('Source URL must be an HTTP or HTTPS link without credentials.');
25
+ }
26
+ const source_message_id = text(value.source_message_id, 'Source message', 40);
27
+ if (source_message_id && (!/^msg_[1-9]\d{0,9}$/.test(source_message_id) || Number(source_message_id.slice(4)) > 2147483647)) throw new RoomKnowledgeError('Invalid source message.');
28
+ return {
29
+ category: value.category,
30
+ title: text(value.title, 'Title', 160, true),
31
+ body: text(value.body, 'Context', 8000, true),
32
+ recommendation: type === 'attention' ? text(value.recommendation, 'Recommendation', 2000) : '',
33
+ unblocks: type === 'attention' ? text(value.unblocks, 'What this unblocks', 1000) : '',
34
+ source_url,
35
+ source_message_id,
36
+ };
37
+ }
38
+
39
+ export function knowledgeId(value) {
40
+ if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{8,80}$/.test(value)) throw new RoomKnowledgeError('Use a stable client ID of 8–80 letters, numbers, underscores or hyphens.');
41
+ return value;
42
+ }
43
+
44
+ export function createKnowledgeRecord(room_id, type, input, author, now = new Date().toISOString()) {
45
+ return { ...parseKnowledgeInput(type, input), id: knowledgeId(input.client_id), room_id, type,
46
+ version: 1, author, updated_by: author, created_at: now, updated_at: now,
47
+ archived: false, response: null };
48
+ }
49
+
50
+ export function reviseKnowledgeRecord(record, input, actor, now = new Date().toISOString()) {
51
+ if (input.expected_version !== record.version) throw new RoomKnowledgeError('This changed since you opened it. Refresh to see the latest version.', 409);
52
+ if (actor.kind !== 'human') throw new RoomKnowledgeError('Only a human can revise shared memory or answer a request.', 403);
53
+ if (record.type === 'attention') {
54
+ if (record.response) throw new RoomKnowledgeError('This request has already been answered.', 409);
55
+ return { ...record, version: record.version + 1, updated_at: now, updated_by: actor,
56
+ response: { body: text(input.response, 'Response', 8000, true), actor, at: now } };
57
+ }
58
+ if (input.archived !== undefined && typeof input.archived !== 'boolean') throw new RoomKnowledgeError('Archived must be true or false.');
59
+ const content = input.archived !== undefined ? {} : parseKnowledgeInput('memory', input);
60
+ return { ...record, ...content, archived: input.archived ?? record.archived,
61
+ version: record.version + 1, updated_at: now, updated_by: actor };
62
+ }
63
+
64
+ export function assertKnowledgeReplay(existing, candidate) {
65
+ const keys = ['type', 'category', 'title', 'body', 'recommendation', 'unblocks', 'source_url', 'source_message_id'];
66
+ if (existing.author.id !== candidate.author.id || existing.author.kind !== candidate.author.kind || keys.some(key => existing[key] !== candidate[key])) {
67
+ throw new RoomKnowledgeError('This client ID was already used for different content. Use a new ID for a new record.', 409);
68
+ }
69
+ }
70
+
71
+ export function formatAttentionResponse(record) {
72
+ const handle = record.author.kind === 'agent' ? record.author.id.trim().replace(/[A-Z]/g, c => c.toLowerCase()).replace(/[^a-z0-9_.:/-]+/g, '') : '';
73
+ return `${handle ? `@agent:${handle}\n\n` : ''}Human response (${record.id}):\n\n${record.response.body}`;
74
+ }
@@ -70,3 +70,7 @@ export function getLocalThreadRoutingAgentKeysForRoots(
70
70
  signal?: AbortSignal;
71
71
  },
72
72
  ): Promise<Map<number, Set<string>>>;
73
+
74
+ export function ensureRequestedRootsProjected(database: SqliteRoutingDatabase, roomId: string, rootNumbers: readonly number[], options?: { foregroundTimeBudgetMs?: number; scheduleOnTimeout?: boolean; signal?: AbortSignal }): Promise<void>;
75
+ export function readProjectedLocalThreadRoutingAgentKeys(database: SqliteRoutingDatabase, roomId: string, rootNumbers: readonly number[], identities: readonly RoutingIdentityLike[]): Map<number, Set<string>>;
76
+ export class LocalThreadRoutingProjectionChangedError extends LocalThreadRoutingProjectionUnavailableError {}
@@ -788,7 +788,7 @@ export function invalidateLocalThreadRoutingRoots(database, roomId, rootNumbersI
788
788
  }
789
789
  }
790
790
 
791
- async function ensureRequestedRootsProjected(database, roomId, rootNumbers, options = {}) {
791
+ export async function ensureRequestedRootsProjected(database, roomId, rootNumbers, options = {}) {
792
792
  if (rootNumbers.length > LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) {
793
793
  throw new LocalThreadRoutingProjectionUnavailableError();
794
794
  }
@@ -869,6 +869,33 @@ export async function getLocalThreadRoutingAgentKeysForRoots(
869
869
  if (rootNumbers.length === 0 || identities.length === 0) return new Map();
870
870
  await ensureRequestedRootsProjected(database, roomId, rootNumbers, options);
871
871
 
872
+ const batches = readProjectedThreadRoutingBatches(database, roomId, rootNumbers, identities);
873
+ for (;;) {
874
+ const step = batches.next();
875
+ if (step.done) return step.value;
876
+ await yieldToEventLoop();
877
+ }
878
+ }
879
+
880
+ /** Read prepared projections synchronously inside an existing message transaction. */
881
+ export function readProjectedLocalThreadRoutingAgentKeys(database, roomId, rootNumbers, identities) {
882
+ const statements = requestedRootProjectionStatements(database);
883
+ const rootsJson = JSON.stringify(rootNumbers);
884
+ if (pendingRequestedRoots(statements, roomId, rootsJson).length
885
+ || statements.invalidated.all(roomId, rootsJson).length) {
886
+ throw new LocalThreadRoutingProjectionChangedError();
887
+ }
888
+ const batches = readProjectedThreadRoutingBatches(database, roomId, rootNumbers, identities);
889
+ for (;;) {
890
+ const step = batches.next();
891
+ if (step.done) return step.value;
892
+ }
893
+ }
894
+
895
+ export class LocalThreadRoutingProjectionChangedError extends LocalThreadRoutingProjectionUnavailableError {}
896
+
897
+ function* readProjectedThreadRoutingBatches(database, roomId, rootNumbers, identities) {
898
+ if (!rootNumbers.length || !identities.length) return new Map();
872
899
  const keysByHash = new Map();
873
900
  const durableKeysByHash = new Map();
874
901
  for (const identity of identities) {
@@ -938,7 +965,7 @@ export async function getLocalThreadRoutingAgentKeysForRoots(
938
965
  keys.add(agentKey);
939
966
  result.set(root, keys);
940
967
  }
941
- await yieldToEventLoop();
968
+ yield;
942
969
  }
943
970
 
944
971
  const aliasInputs = [];
@@ -1032,7 +1059,7 @@ export async function getLocalThreadRoutingAgentKeysForRoots(
1032
1059
  keys.add(agentKey);
1033
1060
  result.set(root, keys);
1034
1061
  }
1035
- await yieldToEventLoop();
1062
+ yield;
1036
1063
  }
1037
1064
  return result;
1038
1065
  }