auggy 0.3.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.
Files changed (121) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/README.md +161 -0
  4. package/package.json +76 -0
  5. package/src/agent-card.ts +39 -0
  6. package/src/agent.ts +283 -0
  7. package/src/agentmail-client.ts +138 -0
  8. package/src/augments/bash/index.ts +463 -0
  9. package/src/augments/bash/skill/SKILL.md +156 -0
  10. package/src/augments/budgets/budget-store.ts +513 -0
  11. package/src/augments/budgets/index.ts +134 -0
  12. package/src/augments/budgets/preamble.ts +93 -0
  13. package/src/augments/budgets/types.ts +89 -0
  14. package/src/augments/file-memory/index.ts +71 -0
  15. package/src/augments/filesystem/index.ts +533 -0
  16. package/src/augments/filesystem/skill/SKILL.md +142 -0
  17. package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
  18. package/src/augments/layered-memory/extractor/buffer.ts +56 -0
  19. package/src/augments/layered-memory/extractor/frequency.ts +79 -0
  20. package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
  21. package/src/augments/layered-memory/extractor/parse.ts +75 -0
  22. package/src/augments/layered-memory/extractor/prompt.md +26 -0
  23. package/src/augments/layered-memory/index.ts +757 -0
  24. package/src/augments/layered-memory/skill/SKILL.md +153 -0
  25. package/src/augments/layered-memory/storage/migrations/README.md +16 -0
  26. package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
  27. package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
  28. package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
  29. package/src/augments/layered-memory/storage/types.ts +98 -0
  30. package/src/augments/link/index.ts +489 -0
  31. package/src/augments/link/translate.ts +261 -0
  32. package/src/augments/notify/adapters/agentmail.ts +70 -0
  33. package/src/augments/notify/adapters/telegram.ts +60 -0
  34. package/src/augments/notify/adapters/webhook.ts +55 -0
  35. package/src/augments/notify/index.ts +284 -0
  36. package/src/augments/notify/skill/SKILL.md +150 -0
  37. package/src/augments/org-context/index.ts +721 -0
  38. package/src/augments/org-context/skill/SKILL.md +96 -0
  39. package/src/augments/skills/index.ts +103 -0
  40. package/src/augments/supabase-memory/index.ts +151 -0
  41. package/src/augments/telegram-transport/index.ts +312 -0
  42. package/src/augments/telegram-transport/polling.ts +55 -0
  43. package/src/augments/telegram-transport/webhook.ts +56 -0
  44. package/src/augments/turn-control/index.ts +61 -0
  45. package/src/augments/turn-control/skill/SKILL.md +155 -0
  46. package/src/augments/visitor-auth/email-validation.ts +66 -0
  47. package/src/augments/visitor-auth/index.ts +779 -0
  48. package/src/augments/visitor-auth/rate-limiter.ts +90 -0
  49. package/src/augments/visitor-auth/skill/SKILL.md +55 -0
  50. package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
  51. package/src/augments/visitor-auth/storage/types.ts +164 -0
  52. package/src/augments/visitor-auth/types.ts +123 -0
  53. package/src/augments/visitor-auth/verify-page.ts +179 -0
  54. package/src/augments/web-fetch/index.ts +331 -0
  55. package/src/augments/web-fetch/skill/SKILL.md +100 -0
  56. package/src/cli/agent-index.ts +289 -0
  57. package/src/cli/augment-catalog.ts +320 -0
  58. package/src/cli/augment-resolver.ts +597 -0
  59. package/src/cli/commands/add-skill.ts +194 -0
  60. package/src/cli/commands/add.ts +87 -0
  61. package/src/cli/commands/chat.ts +207 -0
  62. package/src/cli/commands/create.ts +462 -0
  63. package/src/cli/commands/dev.ts +139 -0
  64. package/src/cli/commands/eval.ts +180 -0
  65. package/src/cli/commands/ls.ts +66 -0
  66. package/src/cli/commands/remove.ts +95 -0
  67. package/src/cli/commands/restart.ts +40 -0
  68. package/src/cli/commands/start.ts +123 -0
  69. package/src/cli/commands/status.ts +104 -0
  70. package/src/cli/commands/stop.ts +84 -0
  71. package/src/cli/commands/visitors-revoke.ts +155 -0
  72. package/src/cli/commands/visitors.ts +101 -0
  73. package/src/cli/config-parser.ts +1034 -0
  74. package/src/cli/engine-resolver.ts +68 -0
  75. package/src/cli/index.ts +178 -0
  76. package/src/cli/model-picker.ts +89 -0
  77. package/src/cli/pid-registry.ts +146 -0
  78. package/src/cli/plist-generator.ts +117 -0
  79. package/src/cli/resolve-config.ts +56 -0
  80. package/src/cli/scaffold-skills.ts +158 -0
  81. package/src/cli/scaffold.ts +291 -0
  82. package/src/cli/skill-frontmatter.ts +51 -0
  83. package/src/cli/skill-validator.ts +151 -0
  84. package/src/cli/types.ts +228 -0
  85. package/src/cli/yaml-helpers.ts +66 -0
  86. package/src/engines/_shared/cost.ts +55 -0
  87. package/src/engines/_shared/schema-normalize.ts +75 -0
  88. package/src/engines/anthropic/pricing.ts +117 -0
  89. package/src/engines/anthropic.ts +483 -0
  90. package/src/engines/openai/pricing.ts +67 -0
  91. package/src/engines/openai.ts +446 -0
  92. package/src/engines/openrouter/pricing.ts +83 -0
  93. package/src/engines/openrouter.ts +185 -0
  94. package/src/helpers.ts +24 -0
  95. package/src/http.ts +387 -0
  96. package/src/index.ts +165 -0
  97. package/src/kernel/capability-table.ts +172 -0
  98. package/src/kernel/context-allocator.ts +161 -0
  99. package/src/kernel/history-manager.ts +198 -0
  100. package/src/kernel/lifecycle-manager.ts +106 -0
  101. package/src/kernel/output-validator.ts +35 -0
  102. package/src/kernel/preamble.ts +23 -0
  103. package/src/kernel/route-collector.ts +97 -0
  104. package/src/kernel/timeout.ts +21 -0
  105. package/src/kernel/tool-selector.ts +47 -0
  106. package/src/kernel/trace-emitter.ts +66 -0
  107. package/src/kernel/transport-queue.ts +147 -0
  108. package/src/kernel/turn-loop.ts +1148 -0
  109. package/src/memory/context-synthesis.ts +83 -0
  110. package/src/memory/memory-bus.ts +61 -0
  111. package/src/memory/registry.ts +80 -0
  112. package/src/memory/tools.ts +320 -0
  113. package/src/memory/types.ts +8 -0
  114. package/src/parts.ts +30 -0
  115. package/src/scaffold-templates/identity.md +31 -0
  116. package/src/telegram-client.ts +145 -0
  117. package/src/tokenizer.ts +14 -0
  118. package/src/transports/ag-ui-events.ts +253 -0
  119. package/src/transports/visitor-token.ts +82 -0
  120. package/src/transports/web-transport.ts +948 -0
  121. package/src/types.ts +1009 -0
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: org-context
3
+ description: When and how to use org_fetch to retrieve knowledge from the operator's organization. Read this before answering questions about the org or its work.
4
+ ---
5
+
6
+ # Org context
7
+
8
+ You are connected to your operator's **organization knowledge base**. Your system context already includes a manifest — a list of endpoints the org exposes (paths like `/mission`, `/team`, `/projects`). Use `org_fetch` to pull the actual content from any of those endpoints when the conversation needs it.
9
+
10
+ The manifest is small (~200 tokens, always loaded). The endpoint contents are larger and load only when you fetch them. This is progressive disclosure — you don't pay the token cost of every doc on every turn, only the ones the conversation calls for.
11
+
12
+ ## Tools
13
+
14
+ | Tool | What it does |
15
+ |------|--------------|
16
+ | `org_fetch(endpoint)` | Retrieve the content of one endpoint listed in the org context manifest |
17
+
18
+ Only `org_fetch` is exposed here. If you need to alert the operator out-of-band about something (escalate a request you can't handle, flag urgent input, ask for human approval), that's a separate capability — see the `notify` skill if it's mounted in this agent. Don't try to use `org_fetch` for it.
19
+
20
+ ## How to call it
21
+
22
+ ```
23
+ org_fetch({ endpoint: "/mission" })
24
+ ```
25
+
26
+ Or with an optional prompt that the tool can pass through to influence what comes back:
27
+
28
+ ```
29
+ org_fetch({ endpoint: "/projects", prompt: "anything related to onboarding" })
30
+ ```
31
+
32
+ **Parameters:**
33
+ - `endpoint` — the path from the manifest (leading slash optional; the tool normalizes)
34
+ - `prompt` (optional) — a hint about what you're looking for; passed through with the response for your own reference
35
+
36
+ ## What it returns
37
+
38
+ A JSON envelope. For endpoints whose response is a structured `{ files: [...] }` payload, the tool flattens the files into a single combined-content string with section headers per file:
39
+
40
+ ```
41
+ {
42
+ "endpoint": "/mission",
43
+ "fileCount": 1,
44
+ "content": "## mission.md\n\n<contents>\n\n---\n\n..."
45
+ }
46
+ ```
47
+
48
+ For other shapes, you get the raw body (up to ~20K chars) under `content`. Long responses are truncated with a `[truncated — N total chars]` marker so you know more exists.
49
+
50
+ ## When to call it
51
+
52
+ | Situation | Endpoint to try |
53
+ |-----------|-----------------|
54
+ | Peer asks what your organization does | The manifest's `org` and `purpose` fields are already in context — you may already have enough |
55
+ | Peer asks about a specific topic (mission, team, projects, decisions) | Find the matching endpoint in the manifest and `org_fetch` it |
56
+ | Peer asks for details that the manifest summary doesn't cover | Fetch the relevant endpoint |
57
+ | You're about to make a claim about the org and aren't sure | Fetch and verify before answering |
58
+
59
+ The manifest in your system context lists the endpoints available — read it before calling `org_fetch` so you choose the right one. If the manifest doesn't list an endpoint covering what the peer wants, say so directly rather than guessing at paths that may not exist.
60
+
61
+ ## When NOT to call it
62
+
63
+ - The peer asked something that has nothing to do with the operator's organization. Use other tools (or no tool) instead.
64
+ - You already fetched the endpoint earlier in the same conversation and the content is still in context. Re-fetching doesn't add freshness — it just consumes tokens.
65
+ - The manifest already contains everything the peer asked about (the org name, the purpose, the operator's name if exposed).
66
+ - You're tempted to fetch every endpoint "just in case." Fetch only what's relevant to the current question.
67
+
68
+ ## What you cannot do
69
+
70
+ - You cannot fetch endpoints not listed in the manifest. The org chose what to expose.
71
+ - You cannot write to or modify org knowledge through this tool — it's read-only.
72
+ - You cannot use `org_fetch` to reach arbitrary URLs on the public web — for that, use the `web_fetch` tool if it's mounted.
73
+
74
+ ## Common mistakes
75
+
76
+ | Wrong | Correct |
77
+ |-------|---------|
78
+ | Saying "I don't know what this organization is" | Check the manifest already in your context; `org_fetch` a relevant endpoint if details are needed |
79
+ | Fetching every endpoint at the start of a turn | Fetch only what the current question calls for |
80
+ | Inventing endpoint paths the manifest doesn't list | Use only paths from the manifest; if nothing fits, say so |
81
+ | Re-fetching the same endpoint multiple times in one turn | Fetch once; the content stays in your context for the rest of the turn |
82
+ | Using `org_fetch` for general web pages | Use `web_fetch` for arbitrary URLs; `org_fetch` is scoped to the operator's org API |
83
+ | Quoting fetched org docs verbatim back at the peer | Synthesize and answer in your own voice; cite the endpoint if the peer wants the source |
84
+
85
+ ## Workflow
86
+
87
+ ### Peer asks an org-specific question
88
+
89
+ 1. Glance at the manifest already in your context — does it cover the answer at the summary level?
90
+ 2. If yes, answer from the summary. If no, find the most relevant endpoint and `org_fetch` it.
91
+ 3. Read the returned content; answer in your own words.
92
+ 4. If the peer wants more detail than that endpoint provides, look for another endpoint in the manifest or be honest about the limit.
93
+
94
+ ### The org API is unreachable
95
+
96
+ If the manifest is unavailable (boot-time fetch failed, or the manifest's contents are invalid), your system context will not include it and `org_fetch` calls will return an error envelope (typically `{"error": "Org context unavailable: no manifest loaded; cannot validate endpoint allowlist"}` or similar). Surface that honestly to the peer — say the org knowledge base is temporarily unavailable; don't fabricate answers about the org.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Skills augment — emits one context block listing the agent's mounted skills.
3
+ *
4
+ * Per ADR-030 (model-facing skill surface separation): identity stays
5
+ * identity; the kernel surfaces the skill manifest via this augment, sourced
6
+ * from each SKILL.md's YAML frontmatter (agentskills.io standard). Activation
7
+ * is `fs_read` via the filesystem augment; the full SKILL.md body is never
8
+ * boot-loaded.
9
+ *
10
+ * The augment is read-only and emits no tools. On every `context()` call it
11
+ * walks `options.dir`, reading frontmatter from `<dir>/<folder>/SKILL.md`.
12
+ * Subdirectories without a SKILL.md, with unparseable frontmatter, or with
13
+ * missing required fields are silently skipped — the boot-time skill
14
+ * validator (`src/cli/skill-validator.ts`) surfaces those operator-facing.
15
+ *
16
+ * The skill folder name (not the frontmatter `name` field) is the canonical
17
+ * model-facing identifier in the listing, because the model invokes the
18
+ * skill by file path (`fs_read skills/<folder>/SKILL.md`) — using the
19
+ * folder name keeps the listing entry and the read path aligned.
20
+ */
21
+
22
+ import { readdirSync, statSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import type { Augment, ContextBlock } from "../../types";
25
+ import { readSkillFrontmatter, type SkillFrontmatter } from "../../cli/skill-frontmatter";
26
+
27
+ export interface SkillsOptions {
28
+ /**
29
+ * Absolute path to the directory containing skill subfolders. Each subfolder
30
+ * should contain a SKILL.md with `name` + `description` YAML frontmatter.
31
+ *
32
+ * The augment-resolver converts relative paths against the agent dir before
33
+ * construction (same pattern as orgContext's file:// scheme), so the
34
+ * augment factory only ever sees absolute paths.
35
+ */
36
+ dir: string;
37
+ }
38
+
39
+ interface DiscoveredSkill extends SkillFrontmatter {
40
+ folder: string;
41
+ }
42
+
43
+ function discoverSkills(dir: string): DiscoveredSkill[] {
44
+ let entries: string[];
45
+ try {
46
+ entries = readdirSync(dir);
47
+ } catch {
48
+ return [];
49
+ }
50
+
51
+ const out: DiscoveredSkill[] = [];
52
+ for (const folder of entries) {
53
+ const sub = join(dir, folder);
54
+ let isDir = false;
55
+ try {
56
+ isDir = statSync(sub).isDirectory();
57
+ } catch {
58
+ continue;
59
+ }
60
+ if (!isDir) continue;
61
+ const fm = readSkillFrontmatter(join(sub, "SKILL.md"));
62
+ if (fm === null) continue;
63
+ out.push({ folder, name: fm.name, description: fm.description });
64
+ }
65
+
66
+ out.sort((a, b) => a.folder.localeCompare(b.folder));
67
+ return out;
68
+ }
69
+
70
+ function buildBlockContent(discovered: DiscoveredSkill[]): string {
71
+ const lines = [
72
+ "# Skills",
73
+ "",
74
+ "Each skill is a guide stored on disk. Read a guide with `fs_read skills/<folder>/SKILL.md` before using its tools when you need usage detail.",
75
+ "",
76
+ ];
77
+ for (const s of discovered) {
78
+ lines.push(`- ${s.folder} — ${s.description}`);
79
+ }
80
+ return lines.join("\n");
81
+ }
82
+
83
+ export function skills(opts: SkillsOptions): Augment {
84
+ return {
85
+ name: "skills",
86
+ capabilities: ["context"],
87
+ context: async () => {
88
+ const discovered = discoverSkills(opts.dir);
89
+ if (discovered.length === 0) return [];
90
+ const block: ContextBlock = {
91
+ source: "skills",
92
+ content: buildBlockContent(discovered),
93
+ placement: "system",
94
+ priority: "required",
95
+ eviction: "never",
96
+ origin: "operator",
97
+ provenance: "augment",
98
+ ttl: "persistent",
99
+ };
100
+ return [block];
101
+ },
102
+ };
103
+ }
@@ -0,0 +1,151 @@
1
+ import type {
2
+ Augment,
3
+ MemoryEntry,
4
+ ContextOrigin,
5
+ ContextPriority,
6
+ ContextPlacement,
7
+ EvictionPolicy,
8
+ } from "../../types";
9
+
10
+ /**
11
+ * Minimal Supabase client interface used by supabaseMemory. Compatible
12
+ * with @supabase/supabase-js and with the test mock.
13
+ *
14
+ * Terminal nodes in the chain return `PromiseLike` (not `Promise`) so
15
+ * thenable builders — like Supabase's PostgrestBuilder, and our mock —
16
+ * satisfy the type structurally.
17
+ */
18
+ export interface SupabaseLikeClient {
19
+ from(table: string): {
20
+ insert(row: unknown): PromiseLike<{ error: Error | null }>;
21
+ select(columns?: string): {
22
+ eq(
23
+ column: string,
24
+ value: unknown,
25
+ ): {
26
+ maybeSingle(): PromiseLike<{ data: unknown; error: Error | null }>;
27
+ };
28
+ ilike(
29
+ column: string,
30
+ value: string,
31
+ ): {
32
+ order(
33
+ column: string,
34
+ opts?: { ascending?: boolean },
35
+ ): {
36
+ limit(n: number): PromiseLike<{ data: unknown[]; error: Error | null }>;
37
+ };
38
+ };
39
+ };
40
+ };
41
+ }
42
+
43
+ export interface SupabaseMemoryOptions {
44
+ namespace: string;
45
+ client: SupabaseLikeClient;
46
+ table: string;
47
+ mutable: boolean;
48
+ origin: ContextOrigin;
49
+ priority: ContextPriority;
50
+ placement: ContextPlacement;
51
+ eviction: EvictionPolicy;
52
+ searchLimit?: number;
53
+ }
54
+
55
+ /**
56
+ * Namespace-based memory provider backed by a Supabase table.
57
+ * Stores rows with { label, content, metadata?, created_at } and
58
+ * supports recent-or-relevant retrieval via ILIKE + ordering by
59
+ * created_at desc. Intended for episodic memory.
60
+ */
61
+ export function supabaseMemory(opts: SupabaseMemoryOptions): Augment {
62
+ const prefix = opts.namespace.endsWith(":") ? opts.namespace : `${opts.namespace}:`;
63
+ const limit = opts.searchLimit ?? 10;
64
+
65
+ const search = async (query: string): Promise<MemoryEntry[]> => {
66
+ // Escape ILIKE wildcards (% and _) in user input so they're treated
67
+ // as literal characters, not pattern matchers.
68
+ const escaped = query.replace(/[%_]/g, (c) => `\\${c}`);
69
+ const { data, error } = await opts.client
70
+ .from(opts.table)
71
+ .select("label, content, metadata, created_at")
72
+ .ilike("content", `%${escaped}%`)
73
+ .order("created_at", { ascending: false })
74
+ .limit(limit);
75
+
76
+ if (error) throw error;
77
+ const rows = (data ?? []) as Array<{
78
+ label: string;
79
+ content: string;
80
+ metadata?: Record<string, unknown>;
81
+ }>;
82
+ // Defense-in-depth: even if the backing table holds rows from other
83
+ // namespaces (e.g. several providers sharing one table), this provider
84
+ // only returns rows it actually owns. Without this filter, the
85
+ // declared ownership of `${prefix}*` would be silently violated.
86
+ return rows
87
+ .filter((r) => r.label.startsWith(prefix))
88
+ .map((r) => ({
89
+ label: r.label,
90
+ content: r.content,
91
+ metadata: r.metadata,
92
+ }));
93
+ };
94
+
95
+ const read = async (label: string): Promise<MemoryEntry | null> => {
96
+ if (!label.startsWith(prefix)) return null;
97
+ const { data, error } = await opts.client
98
+ .from(opts.table)
99
+ .select("label, content, metadata")
100
+ .eq("label", label)
101
+ .maybeSingle();
102
+
103
+ if (error) throw error;
104
+ if (!data) return null;
105
+ const row = data as {
106
+ label: string;
107
+ content: string;
108
+ metadata?: Record<string, unknown>;
109
+ };
110
+ return {
111
+ label: row.label,
112
+ content: row.content,
113
+ metadata: row.metadata,
114
+ };
115
+ };
116
+
117
+ const write = opts.mutable
118
+ ? async (label: string, content: string): Promise<void> => {
119
+ if (!label.startsWith(prefix)) {
120
+ throw new Error(
121
+ `supabaseMemory: label "${label}" does not start with namespace prefix "${prefix}"`,
122
+ );
123
+ }
124
+ const { error } = await opts.client.from(opts.table).insert({
125
+ label,
126
+ content,
127
+ created_at: new Date().toISOString(),
128
+ });
129
+ if (error) throw error;
130
+ }
131
+ : undefined;
132
+
133
+ return {
134
+ name: `supabase-memory-${opts.namespace}`,
135
+ capabilities: ["context", "tools"],
136
+ memory: {
137
+ owns: { kind: "namespace", prefix },
138
+ defaults: {
139
+ mutable: opts.mutable,
140
+ origin: opts.origin,
141
+ priority: opts.priority,
142
+ placement: opts.placement,
143
+ eviction: opts.eviction,
144
+ ttl: "session",
145
+ },
146
+ search,
147
+ read,
148
+ write,
149
+ },
150
+ };
151
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Telegram transport augment — bidirectional Telegram I/O.
3
+ *
4
+ * Inbound: polling (T12) or webhook (T13) modes. Both feed updates through
5
+ * resolveTelegramIdentity to resolve peer identity per the four-path model
6
+ * from item 5's spec.
7
+ *
8
+ * Outbound: replies to the current peer's chat via sendMessage. Outbound to
9
+ * non-current-peer destinations is notify's job, not this transport's.
10
+ *
11
+ * Uses src/telegram-client.ts as a shared utility — no cross-augment coupling.
12
+ *
13
+ * Wiring: Implements the TransportSpec contract. The kernel calls
14
+ * `transport.register(kernel)` to plug in; the augment retains the kernel
15
+ * handle and calls `kernel.handleInbound(trigger, {onEvent})` for every
16
+ * inbound text update. The reply path is wired by registering an outbound
17
+ * callback via `kernel.onOutbound(cb)` once at register-time. Mirrors
18
+ * web-transport's pattern.
19
+ */
20
+
21
+ import type {
22
+ Augment,
23
+ InboundMessage,
24
+ KernelEvent,
25
+ OutboundMessage,
26
+ Part,
27
+ PeerIdentity,
28
+ TelegramAuthOptions,
29
+ TelegramTransportOptions,
30
+ TransportKernel,
31
+ TransportSpec,
32
+ TurnTrigger,
33
+ } from "../../types";
34
+ import type { TelegramBotClient, TelegramUpdate } from "../../telegram-client";
35
+ import { createTelegramBotClient } from "../../telegram-client";
36
+ import { runPollLoop, type PollLoopHandle } from "./polling";
37
+ import { startWebhookServer, type WebhookServerHandle } from "./webhook";
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Boot-time validation
41
+ // ---------------------------------------------------------------------------
42
+
43
+ export interface BootLogger {
44
+ info(msg: string): void;
45
+ warn(msg: string): void;
46
+ }
47
+
48
+ export async function validateAdmittedAgents(
49
+ admittedAgents: Array<{ id: string; telegramUserId: number }> | undefined,
50
+ client: TelegramBotClient,
51
+ log: BootLogger = console,
52
+ ): Promise<void> {
53
+ if (!admittedAgents || admittedAgents.length === 0) return;
54
+ for (const agent of admittedAgents) {
55
+ try {
56
+ await client.getChat(agent.telegramUserId);
57
+ log.info(
58
+ `[telegram-transport] admittedAgent "${agent.id}" (telegramUserId=${agent.telegramUserId}) resolved successfully`,
59
+ );
60
+ } catch (err) {
61
+ log.warn(
62
+ `[telegram-transport] admittedAgent "${agent.id}" (telegramUserId=${agent.telegramUserId}) failed boot-time validation: ${(err as Error).message}. Real agent traffic from this user_id will be silently demoted to public-anonymous. Verify the user_id is correct and the bot has access to message that user.`,
63
+ );
64
+ }
65
+ }
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Identity resolution
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export interface ResolveIdentityInput {
73
+ userId: number;
74
+ threadId: string;
75
+ }
76
+
77
+ export function resolveTelegramIdentity(
78
+ input: ResolveIdentityInput,
79
+ auth: TelegramAuthOptions,
80
+ ): PeerIdentity {
81
+ const { userId, threadId } = input;
82
+ const mode = auth.anonymousIdentityMode ?? "ephemeral";
83
+
84
+ // Order matches item 5's web-transport: creator → agent → recognized → anonymous.
85
+ if (auth.creatorUserIds?.includes(userId)) {
86
+ return {
87
+ id: `tg_user_${userId}`,
88
+ kind: "human",
89
+ trustLevel: "creator",
90
+ sourceAugment: "telegram-transport",
91
+ };
92
+ }
93
+
94
+ const admitted = auth.admittedAgents?.find((a) => a.telegramUserId === userId);
95
+ if (admitted) {
96
+ return {
97
+ id: admitted.id,
98
+ kind: "agent",
99
+ trustLevel: "agent",
100
+ sourceAugment: "telegram-transport",
101
+ };
102
+ }
103
+
104
+ if (auth.recognizedUserIds?.includes(userId)) {
105
+ return {
106
+ id: `tg_user_${userId}`,
107
+ kind: "human",
108
+ trustLevel: "public",
109
+ publicSubstate: "recognized",
110
+ sourceAugment: "telegram-transport",
111
+ };
112
+ }
113
+
114
+ // Default: public-anonymous with mode-driven peer.id shape.
115
+ return {
116
+ id: mode === "durable" ? `tg_user_${userId}` : `tg_anon_${threadId}`,
117
+ kind: "human",
118
+ trustLevel: "public",
119
+ publicSubstate: "anonymous",
120
+ sourceAugment: "telegram-transport",
121
+ };
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Augment factory — full lifecycle (T14)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Internal extension of TelegramTransportOptions that adds test-only hooks.
130
+ * The public type does not include this field; tests use `as any` to pass it.
131
+ */
132
+ interface InternalOptions extends TelegramTransportOptions {
133
+ /** Test-only: override the bot client factory. Useful for unit tests
134
+ * that don't want to hit the real Telegram bot API. */
135
+ _clientFactory?: () => TelegramBotClient;
136
+ }
137
+
138
+ export function telegramTransport(opts: TelegramTransportOptions): Augment {
139
+ const internal = opts as InternalOptions;
140
+ const clientFactory =
141
+ internal._clientFactory ?? (() => createTelegramBotClient({ botToken: opts.botToken }));
142
+ const client = clientFactory();
143
+
144
+ let pollHandle: PollLoopHandle | null = null;
145
+ let webhookHandle: WebhookServerHandle | null = null;
146
+ let kernel: TransportKernel | null = null;
147
+ let registeredName: string | null = null;
148
+
149
+ /**
150
+ * Per-thread chat_id map. Populated when an inbound update arrives, read
151
+ * by the outbound callback so we know where to deliver the reply.
152
+ * Keyed by threadId (`tg-chat-<chatId>`) — same shape as the threadId we
153
+ * pass into resolveTelegramIdentity and into the TurnTrigger.
154
+ */
155
+ const threadChatIds = new Map<string, number | string>();
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Identity resolver (TransportSpec.identify)
159
+ // ---------------------------------------------------------------------------
160
+ //
161
+ // The kernel calls identify() with the raw inbound. For Telegram, the raw
162
+ // inbound is shaped `{ userId, threadId }` — we extract those before calling
163
+ // handleInbound so the kernel can pre-resolve peer identity if it wants to.
164
+
165
+ const identify = (raw: unknown): PeerIdentity | null => {
166
+ const r = raw as { userId?: number; threadId?: string };
167
+ if (typeof r?.userId !== "number" || typeof r?.threadId !== "string") return null;
168
+ return resolveTelegramIdentity({ userId: r.userId, threadId: r.threadId }, opts.auth);
169
+ };
170
+
171
+ const transport: TransportSpec = {
172
+ async register(k: TransportKernel, augmentName: string) {
173
+ kernel = k;
174
+ registeredName = augmentName;
175
+ // Wire the outbound callback once. The kernel invokes this for every
176
+ // outbound text message during a turn — we look up the chat_id by
177
+ // threadId (set when the inbound arrived) and call sendMessage.
178
+ kernel.onOutbound(async (_peer: PeerIdentity, message: OutboundMessage) => {
179
+ // Telegram replies are text-only in v0. Concatenate all text parts
180
+ // to mirror the AG-UI text_message kernel events.
181
+ const textParts = message.parts
182
+ .filter((p): p is Extract<Part, { kind: "text" }> => p.kind === "text")
183
+ .map((p) => p.text);
184
+ if (textParts.length === 0) return;
185
+ const text = textParts.join("");
186
+
187
+ // Find the chat_id. Prefer contextId/taskId-mapped threadId; fall
188
+ // back to message.targetPeer if the kernel relays it. The contract
189
+ // we set up at handleInbound time uses contextId === threadId.
190
+ const threadId = message.contextId;
191
+ if (!threadId) return;
192
+ const chatId = threadChatIds.get(threadId);
193
+ if (chatId === undefined) return;
194
+
195
+ try {
196
+ await client.sendMessage(chatId, text);
197
+ } catch (err) {
198
+ console.warn(
199
+ `[telegram-transport] sendMessage failed for chatId=${chatId}: ${(err as Error).message}`,
200
+ );
201
+ }
202
+ });
203
+ },
204
+ identify,
205
+ };
206
+
207
+ /**
208
+ * Convert a Telegram update to a TurnTrigger and dispatch via the kernel.
209
+ * Mirrors web-transport's handleAgentRun shape: build InboundMessage,
210
+ * wrap in TurnTrigger, call kernel.handleInbound.
211
+ */
212
+ async function handleUpdate(update: TelegramUpdate): Promise<void> {
213
+ if (!kernel) return; // Not yet registered — drop the update.
214
+ if (!update.message?.text || !update.message.from) return;
215
+
216
+ const userId = update.message.from.id;
217
+ const chatId = update.message.chat.id;
218
+ const threadId = `tg-chat-${chatId}`;
219
+ const peer = resolveTelegramIdentity({ userId, threadId }, opts.auth);
220
+
221
+ // Remember chat_id for the outbound callback.
222
+ threadChatIds.set(threadId, chatId);
223
+
224
+ const parts: Part[] = [{ kind: "text", text: update.message.text }];
225
+ const inbound: InboundMessage = {
226
+ parts,
227
+ sourceAugment: "telegram-transport",
228
+ peer,
229
+ timestamp: Date.now(),
230
+ contextId: threadId,
231
+ };
232
+ const trigger: TurnTrigger = {
233
+ type: "message",
234
+ turnId: crypto.randomUUID(),
235
+ threadId,
236
+ contextId: threadId,
237
+ timestamp: Date.now(),
238
+ source: registeredName ?? "telegram-transport",
239
+ peer,
240
+ payload: inbound,
241
+ };
242
+
243
+ // Drop kernelEvents on the floor for now (no streaming UI for Telegram
244
+ // in v0). The text replies arrive via the onOutbound callback wired in
245
+ // register(). If a streaming-edit (editMessageText) experience is added
246
+ // later, this is where text_message_delta would be intercepted.
247
+ const onEvent = (_e: KernelEvent): void => {};
248
+
249
+ try {
250
+ await kernel.handleInbound(trigger, { onEvent });
251
+ } catch (err) {
252
+ console.warn(
253
+ `[telegram-transport] kernel.handleInbound failed for threadId=${threadId}: ${(err as Error).message}`,
254
+ );
255
+ }
256
+ }
257
+
258
+ return {
259
+ name: "telegram-transport",
260
+ capabilities: ["transport"],
261
+ transport,
262
+
263
+ async onBoot(): Promise<void> {
264
+ await validateAdmittedAgents(opts.auth.admittedAgents, client);
265
+
266
+ if (opts.inbound.mode === "polling") {
267
+ pollHandle = runPollLoop({
268
+ client,
269
+ timeoutSec: opts.inbound.polling?.timeoutSec ?? 30,
270
+ onUpdate: (u) => handleUpdate(u),
271
+ });
272
+ } else if (opts.inbound.mode === "webhook") {
273
+ if (!opts.inbound.webhook) {
274
+ throw new Error(
275
+ "[telegram-transport] inbound.mode === 'webhook' requires inbound.webhook config",
276
+ );
277
+ }
278
+ await client.setWebhook(opts.inbound.webhook.publicUrl, opts.inbound.webhook.secretToken, {
279
+ allowedUpdates: opts.inbound.webhook.allowedUpdates,
280
+ });
281
+ webhookHandle = await startWebhookServer({
282
+ port: opts.inbound.webhook.port ?? 8081,
283
+ secretToken: opts.inbound.webhook.secretToken,
284
+ onUpdate: (u) => handleUpdate(u),
285
+ });
286
+ } else {
287
+ throw new Error(
288
+ `[telegram-transport] inbound.mode must be 'polling' or 'webhook' (got ${(opts.inbound as { mode: unknown }).mode})`,
289
+ );
290
+ }
291
+ },
292
+
293
+ async onShutdown(): Promise<void> {
294
+ if (pollHandle) {
295
+ pollHandle.stop();
296
+ await pollHandle.done;
297
+ pollHandle = null;
298
+ }
299
+ if (webhookHandle) {
300
+ webhookHandle.stop();
301
+ webhookHandle = null;
302
+ try {
303
+ await client.deleteWebhook();
304
+ } catch (err) {
305
+ console.warn(
306
+ `[telegram-transport] deleteWebhook on shutdown failed: ${(err as Error).message}`,
307
+ );
308
+ }
309
+ }
310
+ },
311
+ };
312
+ }