@sleep2agi/agent-network 2.3.0-preview.4 → 2.3.0-preview.41

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 (64) hide show
  1. package/README.md +35 -324
  2. package/dist/bin/anet.cjs +101 -0
  3. package/dist/bin/cli.d.ts +1 -0
  4. package/dist/bin/cli.js +20 -1
  5. package/dist/bin/goal-wake-log-render.d.ts +31 -0
  6. package/dist/src/batch-workdir.d.ts +9 -0
  7. package/dist/src/bootstrap-password-db.d.ts +13 -0
  8. package/dist/src/channel-attachments.d.ts +29 -0
  9. package/dist/src/channel-task-trace.d.ts +10 -0
  10. package/dist/src/claude-vendor-env.d.ts +28 -0
  11. package/dist/src/cli-args.d.ts +8 -0
  12. package/dist/src/client-task-trace.d.ts +9 -0
  13. package/dist/src/client.js +1 -1
  14. package/dist/src/codex-copresence-preflight.d.ts +65 -0
  15. package/dist/src/codex-copresence-profile.d.ts +68 -0
  16. package/dist/src/codex-model-default.d.ts +6 -0
  17. package/dist/src/controlled-upload.d.ts +83 -0
  18. package/dist/src/copresence-deps.d.ts +20 -0
  19. package/dist/src/copresence-identity.d.ts +339 -0
  20. package/dist/src/dashboard-managed-process.d.ts +35 -0
  21. package/dist/src/environ-alias.d.ts +14 -0
  22. package/dist/src/grok-attach-client.d.ts +115 -0
  23. package/dist/src/grok-copresence-disclosure.d.ts +11 -0
  24. package/dist/src/grok-copresence-profile.d.ts +65 -0
  25. package/dist/src/im/feishu/adapter.d.ts +149 -1
  26. package/dist/src/im/feishu/bridge.d.ts +9 -0
  27. package/dist/src/im/feishu/config.d.ts +31 -0
  28. package/dist/src/im/feishu/hub-upload.d.ts +88 -0
  29. package/dist/src/im/feishu/markdown-image-renderer.d.ts +61 -0
  30. package/dist/src/im/feishu/outbound-marker.d.ts +140 -0
  31. package/dist/src/im/feishu/outbound-paths.d.ts +50 -0
  32. package/dist/src/im/feishu/outbound-route.d.ts +62 -0
  33. package/dist/src/im/feishu/worker-lifecycle.d.ts +13 -0
  34. package/dist/src/im/feishu/worker.js +391 -16
  35. package/dist/src/im/types.d.ts +38 -1
  36. package/dist/src/locale-diagnostic.d.ts +12 -0
  37. package/dist/src/mock-llm.d.ts +12 -0
  38. package/dist/src/node-server.js +7 -1
  39. package/dist/src/normalize-runtime.d.ts +14 -2
  40. package/dist/src/opencode-agent-node-pair.d.ts +14 -0
  41. package/dist/src/opencode-auth-login.d.ts +43 -0
  42. package/dist/src/opencode-launch-env.d.ts +13 -0
  43. package/dist/src/opencode-owner-mode.d.ts +6 -0
  44. package/dist/src/opencode-package-binary.d.ts +21 -0
  45. package/dist/src/opencode-pin.d.ts +44 -0
  46. package/dist/src/opencode-preset.d.ts +73 -0
  47. package/dist/src/opencode-runtime-binding.d.ts +30 -0
  48. package/dist/src/opencode-safe-root.d.ts +27 -0
  49. package/dist/src/opencode-smoke-env.d.ts +1 -0
  50. package/dist/src/outbound-tool-names.d.ts +1 -0
  51. package/dist/src/owner-env-file.d.ts +2 -0
  52. package/dist/src/package-mode-preflight.d.ts +31 -0
  53. package/dist/src/primary-network.d.ts +23 -0
  54. package/dist/src/private-state.d.ts +10 -0
  55. package/dist/src/project-key.d.ts +1 -0
  56. package/dist/src/secret-shell-guidance.d.ts +3 -0
  57. package/dist/src/task-trace.d.ts +33 -0
  58. package/dist/src/tmux-attach.d.ts +8 -0
  59. package/dist/src/tmux-capability.d.ts +58 -0
  60. package/dist/src/tmux-exact-target.d.ts +34 -0
  61. package/dist/src/tmux-pane-prompt.d.ts +22 -0
  62. package/dist/src/token-cli.d.ts +13 -0
  63. package/dist/src/unsafe-package-path-reason.d.ts +17 -0
  64. package/package.json +10 -6
@@ -1,12 +1,49 @@
1
+ /**
2
+ * RFC-020 §3.1 — Feishu (Lark) adapter for the IM compatibility layer.
3
+ *
4
+ * Uses `@larksuiteoapi/node-sdk` in WebSocket long-connection mode (WSClient).
5
+ * No public IP / no domain verification / no webhook signature decryption —
6
+ * the three biggest 飞书 接入 risks all live in the HTTP event-callback path,
7
+ * not in WSClient mode.
8
+ *
9
+ * Milestones:
10
+ * M1: contract scaffold.
11
+ * M2 (this file): WSClient init + EventDispatcher for `im.message.receive_v1`
12
+ * + event normalization + access whitelist gate + audit log.
13
+ * M3: outbound `im.message.create` (text), edit support (≤20/msg).
14
+ * M5: image upload / download (`im.image.create` / `im.messageResource.get`)
15
+ * + group @bot detection refined to match the bot's own open_id.
16
+ */
17
+ import * as lark from "@larksuiteoapi/node-sdk";
1
18
  import type { IMAdapter, IMAdapterHealth, IMChannelConfig, IMConversationRef, IMIngressMode, NormalizedIMEvent, NormalizedIMMessage } from "../types.js";
2
19
  type OnEventHandler = (event: NormalizedIMEvent) => Promise<void>;
20
+ export type FeishuWsClientLike = Pick<lark.WSClient, "start" | "close">;
21
+ export type FeishuWsClientFactory = (params: ConstructorParameters<typeof lark.WSClient>[0]) => FeishuWsClientLike;
22
+ type FeishuInboundHandler = (rawEvent: unknown) => Promise<unknown>;
23
+ export interface FeishuEventDispatcherLike {
24
+ register(handlers: Record<string, FeishuInboundHandler>): unknown;
25
+ }
26
+ export interface FeishuAdapterOptions {
27
+ /** @internal Avoids real bot-info HTTP calls in lifecycle tests. */
28
+ createClient?: (params: ConstructorParameters<typeof lark.Client>[0]) => lark.Client;
29
+ /** @internal Test seam; production uses the pinned Lark SDK WSClient. */
30
+ createWsClient?: FeishuWsClientFactory;
31
+ /** @internal Test seam; production uses the pinned Lark SDK dispatcher. */
32
+ createEventDispatcher?: () => FeishuEventDispatcherLike;
33
+ /** Independent outer bound in case the SDK promise/callback path stalls. */
34
+ wsReadyTimeoutMs?: number;
35
+ /** Called once when an already-ready socket exhausts reconnect attempts. */
36
+ onTerminalError?: (error: Error) => void;
37
+ }
3
38
  export declare class FeishuAdapter implements IMAdapter {
4
39
  readonly platform = "feishu";
5
40
  readonly ingressMode: IMIngressMode;
6
41
  private feishuConfig;
7
- private connectionName;
42
+ private connectionName_;
8
43
  private client;
9
44
  private wsClient;
45
+ private lifecycleGeneration;
46
+ private readonly options;
10
47
  /**
11
48
  * The bot's own open_id, resolved at init() via /open-apis/bot/v3/info.
12
49
  * Used to detect real @bot mentions (vs any mention) in group messages.
@@ -17,6 +54,24 @@ export declare class FeishuAdapter implements IMAdapter {
17
54
  /** Where to persist downloaded inbound media (M5c). */
18
55
  private mediaDir;
19
56
  private health_;
57
+ constructor(options?: FeishuAdapterOptions);
58
+ /**
59
+ * Snapshot of the current `access.allowFrom` list (from access.json).
60
+ * Used by the bridge's rate-limiter to exempt operator-vouched explicit
61
+ * sender ids from the DM flood limit (2026-06-29: Vincent's multi-turn
62
+ * heavy work was tripping the 3-msg/60s DM limit; explicit-listed
63
+ * users are already operator-trusted via the access whitelist, no
64
+ * need to also flood-limit them). Returns `[]` before `init()`. The
65
+ * wildcard `["*"]` allowlist does NOT count as "explicit" — that's
66
+ * the public-channel shape and still needs flood protection.
67
+ */
68
+ getAllowFrom(): readonly string[];
69
+ /**
70
+ * Read-only accessor used by bridge to resolve per-connection paths
71
+ * (RFC-020 §15 outbound-marker validation needs the connection name
72
+ * to build the allowed per-conversation directory prefix).
73
+ */
74
+ get connectionName(): string;
20
75
  init(config: IMChannelConfig): Promise<void>;
21
76
  start(onEvent: OnEventHandler): Promise<void>;
22
77
  stop(): Promise<void>;
@@ -26,4 +81,97 @@ export declare class FeishuAdapter implements IMAdapter {
26
81
  edit(_target: IMConversationRef, messageId: string, message: NormalizedIMMessage): Promise<void>;
27
82
  health(): IMAdapterHealth;
28
83
  }
84
+ /**
85
+ * Heuristic — does this text look like it contains markdown syntax that
86
+ * Feishu's plain-text `msg_type:"text"` would render as literal source?
87
+ * Catches the patterns Vincent's heavy work produces: tables (`|...|`
88
+ * + separator row), fenced code blocks (`` ``` ``), ATX headings (`#`),
89
+ * bold/italic (`**text**` / `*text*`), unordered/ordered lists, inline
90
+ * code (`` `code` ``), markdown links (`[label](url)`).
91
+ *
92
+ * Returns true for at least one match; the adapter then upgrades to
93
+ * `msg_type:"interactive"` with a `markdown` element. Returns false for
94
+ * plain prose so the text path stays untouched (no perf cost, no
95
+ * behavior change for non-markdown replies).
96
+ *
97
+ * Conservative — single-character matches (e.g., a `|` in prose, one
98
+ * `*` for emphasis-of-one-word that Feishu would render OK as text)
99
+ * are NOT enough to trigger. We want false-positives < false-negatives
100
+ * (a false-positive upgrade renders fine; a false-negative shows raw
101
+ * `|` and `**`).
102
+ */
103
+ export declare function looksLikeMarkdown(text: string): boolean;
104
+ /**
105
+ * Parse Feishu `message_type: "post"` content into plain text. Post
106
+ * content is a nested structure:
107
+ *
108
+ * { title?: string,
109
+ * content: Array<Array<{ tag: "text"|"img"|"a"|"at"|"emotion", ... }>>
110
+ * }
111
+ *
112
+ * Each top-level array entry is a paragraph; each paragraph is an array
113
+ * of typed segments. We flatten by:
114
+ * - prepending title (if present) as `<title>\n\n`
115
+ * - joining paragraphs with `\n\n`
116
+ * - joining segments within a paragraph in order
117
+ * - tag=text → emit segment.text as-is
118
+ * - tag=a → emit `[label](href)` (markdown link)
119
+ * - tag=at → emit `@user_name` (fallback to `@<user_id>` if no name)
120
+ * - tag=img → emit `[图片]` placeholder (actual download via maybeAttachImages)
121
+ * - tag=emotion → emit `[emoji]`
122
+ * - unknown tag → skip
123
+ *
124
+ * @internal exported for unit tests.
125
+ */
126
+ export declare function parsePostContent(rawJson: string): string;
127
+ /**
128
+ * Walk a Feishu `post` content JSON and collect all `image_key` values
129
+ * from `tag: "img"` segments. Used by `maybeAttachImages` to schedule
130
+ * downloads for every image in a 图文混排 message.
131
+ *
132
+ * @internal exported for unit tests.
133
+ */
134
+ export declare function extractPostImageKeys(rawJson: string): string[];
135
+ /**
136
+ * Sanitize a Feishu-supplied `file_name` so it's safe to append to a
137
+ * filesystem path. Strips `/`, `\`, `..`, control characters, and NUL
138
+ * bytes. Empty / all-stripped input falls back to a placeholder that
139
+ * uses the message id, so a hostile client can never write outside the
140
+ * conversation's `<mediaDir>/<convKey>/` directory.
141
+ *
142
+ * NOT a full display-safety pass — the LLM still sees the sanitized
143
+ * bytes and shouldn't render them as HTML/etc. That's a Layer above.
144
+ */
145
+ export declare function sanitizeFileName(raw: string, fallback: string): string;
146
+ /**
147
+ * Feishu text-message practical chunk threshold (RFC-020 §16).
148
+ *
149
+ * The official `im.message.create`/`reply` content limit for
150
+ * `msg_type:text` is ~30 KB JSON-encoded (`{"text":"..."}`), comfortably
151
+ * under what any reasonable bot reply produces. We chunk below that
152
+ * limit at 4000 CHARACTERS — gives a roomy safety margin for multi-byte
153
+ * UTF-8 and lets us split at paragraph boundaries cleanly. Chosen
154
+ * conservatively after Vincent 2026-06-30 ask "issue 发文字" (i.e.
155
+ * never silently fall back to PNG for "long" plain-text replies — they
156
+ * just chunk into multiple messages).
157
+ *
158
+ * Single-message ceiling, NOT a per-second rate limit (that's separate;
159
+ * RFC-020 §4.4).
160
+ */
161
+ export declare const FEISHU_TEXT_SINGLE_LIMIT = 4000;
162
+ /**
163
+ * Split a long text into chunks ≤ `maxChars`. Tries paragraph boundaries
164
+ * (`\n\n`), then line boundaries (`\n`), then word boundaries (space),
165
+ * then hard byte split. Output preserves the original text content
166
+ * (sum of chunks == original, modulo the boundary character that gets
167
+ * consumed by the split).
168
+ *
169
+ * If the input is already short enough, returns a single-element array.
170
+ */
171
+ export declare function splitTextForFeishu(text: string, maxChars: number): string[];
172
+ /**
173
+ * Lark errors may echo request/config values. Keep worker logs actionable while
174
+ * ensuring credentials and multiline payloads never cross the process boundary.
175
+ */
176
+ export declare function sanitizeFeishuWsError(rawError: unknown, appId: string, appSecret: string): Error;
29
177
  export {};
@@ -44,11 +44,20 @@ export interface FeishuBridgeOptions {
44
44
  * - stderr logger otherwise (standalone smoke debugging).
45
45
  */
46
46
  onEvent?: (event: NormalizedIMEvent) => Promise<void>;
47
+ /** Fatal WS failure after initial readiness (for worker lifecycle ownership). */
48
+ onTerminalError?: (error: Error) => void;
47
49
  }
48
50
  /** Bridge → parent: inbound IM event ready for think(). */
49
51
  export interface BridgeIncomingEnvelope {
50
52
  type: "event";
51
53
  event: NormalizedIMEvent;
54
+ /** Canonical outbound directory for this conversation (RFC-020 §15.1).
55
+ * Single source of truth — the agent-node injects this verbatim into
56
+ * the system prompt's "save files here" instruction, and the bridge
57
+ * whitelist accepts files only under this directory. Computed by the
58
+ * bridge from `event.conversation.conversationId` + `adapter
59
+ * .connectionName`. Trailing slash included. */
60
+ outboundDir?: string;
52
61
  }
53
62
  /** Parent → bridge: agent reply text for a previously-forwarded event. */
54
63
  export interface BridgeReplyEnvelope {
@@ -8,6 +8,31 @@ export interface FeishuAccessList {
8
8
  /** Feishu chat_ids the bot is permitted to listen in. */
9
9
  allowChats: string[];
10
10
  }
11
+ /**
12
+ * Outbound text-reply rendering mode (RFC-020 §16). Controls how
13
+ * `adapter.send` handles a text/markdown payload that doesn't already
14
+ * carry an `imagePath` / `files[]` (those upload routes are unaffected).
15
+ *
16
+ * - "plain" (DEFAULT): always send `msg_type:text`. Bot replies are
17
+ * fully copy-pasteable in Feishu; long replies are chunked into
18
+ * multiple text messages instead of being PNG-rendered. This is the
19
+ * correct default for issue/code/CLI bot replies where users want
20
+ * to grab the text. Vincent 2026-06-30 explicit ask: "issue 发文字".
21
+ *
22
+ * - "card": short markdown (bold, list, link, inline code) goes via
23
+ * schema 1.0 interactive card with `markdown` element — text stays
24
+ * copy-friendly + gets bolds/bullets styled. Heading/table/long
25
+ * fall back to plain text (no PNG). Suited to operators who want
26
+ * light formatting without losing copy.
27
+ *
28
+ * - "auto": preserve the pre-2026-06-30 behavior — markdown with
29
+ * headings / tables / >2000 chars is rendered to PNG via headless
30
+ * chromium (#329 path), short markdown goes to schema 1.0 card,
31
+ * plain text goes to msg_type:text. Highest fidelity at the cost
32
+ * of copy-paste. Opt-in for operators who genuinely need rendered
33
+ * tables / heading hierarchy in chat.
34
+ */
35
+ export type OutboundRenderMode = "plain" | "card" | "auto";
11
36
  export interface FeishuChannelConfig {
12
37
  appId: string;
13
38
  appSecret: string;
@@ -20,6 +45,12 @@ export interface FeishuChannelConfig {
20
45
  auditRaw: boolean;
21
46
  /** Per-task timeout in ms; default 5 min (RFC-020 §4.5). */
22
47
  taskTimeoutMs: number;
48
+ /**
49
+ * RFC-020 §16 outbound rendering mode for text replies. Default `"plain"`.
50
+ * See `OutboundRenderMode` for per-mode semantics. Channels that omit
51
+ * the field get `"plain"` — Vincent's "issue 发文字" default.
52
+ */
53
+ outboundRender: OutboundRenderMode;
23
54
  /**
24
55
  * Absolute path to the channel directory. The adapter writes downloaded
25
56
  * inbound media to `<channelDir>/media/` (M5c). Populated by the loader so
@@ -0,0 +1,88 @@
1
+ /**
2
+ * RFC-020 §17 — feishu inbound bridge → hub `/api/upload` integration.
3
+ *
4
+ * Vincent 2026-06-30: cross-machine agents (e.g. TM门户运维@toodadev2)
5
+ * delegated by the feishu-local bot can't read inbound attachments
6
+ * because the only thing they get is a host-local path that doesn't
7
+ * exist on the receiver's filesystem. #351 already wired agent-node's
8
+ * receiver to prefer `file_id` and pull via `GET /api/files/<id>` when
9
+ * present. This module is the SENDER half: the feishu bridge uploads
10
+ * each downloaded inbound file to the hub via `POST /api/upload`,
11
+ * gets back a `file_id`, and propagates it alongside the local path.
12
+ *
13
+ * Failure mode (load-bearing): every failure path returns `null` and
14
+ * the caller falls back to path-only. The bridge MUST NOT crash because
15
+ * the hub is down / overloaded / refused a 12 MiB cap. Single-host
16
+ * `agent-node` Read still works on the local path; only cross-host
17
+ * delegation degrades, no regression vs the pre-fix baseline.
18
+ *
19
+ * Concurrency cap: a single feishu message can carry many images; bursts
20
+ * to /api/upload would hammer the hub rate limit (60/hour per token).
21
+ * `uploadFilesToHubConcurrent` caps in-flight requests at `concurrency`
22
+ * (default 4) so a 20-image message takes 5 sequential rounds instead
23
+ * of 20 simultaneous open sockets + a likely rate-limit denial.
24
+ */
25
+ /**
26
+ * Hub upload limit, mirrors `server/src/uploads.ts:MAX_UPLOAD_BYTES`.
27
+ * Anything bigger is skipped at the bridge — the hub would 413 anyway.
28
+ */
29
+ export declare const HUB_UPLOAD_LIMIT_BYTES: number;
30
+ /** Default in-flight cap for `uploadFilesToHubConcurrent`. */
31
+ export declare const DEFAULT_UPLOAD_CONCURRENCY = 4;
32
+ /**
33
+ * One file uploaded to the hub. `mime` and `size` are from the local
34
+ * filesystem read; `file_id` and `path` come from the hub's response.
35
+ *
36
+ * `file_id` is the canonical cross-machine handle — receiving agent
37
+ * resolves to bytes via `GET /api/files/<file_id>`. `path` is the
38
+ * hub-machine-local absolute path the hub stored to; on the sender
39
+ * side it's purely informational (we already have the source file).
40
+ */
41
+ export interface HubUploadResult {
42
+ file_id: string;
43
+ path?: string;
44
+ mime?: string;
45
+ size?: number;
46
+ name?: string;
47
+ }
48
+ export interface HubUploadOpts {
49
+ /** Hub base URL, no trailing slash. Defaults to env. */
50
+ hubUrl: string;
51
+ /** Bearer token (ntok_ / utok_ / atok_). Defaults to env. */
52
+ authToken: string;
53
+ /** Optional MIME hint; sniffed by filename ext otherwise. */
54
+ mime?: string;
55
+ /** Optional override filename (defaults to basename(path)). */
56
+ name?: string;
57
+ /** Injectable for tests. Defaults to global fetch. */
58
+ fetch?: typeof fetch;
59
+ }
60
+ /**
61
+ * Upload one local file to `${hubUrl}/api/upload`. Returns the file
62
+ * descriptor on success, or `null` on any failure (caller falls back
63
+ * to path-only).
64
+ *
65
+ * - File must exist + be ≤ 12 MiB. Larger → skipped silently with
66
+ * a stderr warn; caller gets `null` and ships path-only.
67
+ * - Auth via `Authorization: Bearer <token>`.
68
+ * - `Content-Length` header is set by `fetch` when given a Blob body;
69
+ * the hub validates it before consuming bytes.
70
+ * - Multipart body with a single `file` field. The hub server
71
+ * reads `form.get("file")` and stores under a new file_id.
72
+ * - Any network error / non-2xx / malformed JSON response → null.
73
+ */
74
+ export declare function uploadToHub(filePath: string, opts: HubUploadOpts): Promise<HubUploadResult | null>;
75
+ /**
76
+ * Upload many files to the hub with a bounded concurrency window.
77
+ * Returns one result per input path, preserving order. A failed
78
+ * upload appears as `null` at that slot — caller composes path-only
79
+ * descriptors for those.
80
+ *
81
+ * Why this matters: one feishu post can carry an arbitrary number of
82
+ * images. Naive `Promise.all(paths.map(uploadToHub))` would open N
83
+ * simultaneous HTTPS sockets to the hub AND blow past the 60/hour
84
+ * per-token rate limit on a single message with >60 images. A
85
+ * concurrency cap of 4 means N/4 sequential rounds, predictable
86
+ * load, no head-of-line blocking on the agent's reply.
87
+ */
88
+ export declare function uploadFilesToHubConcurrent(filePaths: string[], opts: HubUploadOpts, concurrency?: number): Promise<(HubUploadResult | null)[]>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * RFC-020 §14 — markdown → image rendering for Feishu replies.
3
+ *
4
+ * Vincent 2026-06-29 path: Feishu's `markdown` card element doesn't
5
+ * render ATX headings or GFM tables (preview.7 caught only bold / list
6
+ * / link). Rather than partially support a moving subset of card
7
+ * elements, we render structured markdown to a PNG via headless
8
+ * chromium and send it through Feishu's image API (`im:resource:upload`
9
+ * scope). Pixel-perfect fidelity, zero schema fragility, text-not-
10
+ * copyable accepted as the tradeoff for "actually renders".
11
+ *
12
+ * Hybrid route (decided in adapter.send):
13
+ * - plain text (no markdown markers) → msg_type:"text"
14
+ * - markdown WITHOUT heading / table / long → msg_type:"interactive" (schema 1.0 card with `markdown` element — keeps copy/paste for short bold/list/link replies; preview.7 path)
15
+ * - markdown WITH heading / table / long → THIS PATH (msg_type:"image" with rendered PNG)
16
+ *
17
+ * Renderer choice: `puppeteer-core` (no bundled chromium) + system
18
+ * chromium (apt install in Docker). Rationale:
19
+ * - Pure-JS canvas-layout libraries (node-canvas, @napi-rs/canvas)
20
+ * cost 4-6h of manual paragraph wrapping + table cell measurement
21
+ * + Chinese width metrics; chromium does this natively.
22
+ * - puppeteer-core (5MB) + system chromium (~100MB) is smaller than
23
+ * full puppeteer (170MB) which bundles its own chromium.
24
+ * - Headless screenshot ~500ms after warmup. Bot's heavy turn is 20-
25
+ * 70s; rendering cost is in the noise.
26
+ *
27
+ * Chromium reuse: we keep a single browser instance hot across
28
+ * renderings to avoid the ~2-3s cold-start per call. Auto-close on
29
+ * idle is a follow-up — agent-node worker lifetime is bounded.
30
+ */
31
+ import { Buffer } from "node:buffer";
32
+ /**
33
+ * Close the shared browser. Called from adapter.stop() during graceful
34
+ * worker shutdown. Subsequent render calls re-launch.
35
+ */
36
+ export declare function closeBrowser(): Promise<void>;
37
+ /**
38
+ * Render markdown text to a PNG buffer.
39
+ *
40
+ * Width is fixed at 800px (IM-friendly); height auto-fits content via
41
+ * `fullPage:true`. Returns the raw PNG bytes — caller hands them to
42
+ * `lark.im.image.create({image: Readable.from(buffer)})`.
43
+ *
44
+ * Throws on chromium launch failure or page navigation failure.
45
+ * Caller is responsible for fallback (e.g., send the raw text as a
46
+ * code block in the schema 1.0 card path).
47
+ */
48
+ export declare function renderMarkdownToPng(text: string): Promise<Buffer>;
49
+ /**
50
+ * Decide whether `renderMarkdownToPng` should be invoked for this reply
51
+ * text, vs. falling back to msg_type:"text" / msg_type:"interactive"
52
+ * (schema 1.0 card). Locked by 通信龙 3f70044c — trigger image when:
53
+ * - text contains a markdown table (Feishu card can't render)
54
+ * - text contains an ATX heading (Feishu card can't render)
55
+ * - text length > 2000 chars (image is better than scrollable text wall)
56
+ *
57
+ * Returns false for short prose, single-line plain text, and short
58
+ * markdown lists / bold / link / inline-code which DO render fine in
59
+ * the schema 1.0 `markdown` card element.
60
+ */
61
+ export declare function shouldRenderAsImage(text: string): boolean;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * RFC-020 §15 — agent→bridge outbound file protocol.
3
+ *
4
+ * 2026-06-29 Vincent UAT: agent generated a PDF via reportlab + wrote it
5
+ * to `/work/feishu-attachments/<conv>/x.pdf`, then in text asked Vincent
6
+ * "I'm not sure if the system will auto-attach this — try another way?"
7
+ * The bridge has `adapter.send({files})` since #329, but no protocol
8
+ * told the agent how to signal "dispatch this file to the user".
9
+ *
10
+ * Convention: in its final reply, the agent emits one
11
+ *
12
+ * [[send-file:/abs/path]]
13
+ *
14
+ * marker per file (one per line, end of reply). The bridge:
15
+ *
16
+ * 1. extracts every `[[send-file:...]]` token,
17
+ * 2. strips them from the user-visible text,
18
+ * 3. validates each path against an allowed-outbound-root whitelist
19
+ * (`/work/feishu-attachments/<conn>/<chat>/` — same per-conversation
20
+ * directory the LLM has Write access to under Layer B),
21
+ * 4. checks the file exists, is non-empty, and ≤ 30 MB (Feishu file_v1
22
+ * upper bound),
23
+ * 5. magic-byte sniffs to choose `image_key` vs `file_key` upload route,
24
+ * 6. dispatches each file via `adapter.send({files:[...]})`,
25
+ * 7. emits a friendly fallback if validation fails — never the raw error.
26
+ *
27
+ * Pure module — no I/O, no IM SDK calls. The parser returns the cleaned
28
+ * text + a structured list of file requests; bridge.ts is responsible
29
+ * for filesystem checks and dispatching.
30
+ */
31
+ /**
32
+ * One outbound-file request parsed from an agent reply.
33
+ *
34
+ * - `raw`: the bytes between `[[send-file:` and `]]`, before any
35
+ * normalization. Kept for stderr forensic.
36
+ * - `normalized`: lexical-normalized absolute path (quote-strip, `..`
37
+ * collapse, `//` collapse, `./` resolution against cwd). The
38
+ * `validatedAbsolute` field is set later by the bridge after the
39
+ * realpath / whitelist / exists check.
40
+ */
41
+ export interface OutboundFileRequest {
42
+ raw: string;
43
+ normalized: string;
44
+ }
45
+ export interface MarkerParseResult {
46
+ /** Reply text with all `[[send-file:...]]` markers removed + adjacent
47
+ * whitespace collapsed. May be empty (file-only reply). */
48
+ cleanedText: string;
49
+ /** Files in source order. Caller-side validation happens after. */
50
+ files: OutboundFileRequest[];
51
+ }
52
+ /**
53
+ * Lexically normalize a path token from the marker. NOT a full
54
+ * symlink/realpath resolver — bridge does that after this returns.
55
+ *
56
+ * - Trim leading / trailing whitespace.
57
+ * - Strip one layer of matching quotes (`"/x"` / `'/x'` → `/x`).
58
+ * - `.` and `..` segments collapse via `path.posix.normalize`.
59
+ * - Repeated `/` collapse.
60
+ *
61
+ * Returns an absolute path or the empty string if the input cannot be
62
+ * interpreted as an absolute path token (caller drops empties).
63
+ */
64
+ export declare function normalizeMarkerPath(raw: string): string;
65
+ /**
66
+ * Parse an agent reply into cleaned text + outbound-file requests.
67
+ *
68
+ * Empty / null input returns `{cleanedText: "", files: []}` so callers
69
+ * can safely pipe through without defensive checks.
70
+ *
71
+ * Marker-only reply (no surrounding text) returns `cleanedText: ""` —
72
+ * caller should NOT send an empty text message in that case; they only
73
+ * dispatch the files.
74
+ *
75
+ * Multiple markers on one reply are all captured. Adjacent whitespace
76
+ * around stripped markers is collapsed (no double blank lines).
77
+ */
78
+ export declare function parseOutboundMarkers(reply: string | null | undefined): MarkerParseResult;
79
+ export { OUTBOUND_ROOT, feishuConvKey, feishuOutboundDir } from "./outbound-paths.js";
80
+ /**
81
+ * Validate a normalized absolute path against the per-conversation
82
+ * whitelist + size + existence checks. Returns null on success or a
83
+ * Chinese user-friendly reason on failure (the same string can be
84
+ * surfaced as a fallback text reply when ALL files fail validation).
85
+ *
86
+ * Filesystem-touching — bridge calls this AFTER `parseOutboundMarkers`.
87
+ *
88
+ * Two API shapes accepted (overload):
89
+ *
90
+ * 1. **Preferred**: caller passes `expectedDir` — the precomputed
91
+ * `/work/feishu-attachments/<connectionName>/<convKey>/` value
92
+ * from `feishuOutboundDir`. This guarantees what the agent was
93
+ * TOLD to use (via cli.ts prompt injection) matches what we
94
+ * ACCEPT here — no second algorithm.
95
+ *
96
+ * 2. **Legacy**: caller passes `connectionName` + `convKey`. We
97
+ * compute the prefix here via the same helper. Kept for the
98
+ * single-arg unit-test ergonomics + backwards-compat (the old
99
+ * bridge wire-up shape).
100
+ *
101
+ * Params:
102
+ * - p: normalized absolute path candidate
103
+ * - expectedDir: precomputed whitelist directory (trailing `/`)
104
+ * OR
105
+ * - convKey + connectionName: legacy two-piece form
106
+ * - statFn: optional override for tests (defaults to fs.statSync)
107
+ * - realpathFn: optional override for tests (defaults to fs.realpathSync)
108
+ *
109
+ * The whitelist check uses literal `.startsWith(expectedDir)` — the
110
+ * trailing slash on `expectedDir` ensures `oc_abc/` doesn't accept
111
+ * paths under `oc_abc_evil/`.
112
+ */
113
+ export interface ValidateOpts {
114
+ p: string;
115
+ /** Precomputed prefix (with trailing `/`). Preferred over convKey+connectionName. */
116
+ expectedDir?: string;
117
+ convKey?: string;
118
+ connectionName?: string;
119
+ statFn?: (p: string) => {
120
+ size: number;
121
+ };
122
+ realpathFn?: (p: string) => string;
123
+ }
124
+ export declare function validateOutboundPath(opts: ValidateOpts): string | null;
125
+ /**
126
+ * Magic-byte sniffer — picks `image` vs `file` upload route. Reads the
127
+ * first 16 bytes only; safe to call before commitment. Returns:
128
+ *
129
+ * - "image" when the bytes are a PNG / JPG / GIF / WebP / BMP signature
130
+ * (Feishu image_v1 supports these natively, with chat inline render).
131
+ * - "file" otherwise (PDF, TXT, audio, video, archive, …).
132
+ *
133
+ * Caller supplies a Buffer (bridge reads via fs.openSync + readSync).
134
+ */
135
+ export declare function sniffFileKind(buf: Buffer): "image" | "file";
136
+ /**
137
+ * User-facing fallback message when ALL marker validations fail and the
138
+ * stripped text is empty (would leave the user with no reply at all).
139
+ */
140
+ export declare const ALL_FILES_FAILED_FALLBACK = "[\u6587\u4EF6\u9644\u4EF6\u53D1\u9001\u5931\u8D25] \u6211\u51C6\u5907\u597D\u4E86\u6587\u4EF6\u4F46\u5206\u53D1\u65F6\u51FA\u4E86\u95EE\u9898\u2014\u2014\u7A0D\u540E\u518D\u8BD5\u6216\u6362\u79CD\u65B9\u5F0F\u3002";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * RFC-020 §15.1 — feishu outbound/attachment path helpers (SHARED).
3
+ *
4
+ * Single source of truth for `/work/feishu-attachments/<conn>/<convKey>/`
5
+ * naming. Used by:
6
+ *
7
+ * - `adapter.ts` inbound `downloadImage` (subdir for received images),
8
+ * - `bridge.ts` outbound marker whitelist (where the agent may write),
9
+ * - `worker.ts` outbound envelope (the concrete path injected into the
10
+ * agent's prompt, so what we tell the agent matches what we accept).
11
+ *
12
+ * 2026-06-29 Vincent UAT: prior to this module, the inbound downloader
13
+ * used `conversationId.replace(/[^a-zA-Z0-9_-]/g, "_")` and the outbound
14
+ * bridge whitelist used `sender.id` for DMs / `chatId` for groups. The
15
+ * two algorithms diverged — agent learned the inbound oc_<chat>/ layout
16
+ * from incoming attachments, then wrote outbound files to the same
17
+ * oc_<chat>/ subdir, but the bridge required ou_<userid>/. Every PDF
18
+ * generated by the bot was rejected. This module unifies on
19
+ * `conversationId` (the open_chat_id assigned by Feishu — present for
20
+ * DMs and groups equally).
21
+ */
22
+ /**
23
+ * Sanitize a raw Feishu conversation id (`oc_<hex>` / occasionally
24
+ * `om_<id>` for DMs) into a filesystem-safe segment. Identical to the
25
+ * adapter.ts inbound download path policy — DO NOT CHANGE without also
26
+ * migrating any on-disk attachments under the old key.
27
+ *
28
+ * The regex strips anything outside `[A-Za-z0-9_-]`, replacing with `_`.
29
+ * Falsy / non-string input returns the empty string; caller is
30
+ * responsible for substituting a fallback (the empty key would resolve
31
+ * to `/work/feishu-attachments/<conn>//` which the path-prefix
32
+ * whitelist rejects cleanly).
33
+ */
34
+ export declare function feishuConvKey(rawId: string | undefined | null): string;
35
+ /**
36
+ * Compute the canonical per-conversation directory for outbound file
37
+ * dispatch + inbound attachment downloads. Trailing slash included so
38
+ * `.startsWith(...)` whitelist checks are unambiguous about the
39
+ * conversation boundary (no `oc_abc` matching `oc_abc_x`).
40
+ *
41
+ * Example:
42
+ * feishuOutboundDir("feishu-local", "oc_9a7eedbba275a87999f7ee2cfb10f4cb")
43
+ * → "/work/feishu-attachments/feishu-local/oc_9a7eedbba275a87999f7ee2cfb10f4cb/"
44
+ */
45
+ export declare function feishuOutboundDir(connectionName: string, rawConvId: string | undefined | null): string;
46
+ /**
47
+ * The fixed prefix root used as a sanity check by the marker validator.
48
+ * Useful for tests + for `validateOutboundPath` defense-in-depth.
49
+ */
50
+ export declare const OUTBOUND_ROOT = "/work/feishu-attachments";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * RFC-020 §16.1 — feishu outbound route decision (pure helper).
3
+ *
4
+ * Single source of truth for the `adapter.send` decision tree that
5
+ * picks among text / interactive card / image (rendered PNG) / file /
6
+ * imagePath outputs. Extracted into a pure module so the production
7
+ * call site AND the unit tests can bind the same function — previously
8
+ * the test mirrored the decision tree in a sibling `pickRoute` helper,
9
+ * which would drift silently if the production tree changed.
10
+ *
11
+ * Inputs are the minimal data the decision needs:
12
+ *
13
+ * - `text` / `imagePath` / `files` — payload from `NormalizedIMMessage`
14
+ * - `forceTextOnly` — caption-mode hint (RFC-020 §15.2)
15
+ * - `mode` — channel-config `outboundRender` (`"plain"` / `"card"` /
16
+ * `"auto"`, RFC-020 §16)
17
+ *
18
+ * Outputs the chosen `route` plus a short `reason` string for log /
19
+ * debug. The CALLER (adapter.send) actually performs the upload /
20
+ * render side-effects keyed on the route — this function does no I/O.
21
+ *
22
+ * Behavior contract (mirrors adapter.send line-for-line, locked by
23
+ * `feishu-outbound-render-mode.test.ts`):
24
+ *
25
+ * 1. `imagePath` set → `image_upload`
26
+ * 2. `files[0].path` set → `file_upload`
27
+ * 3. `forceTextOnly` true → `text`
28
+ * 4. mode "plain" → `text` (always)
29
+ * 5. mode "card" → `card_short_md` iff `looksLikeMarkdown(text)` AND
30
+ * NOT `shouldRenderAsImage(text)`; else `text`
31
+ * 6. mode "auto" → `image_render` iff `shouldRenderAsImage(text)`;
32
+ * else `card_short_md` iff `looksLikeMarkdown(text)`; else `text`
33
+ * 7. unknown mode (defensive default-default) → `text`
34
+ *
35
+ * The production switch in adapter.send may add the actual upload /
36
+ * render / lark API calls keyed on the route, but the route itself
37
+ * is decided HERE. To change the decision, change this file.
38
+ */
39
+ export type OutboundRoute = "image_upload" | "file_upload" | "text" | "card_short_md" | "image_render";
40
+ export interface RouteInput {
41
+ text?: string;
42
+ imagePath?: string;
43
+ files?: {
44
+ name: string;
45
+ path?: string;
46
+ }[];
47
+ forceTextOnly?: boolean;
48
+ /** `outboundRender` from the channel config. Defaults to "plain" when
49
+ * absent so a caller that doesn't read the config still gets the
50
+ * "Vincent default" behavior. */
51
+ mode?: "plain" | "card" | "auto";
52
+ }
53
+ export interface RouteDecision {
54
+ route: OutboundRoute;
55
+ reason: string;
56
+ }
57
+ /**
58
+ * Decide the outbound route for a `NormalizedIMMessage` (or a subset
59
+ * of its fields). Pure — no I/O, no SDK calls; safe to call from tests
60
+ * directly.
61
+ */
62
+ export declare function resolveOutboundRoute(input: RouteInput): RouteDecision;
@@ -0,0 +1,13 @@
1
+ interface WorkerProcessBoundary {
2
+ stderr: {
3
+ write(message: string): unknown;
4
+ };
5
+ exit(code: number): unknown;
6
+ }
7
+ /**
8
+ * A terminal WS error means this worker no longer owns a live ingress path.
9
+ * Exit non-zero so the parent/supervisor cannot continue advertising it as
10
+ * healthy. Kept separate from worker.ts so the real exit contract is testable.
11
+ */
12
+ export declare function exitFeishuWorker(error: Error, boundary?: WorkerProcessBoundary): void;
13
+ export {};