@rynx-ai/plugin-channel-lark 0.1.9
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.
- package/dist/cli-mirror/mirror-index.d.ts +46 -0
- package/dist/cli-mirror/mirror-index.js +48 -0
- package/dist/cli-mirror/rollout-mirror.d.ts +85 -0
- package/dist/cli-mirror/rollout-mirror.js +333 -0
- package/dist/cli-mirror/rollout-watcher.d.ts +109 -0
- package/dist/cli-mirror/rollout-watcher.js +347 -0
- package/dist/host-adapters.d.ts +82 -0
- package/dist/host-adapters.js +404 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/lark-card-stream.d.ts +363 -0
- package/dist/lark-card-stream.js +1335 -0
- package/dist/lark-content.d.ts +30 -0
- package/dist/lark-content.js +138 -0
- package/dist/lark-conversation-directory.d.ts +65 -0
- package/dist/lark-conversation-directory.js +106 -0
- package/dist/lark-diff-card.d.ts +25 -0
- package/dist/lark-diff-card.js +58 -0
- package/dist/lark-fork-transcript.d.ts +16 -0
- package/dist/lark-fork-transcript.js +141 -0
- package/dist/lark-resume-card.d.ts +31 -0
- package/dist/lark-resume-card.js +105 -0
- package/dist/lark-sdk/client.d.ts +8 -0
- package/dist/lark-sdk/client.js +32 -0
- package/dist/lark-sdk/index.d.ts +1 -0
- package/dist/lark-sdk/index.js +1 -0
- package/dist/lark-sender.d.ts +114 -0
- package/dist/lark-sender.js +323 -0
- package/dist/lark-session-store.d.ts +13 -0
- package/dist/lark-session-store.js +14 -0
- package/dist/lark-settings-card.d.ts +55 -0
- package/dist/lark-settings-card.js +158 -0
- package/dist/lark-setup.d.ts +17 -0
- package/dist/lark-setup.js +86 -0
- package/dist/lark.d.ts +500 -0
- package/dist/lark.js +2010 -0
- package/dist/runtime.d.ts +16 -0
- package/dist/runtime.js +157 -0
- package/dist/runtime.mjs +130863 -0
- package/package.json +35 -0
- package/rynx-plugin.json +42 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { CodexSessionStore, ConversationSessionStore } from "@rynx-ai/core";
|
|
2
|
+
import type { LarkConversationDirectory } from "../lark-conversation-directory.js";
|
|
3
|
+
/**
|
|
4
|
+
* Resolves a Codex thread id (the rollout-file UUID) back to the Feishu
|
|
5
|
+
* conversation it's bound to, so a CLI-driven turn observed on disk can be
|
|
6
|
+
* mirrored into the right chat. Reverse-walks the three stores the bridge
|
|
7
|
+
* already maintains:
|
|
8
|
+
*
|
|
9
|
+
* codexSessionId
|
|
10
|
+
* → `.codex-proxy/sessions.json` (CodexSessionStore) → localThreadId (= sessionId)
|
|
11
|
+
* → `.codex-proxy/lark-sessions.json` (ConversationSessionStore) → conversationKey
|
|
12
|
+
* → `.codex-proxy/lark-conversations.json` (LarkConversationDirectory) → chatId/threadId/participants
|
|
13
|
+
*
|
|
14
|
+
* The middle step is a reverse map lookup, not an id parse: the session id is
|
|
15
|
+
* opaque (`sess_<uuid>`), so the conversation key lives only in the routing map.
|
|
16
|
+
*/
|
|
17
|
+
export interface MirrorTarget {
|
|
18
|
+
codexSessionId: string;
|
|
19
|
+
localThreadId: string;
|
|
20
|
+
conversationKey: string;
|
|
21
|
+
chatId: string;
|
|
22
|
+
threadId?: string;
|
|
23
|
+
/** open_ids that have participated (used to @-mention the user). */
|
|
24
|
+
participants: string[];
|
|
25
|
+
model: string;
|
|
26
|
+
}
|
|
27
|
+
export interface BoundSession {
|
|
28
|
+
codexSessionId: string;
|
|
29
|
+
conversationKey: string;
|
|
30
|
+
}
|
|
31
|
+
export declare class CodexMirrorIndex {
|
|
32
|
+
private readonly codexSessionStore;
|
|
33
|
+
private readonly conversationSessionStore;
|
|
34
|
+
private readonly conversationDirectory;
|
|
35
|
+
constructor({ codexSessionStore, conversationSessionStore, conversationDirectory, }: {
|
|
36
|
+
codexSessionStore: Pick<CodexSessionStore, "listAll" | "findByCodexSessionId">;
|
|
37
|
+
conversationSessionStore: Pick<ConversationSessionStore, "findKeyBySessionId">;
|
|
38
|
+
conversationDirectory: Pick<LarkConversationDirectory, "get">;
|
|
39
|
+
});
|
|
40
|
+
/** codexSessionId → the full send target, or null when the thread isn't
|
|
41
|
+
* bound, its session isn't routed, or the conversation was never recorded. */
|
|
42
|
+
resolveTarget(codexSessionId: string): Promise<MirrorTarget | null>;
|
|
43
|
+
/** All Codex threads currently bound to a Feishu conversation (those whose
|
|
44
|
+
* session id resolves to a routing key). */
|
|
45
|
+
listBoundCodexSessionIds(): Promise<BoundSession[]>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export class CodexMirrorIndex {
|
|
2
|
+
codexSessionStore;
|
|
3
|
+
conversationSessionStore;
|
|
4
|
+
conversationDirectory;
|
|
5
|
+
constructor({ codexSessionStore, conversationSessionStore, conversationDirectory, }) {
|
|
6
|
+
this.codexSessionStore = codexSessionStore;
|
|
7
|
+
this.conversationSessionStore = conversationSessionStore;
|
|
8
|
+
this.conversationDirectory = conversationDirectory;
|
|
9
|
+
}
|
|
10
|
+
/** codexSessionId → the full send target, or null when the thread isn't
|
|
11
|
+
* bound, its session isn't routed, or the conversation was never recorded. */
|
|
12
|
+
async resolveTarget(codexSessionId) {
|
|
13
|
+
const record = await this.codexSessionStore.findByCodexSessionId(codexSessionId);
|
|
14
|
+
if (!record) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const conversationKey = await this.conversationSessionStore.findKeyBySessionId(record.localThreadId);
|
|
18
|
+
if (!conversationKey) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const directory = await this.conversationDirectory.get(conversationKey);
|
|
22
|
+
if (!directory) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
codexSessionId,
|
|
27
|
+
localThreadId: record.localThreadId,
|
|
28
|
+
conversationKey,
|
|
29
|
+
chatId: directory.chatId,
|
|
30
|
+
threadId: directory.threadId,
|
|
31
|
+
participants: directory.participants ?? [],
|
|
32
|
+
model: record.model,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** All Codex threads currently bound to a Feishu conversation (those whose
|
|
36
|
+
* session id resolves to a routing key). */
|
|
37
|
+
async listBoundCodexSessionIds() {
|
|
38
|
+
const records = await this.codexSessionStore.listAll();
|
|
39
|
+
const bound = [];
|
|
40
|
+
for (const record of records) {
|
|
41
|
+
const conversationKey = await this.conversationSessionStore.findKeyBySessionId(record.localThreadId);
|
|
42
|
+
if (conversationKey) {
|
|
43
|
+
bound.push({ codexSessionId: record.codexSessionId, conversationKey });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return bound;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { CodexMirrorIndex } from "./mirror-index.js";
|
|
2
|
+
import { CodexRolloutWatcher, type RolloutEvent, type RolloutWatcherDeps } from "./rollout-watcher.js";
|
|
3
|
+
import type { LarkMessageSender } from "../lark.js";
|
|
4
|
+
/** What the mirror needs from the message sender. `createText` is required (the
|
|
5
|
+
* user-message relay); the card methods are optional (degrade to plain text). */
|
|
6
|
+
export type MirrorSender = Pick<LarkMessageSender, "createText"> & Partial<Pick<LarkMessageSender, "createCard" | "batchUpdateCard" | "sendInteractive">>;
|
|
7
|
+
/** Read-only view of which conversations have a bridge-driven turn running, so
|
|
8
|
+
* the mirror can skip those (they're already shown live in Feishu). */
|
|
9
|
+
export interface InFlightView {
|
|
10
|
+
isInFlight(conversationKey: string): boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface RolloutMirrorLogger {
|
|
13
|
+
log(entry: Record<string, unknown>): void;
|
|
14
|
+
}
|
|
15
|
+
export interface CodexRolloutMirrorOptions {
|
|
16
|
+
index: Pick<CodexMirrorIndex, "resolveTarget" | "listBoundCodexSessionIds">;
|
|
17
|
+
messageSender: MirrorSender;
|
|
18
|
+
inFlight: InFlightView;
|
|
19
|
+
cardThrottleMs?: number;
|
|
20
|
+
reconcileIntervalMs?: number;
|
|
21
|
+
pollMs?: number;
|
|
22
|
+
/** This bot's own open_id, excluded from the @-mention target. */
|
|
23
|
+
botOpenId?: string | null;
|
|
24
|
+
logger?: RolloutMirrorLogger;
|
|
25
|
+
/**
|
|
26
|
+
* Sessions roots scanned for rollout files, newest runtime first. Lets the
|
|
27
|
+
* mirror cover both codex (`~/.codex/sessions`) and traex
|
|
28
|
+
* (`~/.trae/cli/sessions`). Defaults to the watcher's built-in codex root.
|
|
29
|
+
*/
|
|
30
|
+
sessionsRoots?: string[];
|
|
31
|
+
/** Inject a watcher (tests). Defaults to a real {@link CodexRolloutWatcher}. */
|
|
32
|
+
watcher?: CodexRolloutWatcher;
|
|
33
|
+
/** Filesystem seam forwarded to the default watcher (tests). */
|
|
34
|
+
watcherDeps?: Partial<RolloutWatcherDeps>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Mirrors CLI-driven Codex turns (a `codex resume` takeover from a terminal)
|
|
38
|
+
* back into the bound Feishu conversation. Owns a {@link CodexRolloutWatcher},
|
|
39
|
+
* turns its {@link RolloutEvent}s into a per-session state machine, relays each
|
|
40
|
+
* user message as text, and drives a live-updating standalone card for each
|
|
41
|
+
* assistant reply — reusing the same card pipeline as the live Feishu flow.
|
|
42
|
+
*/
|
|
43
|
+
export declare class CodexRolloutMirror {
|
|
44
|
+
private readonly index;
|
|
45
|
+
private readonly sender;
|
|
46
|
+
private readonly inFlight;
|
|
47
|
+
private readonly cardThrottleMs;
|
|
48
|
+
private readonly reconcileIntervalMs;
|
|
49
|
+
private readonly botOpenId;
|
|
50
|
+
private readonly logger;
|
|
51
|
+
private readonly watcher;
|
|
52
|
+
private readonly states;
|
|
53
|
+
private readonly tails;
|
|
54
|
+
private reconcileTimer;
|
|
55
|
+
constructor(options: CodexRolloutMirrorOptions);
|
|
56
|
+
start(): Promise<void>;
|
|
57
|
+
stop(): Promise<void>;
|
|
58
|
+
/** Called by the ingestor when it observes a Codex thread binding, so we
|
|
59
|
+
* begin watching that rollout file immediately (with the key in hand). */
|
|
60
|
+
noteBoundSession(codexSessionId: string): Promise<void>;
|
|
61
|
+
/** Called by the ingestor when a bridge-driven turn completes, to claim the
|
|
62
|
+
* lines the bridge itself wrote so the watcher won't re-mirror them. */
|
|
63
|
+
claimToEof(codexSessionId: string): Promise<void>;
|
|
64
|
+
/** Public entry point for the per-session serialized processing (also the
|
|
65
|
+
* test seam — feed {@link RolloutEvent}s directly without a real watcher). */
|
|
66
|
+
handleEvent(event: RolloutEvent): Promise<void>;
|
|
67
|
+
private enqueue;
|
|
68
|
+
private reconcile;
|
|
69
|
+
private getState;
|
|
70
|
+
/** Resolve (and cache) the Feishu target. Returns null when unbound/unrecorded. */
|
|
71
|
+
private resolveTarget;
|
|
72
|
+
private process;
|
|
73
|
+
private relayUserMessage;
|
|
74
|
+
private sendTextChunks;
|
|
75
|
+
/** `@<user> 发送内容"xxxxxx"`, at-mentioning the single non-bot participant
|
|
76
|
+
* when known. Returns both the mentioned form and a plain fallback. */
|
|
77
|
+
private buildRelayText;
|
|
78
|
+
private canUseCard;
|
|
79
|
+
private ensureCard;
|
|
80
|
+
private ingest;
|
|
81
|
+
private finalizeCard;
|
|
82
|
+
}
|
|
83
|
+
/** Map rollout tool names onto the names the card pipeline's `classifyToolKind`
|
|
84
|
+
* understands, so command/web-search tools render with the right icon/kind. */
|
|
85
|
+
export declare function normalizeToolName(name: string): string;
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CodexRolloutWatcher } from "./rollout-watcher.js";
|
|
3
|
+
import { createInitialCardState, LarkTurnCardController, } from "../lark-card-stream.js";
|
|
4
|
+
import { chunkLarkReplyText } from "../lark-content.js";
|
|
5
|
+
import { deriveConversationShortId } from "../lark-conversation-directory.js";
|
|
6
|
+
const RELAY_TEXT_MAX_CHARS = 2000;
|
|
7
|
+
const defaultLogger = {
|
|
8
|
+
log(entry) {
|
|
9
|
+
console.log(JSON.stringify({ level: "info", type: "lark_mirror", ...entry }));
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Mirrors CLI-driven Codex turns (a `codex resume` takeover from a terminal)
|
|
14
|
+
* back into the bound Feishu conversation. Owns a {@link CodexRolloutWatcher},
|
|
15
|
+
* turns its {@link RolloutEvent}s into a per-session state machine, relays each
|
|
16
|
+
* user message as text, and drives a live-updating standalone card for each
|
|
17
|
+
* assistant reply — reusing the same card pipeline as the live Feishu flow.
|
|
18
|
+
*/
|
|
19
|
+
export class CodexRolloutMirror {
|
|
20
|
+
index;
|
|
21
|
+
sender;
|
|
22
|
+
inFlight;
|
|
23
|
+
cardThrottleMs;
|
|
24
|
+
reconcileIntervalMs;
|
|
25
|
+
botOpenId;
|
|
26
|
+
logger;
|
|
27
|
+
watcher;
|
|
28
|
+
states = new Map();
|
|
29
|
+
tails = new Map();
|
|
30
|
+
reconcileTimer = null;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.index = options.index;
|
|
33
|
+
this.sender = options.messageSender;
|
|
34
|
+
this.inFlight = options.inFlight;
|
|
35
|
+
this.cardThrottleMs = options.cardThrottleMs ?? 250;
|
|
36
|
+
this.reconcileIntervalMs = options.reconcileIntervalMs ?? 30000;
|
|
37
|
+
this.botOpenId = options.botOpenId ?? null;
|
|
38
|
+
this.logger = options.logger ?? defaultLogger;
|
|
39
|
+
const watcherDeps = { ...options.watcherDeps };
|
|
40
|
+
const roots = options.sessionsRoots?.filter(Boolean) ?? [];
|
|
41
|
+
if (roots.length > 0 && watcherDeps.sessionsRoot === undefined) {
|
|
42
|
+
watcherDeps.sessionsRoot = roots[0];
|
|
43
|
+
watcherDeps.extraSessionsRoots = roots.slice(1);
|
|
44
|
+
}
|
|
45
|
+
this.watcher =
|
|
46
|
+
options.watcher ??
|
|
47
|
+
new CodexRolloutWatcher((event) => void this.enqueue(event), {
|
|
48
|
+
deps: watcherDeps,
|
|
49
|
+
pollMs: options.pollMs,
|
|
50
|
+
logger: { log: (entry) => this.logger.log({ event: "mirror.watcher", ...entry }) },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async start() {
|
|
54
|
+
let registered = 0;
|
|
55
|
+
try {
|
|
56
|
+
const bound = await this.index.listBoundCodexSessionIds();
|
|
57
|
+
for (const { codexSessionId } of bound) {
|
|
58
|
+
await this.watcher.register(codexSessionId);
|
|
59
|
+
registered += 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
this.logger.log({ event: "mirror.start_failed", error: errMsg(error) });
|
|
64
|
+
}
|
|
65
|
+
this.logger.log({ event: "mirror.started", registered });
|
|
66
|
+
this.reconcileTimer = setInterval(() => void this.reconcile(), this.reconcileIntervalMs);
|
|
67
|
+
this.reconcileTimer.unref?.();
|
|
68
|
+
}
|
|
69
|
+
async stop() {
|
|
70
|
+
if (this.reconcileTimer) {
|
|
71
|
+
clearInterval(this.reconcileTimer);
|
|
72
|
+
this.reconcileTimer = null;
|
|
73
|
+
}
|
|
74
|
+
this.watcher.stop();
|
|
75
|
+
await Promise.all([...this.states.values()].map((state) => this.finalizeCard(state)));
|
|
76
|
+
this.states.clear();
|
|
77
|
+
}
|
|
78
|
+
/** Called by the ingestor when it observes a Codex thread binding, so we
|
|
79
|
+
* begin watching that rollout file immediately (with the key in hand). */
|
|
80
|
+
async noteBoundSession(codexSessionId) {
|
|
81
|
+
try {
|
|
82
|
+
await this.watcher.register(codexSessionId);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
this.logger.log({ event: "mirror.note_failed", codex_session_id: codexSessionId, error: errMsg(error) });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Called by the ingestor when a bridge-driven turn completes, to claim the
|
|
89
|
+
* lines the bridge itself wrote so the watcher won't re-mirror them. */
|
|
90
|
+
async claimToEof(codexSessionId) {
|
|
91
|
+
await this.watcher.claimToEof(codexSessionId);
|
|
92
|
+
}
|
|
93
|
+
/** Public entry point for the per-session serialized processing (also the
|
|
94
|
+
* test seam — feed {@link RolloutEvent}s directly without a real watcher). */
|
|
95
|
+
handleEvent(event) {
|
|
96
|
+
return this.enqueue(event);
|
|
97
|
+
}
|
|
98
|
+
enqueue(event) {
|
|
99
|
+
const key = event.codexSessionId;
|
|
100
|
+
const previous = this.tails.get(key) ?? Promise.resolve();
|
|
101
|
+
const next = previous.catch(() => undefined).then(() => this.process(event));
|
|
102
|
+
const tracked = next.finally(() => {
|
|
103
|
+
if (this.tails.get(key) === tracked) {
|
|
104
|
+
this.tails.delete(key);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
this.tails.set(key, tracked);
|
|
108
|
+
return tracked;
|
|
109
|
+
}
|
|
110
|
+
async reconcile() {
|
|
111
|
+
try {
|
|
112
|
+
const bound = await this.index.listBoundCodexSessionIds();
|
|
113
|
+
for (const { codexSessionId } of bound) {
|
|
114
|
+
await this.watcher.register(codexSessionId); // idempotent
|
|
115
|
+
}
|
|
116
|
+
await this.watcher.relocatePending();
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
this.logger.log({ event: "mirror.reconcile_failed", error: errMsg(error) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
getState(codexSessionId) {
|
|
123
|
+
let state = this.states.get(codexSessionId);
|
|
124
|
+
if (!state) {
|
|
125
|
+
state = {
|
|
126
|
+
target: null,
|
|
127
|
+
targetResolved: false,
|
|
128
|
+
unresolvedLogged: false,
|
|
129
|
+
controller: null,
|
|
130
|
+
lastFinal: null,
|
|
131
|
+
fallbackFinal: null,
|
|
132
|
+
};
|
|
133
|
+
this.states.set(codexSessionId, state);
|
|
134
|
+
}
|
|
135
|
+
return state;
|
|
136
|
+
}
|
|
137
|
+
/** Resolve (and cache) the Feishu target. Returns null when unbound/unrecorded. */
|
|
138
|
+
async resolveTarget(state, codexSessionId) {
|
|
139
|
+
if (state.targetResolved && state.target) {
|
|
140
|
+
return state.target;
|
|
141
|
+
}
|
|
142
|
+
const target = await this.index.resolveTarget(codexSessionId);
|
|
143
|
+
state.target = target;
|
|
144
|
+
state.targetResolved = Boolean(target);
|
|
145
|
+
if (!target && !state.unresolvedLogged) {
|
|
146
|
+
state.unresolvedLogged = true;
|
|
147
|
+
this.logger.log({ event: "mirror.target_unresolved", codex_session_id: codexSessionId });
|
|
148
|
+
}
|
|
149
|
+
return target;
|
|
150
|
+
}
|
|
151
|
+
async process(event) {
|
|
152
|
+
const state = this.getState(event.codexSessionId);
|
|
153
|
+
const target = await this.resolveTarget(state, event.codexSessionId);
|
|
154
|
+
if (!target) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// De-dup gate (re-checked here at dequeue time): while the bridge drives a
|
|
158
|
+
// turn for this conversation, its rollout lines are already shown live in
|
|
159
|
+
// Feishu — skip them entirely.
|
|
160
|
+
if (this.inFlight.isInFlight(target.conversationKey)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
switch (event.kind) {
|
|
164
|
+
case "userMessage":
|
|
165
|
+
await this.finalizeCard(state);
|
|
166
|
+
await this.relayUserMessage(target, event.text);
|
|
167
|
+
return;
|
|
168
|
+
case "assistantCommentary":
|
|
169
|
+
await this.ensureCard(state, target);
|
|
170
|
+
await this.ingest(state, { type: "token", text: event.text });
|
|
171
|
+
return;
|
|
172
|
+
case "assistantFinal":
|
|
173
|
+
state.lastFinal = event.text;
|
|
174
|
+
state.fallbackFinal = event.text;
|
|
175
|
+
await this.ensureCard(state, target);
|
|
176
|
+
await this.ingest(state, { type: "message_completed", text: event.text });
|
|
177
|
+
return;
|
|
178
|
+
case "toolStart":
|
|
179
|
+
await this.ensureCard(state, target);
|
|
180
|
+
await this.ingest(state, {
|
|
181
|
+
type: "tool",
|
|
182
|
+
event: "on_tool_start",
|
|
183
|
+
name: normalizeToolName(event.name),
|
|
184
|
+
input: { id: event.callId, command: event.command },
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
case "toolEnd":
|
|
188
|
+
await this.ingest(state, {
|
|
189
|
+
type: "tool",
|
|
190
|
+
event: "on_tool_end",
|
|
191
|
+
name: "command_execution",
|
|
192
|
+
output: { id: event.callId, status: "completed", aggregatedOutput: event.output ?? null },
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
case "taskComplete":
|
|
196
|
+
await this.finalizeCard(state);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async relayUserMessage(target, text) {
|
|
201
|
+
const { mentioned, plain } = this.buildRelayText(target, text);
|
|
202
|
+
try {
|
|
203
|
+
await this.sendTextChunks(target.chatId, mentioned);
|
|
204
|
+
this.logger.log({
|
|
205
|
+
event: "mirror.user_relayed",
|
|
206
|
+
codex_session_id: target.codexSessionId,
|
|
207
|
+
conversation_key: target.conversationKey,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
// The @-mention can be rejected by some chats; never let that drop the
|
|
212
|
+
// user's question — retry once without the mention.
|
|
213
|
+
if (mentioned !== plain) {
|
|
214
|
+
try {
|
|
215
|
+
await this.sendTextChunks(target.chatId, plain);
|
|
216
|
+
this.logger.log({
|
|
217
|
+
event: "mirror.user_relayed",
|
|
218
|
+
codex_session_id: target.codexSessionId,
|
|
219
|
+
conversation_key: target.conversationKey,
|
|
220
|
+
fallback: "plain",
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
catch (retryError) {
|
|
225
|
+
this.logger.log({ event: "mirror.relay_failed", codex_session_id: target.codexSessionId, error: errMsg(retryError) });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.logger.log({ event: "mirror.relay_failed", codex_session_id: target.codexSessionId, error: errMsg(error) });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async sendTextChunks(chatId, text) {
|
|
233
|
+
for (const chunk of chunkLarkReplyText(text)) {
|
|
234
|
+
// Feishu caps the message `uuid` at 50 chars — a bare randomUUID (36)
|
|
235
|
+
// stays well under and is unique per chunk.
|
|
236
|
+
await this.sender.createText({ chatId, text: chunk, uuid: randomUUID() });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** `@<user> 发送内容"xxxxxx"`, at-mentioning the single non-bot participant
|
|
240
|
+
* when known. Returns both the mentioned form and a plain fallback. */
|
|
241
|
+
buildRelayText(target, text) {
|
|
242
|
+
const trimmed = text.length > RELAY_TEXT_MAX_CHARS ? `${text.slice(0, RELAY_TEXT_MAX_CHARS)}…` : text;
|
|
243
|
+
const plain = `发送内容"${trimmed}"`;
|
|
244
|
+
const participants = target.participants.filter((id) => id && id !== this.botOpenId);
|
|
245
|
+
const mentioned = participants.length === 1 ? `<at user_id="${participants[0]}"></at> ${plain}` : plain;
|
|
246
|
+
return { mentioned, plain };
|
|
247
|
+
}
|
|
248
|
+
canUseCard() {
|
|
249
|
+
return Boolean(this.sender.createCard && this.sender.batchUpdateCard && this.sender.sendInteractive);
|
|
250
|
+
}
|
|
251
|
+
async ensureCard(state, target) {
|
|
252
|
+
if (state.controller || !this.canUseCard()) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const controller = new LarkTurnCardController({
|
|
256
|
+
sender: this.sender,
|
|
257
|
+
session: { chatId: target.chatId, threadId: target.threadId },
|
|
258
|
+
initialState: createInitialCardState({
|
|
259
|
+
localThreadId: target.localThreadId,
|
|
260
|
+
model: target.model,
|
|
261
|
+
resumeShortId: deriveConversationShortId(target.conversationKey),
|
|
262
|
+
}),
|
|
263
|
+
throttleMs: this.cardThrottleMs,
|
|
264
|
+
logger: { log: (entry) => this.logger.log({ event: "mirror.card", ...entry }) },
|
|
265
|
+
// No stop button: the bridge can't cancel a CLI-driven turn.
|
|
266
|
+
});
|
|
267
|
+
try {
|
|
268
|
+
await controller.startStandalone();
|
|
269
|
+
state.controller = controller;
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
this.logger.log({ event: "mirror.card_start_failed", codex_session_id: target.codexSessionId, error: errMsg(error) });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async ingest(state, event) {
|
|
276
|
+
if (!state.controller) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
await state.controller.ingestEvent(event);
|
|
280
|
+
}
|
|
281
|
+
async finalizeCard(state) {
|
|
282
|
+
if (state.controller) {
|
|
283
|
+
const controller = state.controller;
|
|
284
|
+
state.controller = null;
|
|
285
|
+
const finalAnswer = state.lastFinal ?? undefined;
|
|
286
|
+
state.lastFinal = null;
|
|
287
|
+
state.fallbackFinal = null;
|
|
288
|
+
try {
|
|
289
|
+
await controller.finalize({ status: "completed", finalAnswer });
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
this.logger.log({ event: "mirror.card_finalize_failed", error: errMsg(error) });
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
// No card capability: emit the accumulated final answer as plain text.
|
|
297
|
+
if (state.fallbackFinal && state.target) {
|
|
298
|
+
const final = state.fallbackFinal;
|
|
299
|
+
state.fallbackFinal = null;
|
|
300
|
+
state.lastFinal = null;
|
|
301
|
+
try {
|
|
302
|
+
for (const chunk of chunkLarkReplyText(final)) {
|
|
303
|
+
await this.sender.createText({
|
|
304
|
+
chatId: state.target.chatId,
|
|
305
|
+
text: chunk,
|
|
306
|
+
uuid: randomUUID(),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
this.logger.log({ event: "mirror.final_relay_failed", error: errMsg(error) });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** Map rollout tool names onto the names the card pipeline's `classifyToolKind`
|
|
317
|
+
* understands, so command/web-search tools render with the right icon/kind. */
|
|
318
|
+
export function normalizeToolName(name) {
|
|
319
|
+
const lower = name.toLowerCase();
|
|
320
|
+
if (lower === "exec_command" || lower === "shell" || lower === "local_shell" || lower.includes("exec")) {
|
|
321
|
+
return "command_execution";
|
|
322
|
+
}
|
|
323
|
+
if (lower.includes("web_search")) {
|
|
324
|
+
return "web_search";
|
|
325
|
+
}
|
|
326
|
+
if (lower.startsWith("mcp")) {
|
|
327
|
+
return name;
|
|
328
|
+
}
|
|
329
|
+
return name;
|
|
330
|
+
}
|
|
331
|
+
function errMsg(error) {
|
|
332
|
+
return error instanceof Error ? error.message : String(error);
|
|
333
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
/**
|
|
3
|
+
* Tails Codex "rollout" files (`~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl`)
|
|
4
|
+
* and emits high-level conversation events. This is the only way to observe a
|
|
5
|
+
* session that the user took over from a terminal (`codex resume <id>`): the
|
|
6
|
+
* CLI is a separate process, so its turns surface only on disk — the bridge's
|
|
7
|
+
* own app-server connection never sees them.
|
|
8
|
+
*
|
|
9
|
+
* The watcher knows nothing about Feishu/cards; it just turns appended JSONL
|
|
10
|
+
* lines into {@link RolloutEvent}s. All filesystem access goes through an
|
|
11
|
+
* injectable {@link RolloutWatcherDeps} seam so the parser/state machine can be
|
|
12
|
+
* unit-tested by feeding synthetic text via {@link CodexRolloutWatcher.ingestAppendedText}.
|
|
13
|
+
*/
|
|
14
|
+
export type RolloutEvent = {
|
|
15
|
+
kind: "userMessage";
|
|
16
|
+
codexSessionId: string;
|
|
17
|
+
text: string;
|
|
18
|
+
ts?: string;
|
|
19
|
+
} | {
|
|
20
|
+
kind: "assistantCommentary";
|
|
21
|
+
codexSessionId: string;
|
|
22
|
+
text: string;
|
|
23
|
+
ts?: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: "assistantFinal";
|
|
26
|
+
codexSessionId: string;
|
|
27
|
+
text: string;
|
|
28
|
+
ts?: string;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "toolStart";
|
|
31
|
+
codexSessionId: string;
|
|
32
|
+
callId: string;
|
|
33
|
+
name: string;
|
|
34
|
+
command?: string;
|
|
35
|
+
ts?: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "toolEnd";
|
|
38
|
+
codexSessionId: string;
|
|
39
|
+
callId: string;
|
|
40
|
+
output?: string;
|
|
41
|
+
ts?: string;
|
|
42
|
+
} | {
|
|
43
|
+
kind: "taskComplete";
|
|
44
|
+
codexSessionId: string;
|
|
45
|
+
ts?: string;
|
|
46
|
+
};
|
|
47
|
+
export interface RolloutDirEntry {
|
|
48
|
+
name: string;
|
|
49
|
+
isDirectory: boolean;
|
|
50
|
+
isFile: boolean;
|
|
51
|
+
}
|
|
52
|
+
export interface RolloutWatcherDeps {
|
|
53
|
+
/** Root scanned for `rollout-*-<id>.jsonl`. Defaults to `~/.codex/sessions`. */
|
|
54
|
+
sessionsRoot: string;
|
|
55
|
+
/**
|
|
56
|
+
* Additional sessions roots scanned after {@link sessionsRoot}. Lets one
|
|
57
|
+
* watcher cover multiple runtimes (e.g. codex `~/.codex/sessions` plus
|
|
58
|
+
* traex `~/.trae/cli/sessions`) since a rollout id lives in exactly one.
|
|
59
|
+
*/
|
|
60
|
+
extraSessionsRoots?: string[];
|
|
61
|
+
readdir(dir: string): Promise<RolloutDirEntry[]>;
|
|
62
|
+
fileSize(filePath: string): Promise<number>;
|
|
63
|
+
/** Read bytes `[start, end)` from `filePath`. */
|
|
64
|
+
readRange(filePath: string, start: number, end: number): Promise<Buffer>;
|
|
65
|
+
/** Begin watching `filePath`; call `onChange` when it grows. Returns unwatch. */
|
|
66
|
+
watch(filePath: string, onChange: () => void): () => void;
|
|
67
|
+
}
|
|
68
|
+
export interface RolloutWatcherLogger {
|
|
69
|
+
log(entry: Record<string, unknown>): void;
|
|
70
|
+
}
|
|
71
|
+
export declare function resolveCodexSessionsRoot(): string;
|
|
72
|
+
export declare class CodexRolloutWatcher {
|
|
73
|
+
private readonly onEvent;
|
|
74
|
+
private readonly deps;
|
|
75
|
+
private readonly logger?;
|
|
76
|
+
private readonly sessions;
|
|
77
|
+
constructor(onEvent: (event: RolloutEvent) => void, options?: {
|
|
78
|
+
deps?: Partial<RolloutWatcherDeps>;
|
|
79
|
+
pollMs?: number;
|
|
80
|
+
logger?: RolloutWatcherLogger;
|
|
81
|
+
});
|
|
82
|
+
/**
|
|
83
|
+
* Begin tracking a Codex thread. Locates its rollout file and baselines the
|
|
84
|
+
* cursor to the current EOF (only NEW activity is mirrored — no replay). If
|
|
85
|
+
* the file doesn't exist yet, the id stays pending and {@link relocatePending}
|
|
86
|
+
* will pick it up later.
|
|
87
|
+
*/
|
|
88
|
+
register(codexSessionId: string): Promise<void>;
|
|
89
|
+
unregister(codexSessionId: string): void;
|
|
90
|
+
/** Try to locate rollout files for any still-pending ids (called by the
|
|
91
|
+
* mirror's reconcile loop to pick up files created after registration). */
|
|
92
|
+
relocatePending(): Promise<void>;
|
|
93
|
+
/**
|
|
94
|
+
* Advance the cursor to current EOF without emitting events — used to "claim"
|
|
95
|
+
* lines the bridge itself wrote during a bridge-driven turn so the watcher
|
|
96
|
+
* doesn't re-mirror them.
|
|
97
|
+
*/
|
|
98
|
+
claimToEof(codexSessionId: string): Promise<void>;
|
|
99
|
+
/** Test seam: feed appended text directly, bypassing the filesystem. */
|
|
100
|
+
ingestAppendedText(codexSessionId: string, text: string): void;
|
|
101
|
+
stop(): void;
|
|
102
|
+
private locate;
|
|
103
|
+
private findRolloutFile;
|
|
104
|
+
private scheduleGrowth;
|
|
105
|
+
private processGrowth;
|
|
106
|
+
private processChunk;
|
|
107
|
+
private mapLine;
|
|
108
|
+
private log;
|
|
109
|
+
}
|