@getfamiliar/shared 0.1.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.
Files changed (83) hide show
  1. package/build/AgentRun.d.ts +187 -0
  2. package/build/AgentRun.js +1 -0
  3. package/build/AgentRunBus.d.ts +147 -0
  4. package/build/AgentRunBus.js +362 -0
  5. package/build/Calendar.d.ts +450 -0
  6. package/build/Calendar.js +37 -0
  7. package/build/ChatMessage.d.ts +53 -0
  8. package/build/ChatMessage.js +11 -0
  9. package/build/ChatMessageBus.d.ts +110 -0
  10. package/build/ChatMessageBus.js +257 -0
  11. package/build/Config.d.ts +83 -0
  12. package/build/Config.js +1 -0
  13. package/build/ContainerTool.d.ts +34 -0
  14. package/build/ContainerTool.js +1 -0
  15. package/build/CronExpression.d.ts +53 -0
  16. package/build/CronExpression.js +77 -0
  17. package/build/Event.d.ts +280 -0
  18. package/build/Event.js +14 -0
  19. package/build/EventBus.d.ts +198 -0
  20. package/build/EventBus.js +482 -0
  21. package/build/HandlerCatalog.d.ts +75 -0
  22. package/build/HandlerCatalog.js +165 -0
  23. package/build/InferenceEvent.d.ts +30 -0
  24. package/build/InferenceEvent.js +1 -0
  25. package/build/InferenceEventBus.d.ts +39 -0
  26. package/build/InferenceEventBus.js +80 -0
  27. package/build/Mail.d.ts +250 -0
  28. package/build/Mail.js +73 -0
  29. package/build/MailStyleTemplate.d.ts +71 -0
  30. package/build/MailStyleTemplate.js +47 -0
  31. package/build/ModelMetaData.d.ts +63 -0
  32. package/build/ModelMetaData.js +12 -0
  33. package/build/PathGlob.d.ts +20 -0
  34. package/build/PathGlob.js +33 -0
  35. package/build/Plugin.d.ts +709 -0
  36. package/build/Plugin.js +29 -0
  37. package/build/PostgresConnection.d.ts +75 -0
  38. package/build/PostgresConnection.js +162 -0
  39. package/build/ScheduledSubagent.d.ts +49 -0
  40. package/build/ScheduledSubagent.js +1 -0
  41. package/build/ScheduledSubagentBus.d.ts +94 -0
  42. package/build/ScheduledSubagentBus.js +187 -0
  43. package/build/Schema.d.ts +84 -0
  44. package/build/Schema.js +570 -0
  45. package/build/StepResult.d.ts +103 -0
  46. package/build/StepResult.js +1 -0
  47. package/build/StepResultBus.d.ts +64 -0
  48. package/build/StepResultBus.js +169 -0
  49. package/build/Timezone.d.ts +58 -0
  50. package/build/Timezone.js +84 -0
  51. package/build/ToolCall.d.ts +25 -0
  52. package/build/ToolCall.js +1 -0
  53. package/build/ToolCallBus.d.ts +65 -0
  54. package/build/ToolCallBus.js +110 -0
  55. package/build/ToolLevel.d.ts +20 -0
  56. package/build/ToolLevel.js +2 -0
  57. package/build/ToolRunner.d.ts +118 -0
  58. package/build/ToolRunner.js +192 -0
  59. package/build/ToolsExpressionUtils.d.ts +80 -0
  60. package/build/ToolsExpressionUtils.js +102 -0
  61. package/build/WorkspaceFile.d.ts +104 -0
  62. package/build/WorkspaceFile.js +7 -0
  63. package/build/estimateTokens.d.ts +16 -0
  64. package/build/estimateTokens.js +18 -0
  65. package/build/index.d.ts +37 -0
  66. package/build/index.js +26 -0
  67. package/build/logging/Logger.d.ts +66 -0
  68. package/build/logging/Logger.js +90 -0
  69. package/build/markdownHtml.d.ts +14 -0
  70. package/build/markdownHtml.js +21 -0
  71. package/build/markdownTerminal.d.ts +14 -0
  72. package/build/markdownTerminal.js +376 -0
  73. package/build/testing/MockAgentRunBus.d.ts +52 -0
  74. package/build/testing/MockAgentRunBus.js +175 -0
  75. package/build/testing/MockBusStore.d.ts +22 -0
  76. package/build/testing/MockBusStore.js +22 -0
  77. package/build/testing/MockEventBus.d.ts +20 -0
  78. package/build/testing/MockEventBus.js +57 -0
  79. package/build/testing/MockLogger.d.ts +52 -0
  80. package/build/testing/MockLogger.js +65 -0
  81. package/build/testing/index.d.ts +4 -0
  82. package/build/testing/index.js +4 -0
  83. package/package.json +63 -0
@@ -0,0 +1,165 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { matchesAnyGlob } from "./PathGlob.js";
4
+ /**
5
+ * Workspace files at the very top level that are not handlers and
6
+ * must be filtered out of {@link HandlerCatalog.list}. These are the
7
+ * documents read by `buildSystemPrompt` rather than executed as
8
+ * handlers (see `CLAUDE.md` — markdown layers / global context).
9
+ */
10
+ const RESERVED_ROOT_FILES = new Set(["SOUL.md", "CONTEXT.md"]);
11
+ /**
12
+ * Workspace subtrees that contain markdown but never handlers.
13
+ * `skills/<name>/SKILL.md` is a shared recipe loaded via `fs_read`,
14
+ * not an agent entry point.
15
+ */
16
+ const RESERVED_TOP_DIRS = new Set(["skills"]);
17
+ /**
18
+ * Read-only view over the `workspace/` directory's handler files.
19
+ *
20
+ * Mirrors the resolution semantics implemented by
21
+ * `container/src/HandlerFile.ts` so host-side code (CLI, cron
22
+ * scheduler introspection, tab-completion) can answer "does this
23
+ * handler exist?" and "list every handler" without depending on
24
+ * container internals or reimplementing the topic-walk inheritance.
25
+ *
26
+ * The catalog is stateless on the disk — every method re-scans —
27
+ * so live workspace edits are reflected without a daemon restart.
28
+ * At REPL frequencies this is cheap; if a hot path later needs
29
+ * caching it can wrap an instance.
30
+ */
31
+ export class HandlerCatalog {
32
+ workspaceDir;
33
+ writablePathGlobs;
34
+ /**
35
+ * @param workspaceDir Absolute path of the `workspace/` directory.
36
+ * @param writablePathGlobs `core.writablePaths` globs (e.g. `wiki/**`).
37
+ * Files under these are writable by non-privileged runs and are
38
+ * therefore never handlers — they are excluded from {@link list}
39
+ * and {@link resolve} so they are never proposed or resolved as a
40
+ * handler. Mirrors the container's `HandlerFile.load` guard.
41
+ */
42
+ constructor(workspaceDir, writablePathGlobs = []) {
43
+ this.workspaceDir = workspaceDir;
44
+ this.writablePathGlobs = writablePathGlobs;
45
+ }
46
+ /**
47
+ * Walk `workspace/` and return one {@link HandlerPath} per
48
+ * candidate handler file. Excludes the global-context files at
49
+ * the workspace root (`SOUL.md`, `CONTEXT.md`)
50
+ * and the `skills/` subtree. Every other `.md` file under a topic
51
+ * directory is included, even if it's a knowledge file (e.g.
52
+ * `people/anna.md`) — `HandlerCatalog` does not police what is or
53
+ * isn't a "proper" handler; that's a handler-loading concern.
54
+ *
55
+ * Sorted by `slashPath` for stable display ordering in
56
+ * tab-completion. Returns an empty array when the workspace
57
+ * directory does not exist.
58
+ */
59
+ async list() {
60
+ const exists = await directoryExists(this.workspaceDir);
61
+ if (!exists) {
62
+ return [];
63
+ }
64
+ const results = [];
65
+ await walkMarkdown(this.workspaceDir, [], results, this.workspaceDir, this.writablePathGlobs);
66
+ results.sort((a, b) => a.slashPath.localeCompare(b.slashPath));
67
+ return results;
68
+ }
69
+ /**
70
+ * Resolve a handler call: given a topic (e.g. `chat:telegram`)
71
+ * and a basename (e.g. `index`), return the absolute path of the
72
+ * deepest existing handler file, mirroring the container's
73
+ * inheritance walk.
74
+ *
75
+ * For topic `a:b:c` and basename `x` the resolution tries, in
76
+ * order: `a/b/c/x.md`, `a/b/x.md`, `a/x.md`. Returns the first
77
+ * existing path or `null` if none exist. The deepest existing
78
+ * file is what `HandlerFile.load` would treat as the leaf — for
79
+ * cli-chat existence checks, that's exactly the question we
80
+ * want answered.
81
+ */
82
+ async resolve(topic, handler) {
83
+ const segments = topic.split(":");
84
+ for (let depth = segments.length; depth >= 1; depth--) {
85
+ const rel = path.join(...segments.slice(0, depth), `${handler}.md`);
86
+ const relPosix = rel.split(path.sep).join("/");
87
+ const absolute = path.join(this.workspaceDir, rel);
88
+ if (await fileExists(absolute)) {
89
+ // A file under `core.writablePaths` is never a handler, even
90
+ // when it exists — mirror the container's `HandlerFile.load`
91
+ // guard so host-side callers agree it does not resolve.
92
+ if (matchesAnyGlob(this.writablePathGlobs, relPosix)) {
93
+ return null;
94
+ }
95
+ return absolute;
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+ }
101
+ /**
102
+ * Recursively walk `dir`, accumulating one {@link HandlerPath} per
103
+ * `.md` file. `segments` tracks the topic path from the workspace
104
+ * root; an empty array means we're at the root, where reserved files
105
+ * are skipped.
106
+ */
107
+ async function walkMarkdown(dir, segments, out, workspaceDir, writablePathGlobs) {
108
+ let entries;
109
+ try {
110
+ entries = await fs.readdir(dir, { withFileTypes: true });
111
+ }
112
+ catch {
113
+ return;
114
+ }
115
+ for (const entry of entries) {
116
+ if (entry.isDirectory()) {
117
+ if (segments.length === 0 && RESERVED_TOP_DIRS.has(entry.name)) {
118
+ continue;
119
+ }
120
+ await walkMarkdown(path.join(dir, entry.name), [...segments, entry.name], out, workspaceDir, writablePathGlobs);
121
+ continue;
122
+ }
123
+ if (!entry.isFile() || !entry.name.endsWith(".md")) {
124
+ continue;
125
+ }
126
+ if (segments.length === 0 && RESERVED_ROOT_FILES.has(entry.name)) {
127
+ continue;
128
+ }
129
+ if (segments.length === 0) {
130
+ // Stray `.md` at the workspace root with no topic folder —
131
+ // not a handler. Skip silently rather than reporting an
132
+ // empty topic.
133
+ continue;
134
+ }
135
+ const handlerBase = entry.name.slice(0, -".md".length);
136
+ const topic = segments.join(":");
137
+ const absolutePath = path.join(dir, entry.name);
138
+ const relativePath = path.relative(workspaceDir, absolutePath).split(path.sep).join("/");
139
+ // Files under `core.writablePaths` (e.g. `wiki/**`) are writable by
140
+ // non-privileged runs and are never handlers — never propose them.
141
+ if (matchesAnyGlob(writablePathGlobs, relativePath)) {
142
+ continue;
143
+ }
144
+ const slashPath = `/${[...segments, handlerBase].join("/")}`;
145
+ out.push({ topic, handler: handlerBase, absolutePath, relativePath, slashPath });
146
+ }
147
+ }
148
+ async function fileExists(absolute) {
149
+ try {
150
+ const stat = await fs.stat(absolute);
151
+ return stat.isFile();
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ async function directoryExists(absolute) {
158
+ try {
159
+ const stat = await fs.stat(absolute);
160
+ return stat.isDirectory();
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * One model HTTP call outcome.
3
+ *
4
+ * - `success` — the upstream returned a 2xx the runner accepted.
5
+ * - `retryable` — the upstream returned 408/429/5xx or otherwise signalled
6
+ * transient failure; AgentRunner postponed the agentrun.
7
+ * - `fatal` — non-retryable upstream error (auth, malformed request,
8
+ * unknown model id) that immediately settled the agentrun as `failed`.
9
+ */
10
+ export type InferenceOutcome = "success" | "retryable" | "fatal";
11
+ /** Persisted shape of an `inference_events` row. */
12
+ export interface InferenceEventRow {
13
+ readonly id: string;
14
+ readonly provider: string;
15
+ readonly model: string;
16
+ readonly agentRunId: string | null;
17
+ readonly outcome: InferenceOutcome;
18
+ readonly statusCode: number | null;
19
+ readonly errorExcerpt: string | null;
20
+ readonly occurredAt: Date;
21
+ }
22
+ /** Insert shape — `id` and `occurredAt` are filled by the DB. */
23
+ export interface NewInferenceEvent {
24
+ readonly provider: string;
25
+ readonly model: string;
26
+ readonly agentRunId?: string | null;
27
+ readonly outcome: InferenceOutcome;
28
+ readonly statusCode?: number | null;
29
+ readonly errorExcerpt?: string | null;
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import type { InferenceEventRow, NewInferenceEvent } from "./InferenceEvent.js";
2
+ import type { PostgresConnection } from "./PostgresConnection.js";
3
+ /**
4
+ * Domain client for the `inference_events` table — every upstream model
5
+ * HTTP call AgentRunner attempted, classified into success / retryable /
6
+ * fatal. Append-only: there is no update or delete on this table outside
7
+ * the global retention sweep. Mirrors {@link import("./AgentRunBus.js").AgentRunBus}
8
+ * in its constructor + Pool access pattern; does not own connection
9
+ * lifecycle.
10
+ */
11
+ export declare class InferenceEventBus {
12
+ private readonly connection;
13
+ constructor(connection: PostgresConnection);
14
+ /**
15
+ * Apply the bus-state schema (idempotent). Same SQL bundle every
16
+ * other bus client installs; calling any one is sufficient on
17
+ * daemon start.
18
+ */
19
+ installSchema(): Promise<void>;
20
+ /**
21
+ * Insert one inference event. Fire-and-forget from AgentRunner; any
22
+ * thrown error is the caller's responsibility to log — we don't want
23
+ * to mask a real model error with an audit-table write error.
24
+ */
25
+ add(row: NewInferenceEvent): Promise<void>;
26
+ /**
27
+ * Fetch every inference event with `occurred_at >= since`, optionally
28
+ * filtered to one model id. Ordered by `occurred_at DESC` so the
29
+ * reflection tool's "last successful call" lookup is the first hit.
30
+ */
31
+ listSince(since: Date, model?: string): Promise<readonly InferenceEventRow[]>;
32
+ /**
33
+ * Delete rows older than `cutoff`. Returns the number of rows
34
+ * removed. Run from the same retention pass that prunes daemon log
35
+ * files so the two retention policies stay aligned with
36
+ * `core.logRetentionDays`.
37
+ */
38
+ pruneBefore(cutoff: Date): Promise<number>;
39
+ }
@@ -0,0 +1,80 @@
1
+ import { SCHEMA_SQL } from "./Schema.js";
2
+ /**
3
+ * Domain client for the `inference_events` table — every upstream model
4
+ * HTTP call AgentRunner attempted, classified into success / retryable /
5
+ * fatal. Append-only: there is no update or delete on this table outside
6
+ * the global retention sweep. Mirrors {@link import("./AgentRunBus.js").AgentRunBus}
7
+ * in its constructor + Pool access pattern; does not own connection
8
+ * lifecycle.
9
+ */
10
+ export class InferenceEventBus {
11
+ connection;
12
+ constructor(connection) {
13
+ this.connection = connection;
14
+ }
15
+ /**
16
+ * Apply the bus-state schema (idempotent). Same SQL bundle every
17
+ * other bus client installs; calling any one is sufficient on
18
+ * daemon start.
19
+ */
20
+ async installSchema() {
21
+ await this.connection.getPool().query(SCHEMA_SQL);
22
+ }
23
+ /**
24
+ * Insert one inference event. Fire-and-forget from AgentRunner; any
25
+ * thrown error is the caller's responsibility to log — we don't want
26
+ * to mask a real model error with an audit-table write error.
27
+ */
28
+ async add(row) {
29
+ await this.connection.getPool().query(`INSERT INTO inference_events
30
+ (provider, model, agent_run_id, outcome, status_code, error_excerpt)
31
+ VALUES ($1, $2, $3, $4, $5, $6)`, [
32
+ row.provider,
33
+ row.model,
34
+ row.agentRunId ?? null,
35
+ row.outcome,
36
+ row.statusCode ?? null,
37
+ row.errorExcerpt ?? null,
38
+ ]);
39
+ }
40
+ /**
41
+ * Fetch every inference event with `occurred_at >= since`, optionally
42
+ * filtered to one model id. Ordered by `occurred_at DESC` so the
43
+ * reflection tool's "last successful call" lookup is the first hit.
44
+ */
45
+ async listSince(since, model) {
46
+ const pool = this.connection.getPool();
47
+ const result = model === undefined
48
+ ? await pool.query(`SELECT * FROM inference_events
49
+ WHERE occurred_at >= $1
50
+ ORDER BY occurred_at DESC`, [since])
51
+ : await pool.query(`SELECT * FROM inference_events
52
+ WHERE occurred_at >= $1 AND model = $2
53
+ ORDER BY occurred_at DESC`, [since, model]);
54
+ return result.rows.map(mapRow);
55
+ }
56
+ /**
57
+ * Delete rows older than `cutoff`. Returns the number of rows
58
+ * removed. Run from the same retention pass that prunes daemon log
59
+ * files so the two retention policies stay aligned with
60
+ * `core.logRetentionDays`.
61
+ */
62
+ async pruneBefore(cutoff) {
63
+ const result = await this.connection
64
+ .getPool()
65
+ .query(`DELETE FROM inference_events WHERE occurred_at < $1`, [cutoff]);
66
+ return result.rowCount ?? 0;
67
+ }
68
+ }
69
+ function mapRow(raw) {
70
+ return {
71
+ id: raw.id,
72
+ provider: raw.provider,
73
+ model: raw.model,
74
+ agentRunId: raw.agent_run_id,
75
+ outcome: raw.outcome,
76
+ statusCode: raw.status_code,
77
+ errorExcerpt: raw.error_excerpt,
78
+ occurredAt: raw.occurred_at,
79
+ };
80
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Plugin-agnostic mail model. Lives in `shared/` because both the host
3
+ * (which owns the `mail_*` agent tools and the safety gate) and the
4
+ * plugin providers (which translate the typed interface into provider
5
+ * API calls) trade in the same shapes.
6
+ *
7
+ * The core has no mail cache of its own — providers fetch live every
8
+ * time. Plugin pollers still emit `mail:<plugin>` events to wake
9
+ * handlers, but the persisted entity is the event row; the mail body
10
+ * and attachments are pulled on demand through the provider.
11
+ *
12
+ * **Mail ids** are provider-prefixed strings: `<pluginId>:<mailbox>:<realId>`.
13
+ * The mailbox segment lets the host route a tool call to the correct
14
+ * provider AND the correct logged-in account without needing a separate
15
+ * mailbox registry. {@link buildMailId} / {@link parseMailId} are the
16
+ * canonical helpers; raw string concatenation is forbidden.
17
+ *
18
+ * **Folders.** The core uses three abstract aliases — `inbox`, `archive`,
19
+ * `trash` — which each provider maps to its native folder names
20
+ * (e.g. ms365 → `inbox`, `archive`, `deleteditems`; gmail eventually →
21
+ * `INBOX`, `[Gmail]/All Mail`, `[Gmail]/Trash`).
22
+ */
23
+ export type MailFolder = "inbox" | "archive" | "trash" | "sent";
24
+ /**
25
+ * One downloaded attachment. The core tool stages these into the
26
+ * agentrun's scratch directory; the provider supplies the raw bytes.
27
+ */
28
+ export interface MailAttachment {
29
+ readonly name: string;
30
+ readonly contents: Buffer;
31
+ }
32
+ /**
33
+ * Sanitized address bundle as it appears in the `mail:new` event
34
+ * payload and in {@link MailSearchHit} results. `address` is always
35
+ * safe to use as a filename component; `rawAddress` is `null` when the
36
+ * upstream address passed validation, otherwise it carries the
37
+ * original bytes for audit (handlers must NEVER use it as a path).
38
+ */
39
+ export interface MailAddress {
40
+ readonly name: string | null;
41
+ readonly address: string;
42
+ readonly rawAddress: string | null;
43
+ }
44
+ /**
45
+ * Attachment metadata that ships with a `mail:new` payload and search
46
+ * hits. Bytes never travel through this shape — the agent calls
47
+ * `mail_fetch_attachments` to download contents on demand.
48
+ */
49
+ export interface MailAttachmentMeta {
50
+ readonly id: string;
51
+ readonly name: string;
52
+ readonly contentType: string;
53
+ readonly size: number;
54
+ readonly isInline: boolean;
55
+ }
56
+ /**
57
+ * Predicates for {@link MailProvider.search}. Date bounds are passed
58
+ * in already-resolved UTC ISO form (the host parses agent-supplied
59
+ * local-tz strings against `core.timezone` before dispatching, so
60
+ * providers never have to know about timezones).
61
+ */
62
+ export interface MailSearchQuery {
63
+ /** Full-text query, applied across subject/body/from/to/cc. */
64
+ readonly text?: string;
65
+ /** Address that must appear as sender, To, or Cc. */
66
+ readonly contact?: string;
67
+ /** Inclusive lower bound on receivedDateTime (UTC ISO instant). */
68
+ readonly after?: string;
69
+ /** Exclusive upper bound on receivedDateTime (UTC ISO instant). */
70
+ readonly before?: string;
71
+ /** Restrict to one abstract folder; otherwise any folder is in scope. */
72
+ readonly folder?: MailFolder;
73
+ /**
74
+ * Optional mailbox restriction. When omitted the provider searches
75
+ * every mailbox in its configured set, regardless of polling state.
76
+ * When given and not in the configured set, providers may still
77
+ * attempt the call as a best-effort fallback.
78
+ */
79
+ readonly mailbox?: string;
80
+ /**
81
+ * Hard ceiling on hits to return. The host passes the remaining
82
+ * global budget (initial 100, decremented across providers) so a
83
+ * provider can stop paging early instead of fetching results that
84
+ * would be dropped anyway.
85
+ */
86
+ readonly limit: number;
87
+ }
88
+ /**
89
+ * One match returned by {@link MailProvider.search}. Shape is
90
+ * intentionally identical to the `mail:new` event payload so handlers
91
+ * can treat search hits and live mail events with the same code path.
92
+ */
93
+ export interface MailSearchHit {
94
+ readonly mail_id: string;
95
+ readonly isShared: boolean;
96
+ readonly from: MailAddress;
97
+ readonly to: readonly MailAddress[];
98
+ readonly cc: readonly MailAddress[];
99
+ readonly subject: string;
100
+ readonly date: string;
101
+ readonly internetMessageId: string;
102
+ readonly hasAttachments: boolean;
103
+ readonly attachments: readonly MailAttachmentMeta[] | null;
104
+ /**
105
+ * Folder the hit currently lives in, using the same vocabulary the
106
+ * agent passes to `mail_search`'s `folder` argument and `mail_move`:
107
+ * one of the three known aliases (`inbox` / `archive` / `trash`) or
108
+ * `other` for anything else (custom user folders the agent doesn't
109
+ * have a verb for today). `null` only when the provider couldn't
110
+ * resolve the folder for a given hit.
111
+ */
112
+ readonly folder: MailFolder | "other" | null;
113
+ }
114
+ /**
115
+ * Composition input for {@link MailProvider.draftReply} /
116
+ * {@link MailProvider.sendReply}. The body is markdown; providers render
117
+ * to their native body format (HTML for Graph) before dispatch.
118
+ */
119
+ export interface ReplyInput {
120
+ readonly bodyMarkdown: string;
121
+ /** When `true`, address every recipient on the original mail. */
122
+ readonly replyAll: boolean;
123
+ }
124
+ /**
125
+ * Composition input for {@link MailProvider.draftForward} /
126
+ * {@link MailProvider.sendForward}. The optional comment renders above
127
+ * the quoted original. Original attachments are carried over by the
128
+ * provider where possible.
129
+ */
130
+ export interface ForwardInput {
131
+ readonly to: readonly string[];
132
+ readonly cc: readonly string[];
133
+ readonly commentMarkdown: string;
134
+ }
135
+ /**
136
+ * Composition input for a brand-new mail (no source message). The
137
+ * "from" mailbox is passed separately to {@link MailProvider.draftNew} /
138
+ * {@link MailProvider.sendNew} as the first argument so the same shape
139
+ * carries through every send/draft variant.
140
+ */
141
+ export interface NewMailInput {
142
+ readonly to: readonly string[];
143
+ readonly cc: readonly string[];
144
+ readonly subject: string;
145
+ readonly bodyMarkdown: string;
146
+ }
147
+ /**
148
+ * The contract a provider plugin implements + registers via
149
+ * {@link MailApi.registerProvider}. The host's `mail_*` tools dispatch
150
+ * here by parsing the mail id's `<pluginId>:` prefix and looking the
151
+ * provider up in the registry.
152
+ *
153
+ * Methods return raw provider-native ids; the core wraps them with
154
+ * the `<pluginId>:<mailbox>:` prefix before handing them back to the
155
+ * agent. That way every id the agent sees is self-routing.
156
+ *
157
+ * Provider safety (send guards, attendee stripping, etc.) is **not**
158
+ * the provider's concern — those policies live in core (`MailSafety`
159
+ * in the host). Providers focus on the API call.
160
+ */
161
+ export interface MailProvider {
162
+ /** Identifies this provider in the registry. Matches the plugin id. */
163
+ readonly pluginId: string;
164
+ /** Fetch the plain-text body of one message. */
165
+ fetchBody(mailbox: string, messageId: string): Promise<string>;
166
+ /** Download every non-inline attachment as in-memory buffers. */
167
+ fetchAttachments(mailbox: string, messageId: string): Promise<readonly MailAttachment[]>;
168
+ /**
169
+ * Inspect an existing message and return the recipient set a reply
170
+ * would address (sender always; to/cc lists when `replyAll`). Used
171
+ * by core to gate `send_reply` against the safety whitelist *before*
172
+ * the send call; saves one round trip in the gated path and lets
173
+ * the agent see the recipients in the result envelope.
174
+ */
175
+ previewReplyRecipients(mailbox: string, messageId: string, replyAll: boolean): Promise<readonly string[]>;
176
+ /** Create a draft reply. Returns the provider-native draft id. */
177
+ draftReply(mailbox: string, messageId: string, input: ReplyInput): Promise<string>;
178
+ /** Create a draft forward. Returns the provider-native draft id. */
179
+ draftForward(mailbox: string, messageId: string, input: ForwardInput): Promise<string>;
180
+ /** Create a brand-new draft under `mailbox`. Returns its id. */
181
+ draftNew(mailbox: string, input: NewMailInput): Promise<string>;
182
+ /**
183
+ * Compose and dispatch a reply immediately. The core has already
184
+ * cleared the safety gate by the time this is called.
185
+ */
186
+ sendReply(mailbox: string, messageId: string, input: ReplyInput): Promise<void>;
187
+ /** Compose and dispatch a forward immediately. */
188
+ sendForward(mailbox: string, messageId: string, input: ForwardInput): Promise<void>;
189
+ /** Compose and dispatch a brand-new mail immediately. */
190
+ sendNew(mailbox: string, input: NewMailInput): Promise<void>;
191
+ /** Move a message to one of the abstract folder aliases. */
192
+ move(mailbox: string, messageId: string, folder: MailFolder): Promise<void>;
193
+ /**
194
+ * Search this provider's mail for hits matching `query`. The host
195
+ * fans out to every registered provider when no `plugin` filter is
196
+ * set; per-provider results are merged into a single JSONL response
197
+ * to the agent. Implementations should:
198
+ *
199
+ * - target `query.mailbox` when given (best-effort: try even if
200
+ * the address isn't in the configured set), otherwise iterate
201
+ * over every mailbox the provider considers configured —
202
+ * independent of whether that mailbox is being polled;
203
+ * - cap returned hits at `query.limit` (the host's remaining
204
+ * global budget) and stop paging once the cap is hit;
205
+ * - shape each hit identically to the provider's `mail:new`
206
+ * event payload, so downstream tools accept the `mail_id` as
207
+ * self-routing.
208
+ */
209
+ search(query: MailSearchQuery): Promise<readonly MailSearchHit[]>;
210
+ }
211
+ /**
212
+ * Capabilities the host exposes to plugins for mail data. Reached via
213
+ * `ctx.mail` on a plugin's `HostContext`. Tiny by design: there is no
214
+ * mail cache in core, so the only thing plugins do here is register
215
+ * their provider during `start()`.
216
+ */
217
+ export interface MailApi {
218
+ /**
219
+ * Register the create / send / fetch callbacks a provider plugin
220
+ * implements. Throws if the same `pluginId` registers twice — a
221
+ * wiring bug, not a feature.
222
+ */
223
+ registerProvider(provider: MailProvider): void;
224
+ }
225
+ /**
226
+ * Build a self-routing mail id from the three segments. Format:
227
+ * `<pluginId>:<mailbox>:<realId>`. The id is what flows through tool
228
+ * args, event payloads, and agent prompts — the agent never sees the
229
+ * three segments separately.
230
+ *
231
+ * No escaping is applied: mailboxes containing literal `:` (rare;
232
+ * RFC 5321 allows quoted local parts) are unsupported. Graph ids
233
+ * routinely contain `=`, `+`, `/` but never `:`, so the parse split is
234
+ * unambiguous in practice.
235
+ */
236
+ export declare function buildMailId(pluginId: string, mailbox: string, realId: string): string;
237
+ /**
238
+ * Parse a mail id back into its three segments. The split is on the
239
+ * first two `:` characters; anything after the second colon is the
240
+ * real id (which provider implementations may use freely).
241
+ *
242
+ * @throws If the id has fewer than two `:` separators or any segment
243
+ * is empty. The error message names the offending input so a bad
244
+ * id surfaced from agent args produces an actionable tool failure.
245
+ */
246
+ export declare function parseMailId(id: string): {
247
+ readonly pluginId: string;
248
+ readonly mailbox: string;
249
+ readonly realId: string;
250
+ };
package/build/Mail.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Plugin-agnostic mail model. Lives in `shared/` because both the host
3
+ * (which owns the `mail_*` agent tools and the safety gate) and the
4
+ * plugin providers (which translate the typed interface into provider
5
+ * API calls) trade in the same shapes.
6
+ *
7
+ * The core has no mail cache of its own — providers fetch live every
8
+ * time. Plugin pollers still emit `mail:<plugin>` events to wake
9
+ * handlers, but the persisted entity is the event row; the mail body
10
+ * and attachments are pulled on demand through the provider.
11
+ *
12
+ * **Mail ids** are provider-prefixed strings: `<pluginId>:<mailbox>:<realId>`.
13
+ * The mailbox segment lets the host route a tool call to the correct
14
+ * provider AND the correct logged-in account without needing a separate
15
+ * mailbox registry. {@link buildMailId} / {@link parseMailId} are the
16
+ * canonical helpers; raw string concatenation is forbidden.
17
+ *
18
+ * **Folders.** The core uses three abstract aliases — `inbox`, `archive`,
19
+ * `trash` — which each provider maps to its native folder names
20
+ * (e.g. ms365 → `inbox`, `archive`, `deleteditems`; gmail eventually →
21
+ * `INBOX`, `[Gmail]/All Mail`, `[Gmail]/Trash`).
22
+ */
23
+ /**
24
+ * Build a self-routing mail id from the three segments. Format:
25
+ * `<pluginId>:<mailbox>:<realId>`. The id is what flows through tool
26
+ * args, event payloads, and agent prompts — the agent never sees the
27
+ * three segments separately.
28
+ *
29
+ * No escaping is applied: mailboxes containing literal `:` (rare;
30
+ * RFC 5321 allows quoted local parts) are unsupported. Graph ids
31
+ * routinely contain `=`, `+`, `/` but never `:`, so the parse split is
32
+ * unambiguous in practice.
33
+ */
34
+ export function buildMailId(pluginId, mailbox, realId) {
35
+ if (pluginId.length === 0 || pluginId.includes(":")) {
36
+ throw new Error(`pluginId must be non-empty and ":"-free: "${pluginId}"`);
37
+ }
38
+ if (mailbox.length === 0 || mailbox.includes(":")) {
39
+ throw new Error(`mailbox must be non-empty and ":"-free: "${mailbox}"`);
40
+ }
41
+ if (realId.length === 0) {
42
+ throw new Error("realId must be non-empty");
43
+ }
44
+ return `${pluginId}:${mailbox}:${realId}`;
45
+ }
46
+ /**
47
+ * Parse a mail id back into its three segments. The split is on the
48
+ * first two `:` characters; anything after the second colon is the
49
+ * real id (which provider implementations may use freely).
50
+ *
51
+ * @throws If the id has fewer than two `:` separators or any segment
52
+ * is empty. The error message names the offending input so a bad
53
+ * id surfaced from agent args produces an actionable tool failure.
54
+ */
55
+ export function parseMailId(id) {
56
+ const first = id.indexOf(":");
57
+ if (first <= 0) {
58
+ throw new Error(`mail id "${id}" is malformed: expected "<plugin>:<mailbox>:<realId>"`);
59
+ }
60
+ const second = id.indexOf(":", first + 1);
61
+ if (second <= first + 1) {
62
+ throw new Error(`mail id "${id}" is malformed: missing mailbox segment`);
63
+ }
64
+ const realId = id.slice(second + 1);
65
+ if (realId.length === 0) {
66
+ throw new Error(`mail id "${id}" is malformed: empty real-id segment`);
67
+ }
68
+ return {
69
+ pluginId: id.slice(0, first),
70
+ mailbox: id.slice(first + 1, second),
71
+ realId,
72
+ };
73
+ }