@roarpeng/graphflow 1.9.16 → 1.12.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.
- package/CHANGELOG.md +49 -1
- package/README.md +9 -8
- package/README.zh.md +3 -2
- package/dist/config/resolve.d.ts +13 -1
- package/dist/config/resolve.d.ts.map +1 -1
- package/dist/config/resolve.js +29 -3
- package/dist/config/resolve.js.map +1 -1
- package/dist/config/workspace-root.d.ts +6 -0
- package/dist/config/workspace-root.d.ts.map +1 -1
- package/dist/config/workspace-root.js +3 -2
- package/dist/config/workspace-root.js.map +1 -1
- package/dist/graph/graphify-file-client.d.ts +49 -0
- package/dist/graph/graphify-file-client.d.ts.map +1 -1
- package/dist/graph/graphify-file-client.js +113 -15
- package/dist/graph/graphify-file-client.js.map +1 -1
- package/dist/graph/knowledge-extraction.d.ts +43 -0
- package/dist/graph/knowledge-extraction.d.ts.map +1 -0
- package/dist/graph/knowledge-extraction.js +274 -0
- package/dist/graph/knowledge-extraction.js.map +1 -0
- package/dist/graph/token-savings.d.ts +39 -0
- package/dist/graph/token-savings.d.ts.map +1 -1
- package/dist/graph/token-savings.js +138 -0
- package/dist/graph/token-savings.js.map +1 -1
- package/dist/learning/canary-gate.d.ts +5 -1
- package/dist/learning/canary-gate.d.ts.map +1 -1
- package/dist/learning/canary-gate.js +5 -2
- package/dist/learning/canary-gate.js.map +1 -1
- package/dist/learning/episodic-memory.d.ts.map +1 -1
- package/dist/learning/episodic-memory.js +19 -3
- package/dist/learning/episodic-memory.js.map +1 -1
- package/dist/learning/skill-admission.d.ts +55 -10
- package/dist/learning/skill-admission.d.ts.map +1 -1
- package/dist/learning/skill-admission.js +177 -35
- package/dist/learning/skill-admission.js.map +1 -1
- package/dist/learning/skill-flywheel.d.ts +24 -6
- package/dist/learning/skill-flywheel.d.ts.map +1 -1
- package/dist/learning/skill-flywheel.js +99 -19
- package/dist/learning/skill-flywheel.js.map +1 -1
- package/dist/learning/skill-markdown.d.ts +12 -0
- package/dist/learning/skill-markdown.d.ts.map +1 -0
- package/dist/learning/skill-markdown.js +226 -0
- package/dist/learning/skill-markdown.js.map +1 -0
- package/dist/learning/skill-store.d.ts.map +1 -1
- package/dist/learning/skill-store.js +23 -4
- package/dist/learning/skill-store.js.map +1 -1
- package/dist/learning/skill-types.d.ts +10 -0
- package/dist/learning/skill-types.d.ts.map +1 -1
- package/dist/learning/skill-types.js.map +1 -1
- package/dist/learning/workflow-skill.d.ts +2 -0
- package/dist/learning/workflow-skill.d.ts.map +1 -1
- package/dist/learning/workflow-skill.js +15 -0
- package/dist/learning/workflow-skill.js.map +1 -1
- package/dist/surfaces/cli/index.js +69 -0
- package/dist/surfaces/cli/index.js.map +1 -1
- package/dist/surfaces/cli/output.d.ts.map +1 -1
- package/dist/surfaces/cli/output.js +3 -0
- package/dist/surfaces/cli/output.js.map +1 -1
- package/dist/surfaces/cli/runtime/dialogue.js +3 -3
- package/dist/surfaces/cli/runtime/dialogue.js.map +1 -1
- package/dist/surfaces/cli/runtime/graph.d.ts.map +1 -1
- package/dist/surfaces/cli/runtime/graph.js +14 -10
- package/dist/surfaces/cli/runtime/graph.js.map +1 -1
- package/dist/surfaces/cli/runtime/knowledge.d.ts +37 -0
- package/dist/surfaces/cli/runtime/knowledge.d.ts.map +1 -0
- package/dist/surfaces/cli/runtime/knowledge.js +162 -0
- package/dist/surfaces/cli/runtime/knowledge.js.map +1 -0
- package/dist/surfaces/cli/runtime/routing.js +3 -3
- package/dist/surfaces/cli/runtime/routing.js.map +1 -1
- package/dist/surfaces/cli/runtime/types.d.ts +6 -0
- package/dist/surfaces/cli/runtime/types.d.ts.map +1 -1
- package/dist/surfaces/cli/runtime.d.ts +1 -0
- package/dist/surfaces/cli/runtime.d.ts.map +1 -1
- package/dist/surfaces/cli/runtime.js +5 -1
- package/dist/surfaces/cli/runtime.js.map +1 -1
- package/dist/surfaces/mcp/server.d.ts +24 -0
- package/dist/surfaces/mcp/server.d.ts.map +1 -1
- package/dist/surfaces/mcp/server.js +265 -1
- package/dist/surfaces/mcp/server.js.map +1 -1
- package/dist/surfaces/mcp/tool-definitions.d.ts +1 -0
- package/dist/surfaces/mcp/tool-definitions.d.ts.map +1 -1
- package/dist/surfaces/mcp/tool-definitions.js +9 -1
- package/dist/surfaces/mcp/tool-definitions.js.map +1 -1
- package/dist/surfaces/mcp/tool-handlers.d.ts +4 -1
- package/dist/surfaces/mcp/tool-handlers.d.ts.map +1 -1
- package/dist/surfaces/mcp/tool-handlers.js +69 -19
- package/dist/surfaces/mcp/tool-handlers.js.map +1 -1
- package/dsh/client.js +431 -0
- package/dsh/plugin.mjs +766 -40
- package/package.json +12 -3
- package/plugin.json +1 -1
package/dsh/plugin.mjs
CHANGED
|
@@ -7,20 +7,52 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Duck-types `ctx` — no hard dependency on `@deepseek-ai/cordis`.
|
|
9
9
|
* Missing `ctx.skills` / events → no-op. Never throw into the harness loop.
|
|
10
|
+
*
|
|
11
|
+
* Reply auto-fill: each agent turn ends with a durable `turn/end` session
|
|
12
|
+
* event (`data.reason.kind`). The glue tracks the last non-interrupted
|
|
13
|
+
* `assistant/message` text of the turn (the event's message lives at
|
|
14
|
+
* `event.data.message` — session events are `{ type, seq, time, data }`), and
|
|
15
|
+
* on `turn/end` backfills the pending graph dialogue turn with that final
|
|
16
|
+
* reply. Backfill prefers calling the co-located GraphFlow runtime
|
|
17
|
+
* (`captureAssistantReply` + `recordDialogueTurnRuntime`) in-process, and
|
|
18
|
+
* falls back to spawning the local CLI (`context preview --reply` then
|
|
19
|
+
* `dialogue record --reply`, both idempotent tip fills). Env switch:
|
|
20
|
+
* `GRAPHFLOW_CAPTURE_REPLY=0` disables reply filling only.
|
|
21
|
+
*
|
|
22
|
+
* Static panel data channel: the /gf Connection RPC channel
|
|
23
|
+
* (`ctx.connection.rpc.handle("/gf", handler, { authority: "trusted-host" })`)
|
|
24
|
+
* serves the `nodes` method — it runs `workbench tree --json` and
|
|
25
|
+
* `dialogue list --json --limit 50` in the requested workspace and returns the
|
|
26
|
+
* parsed snapshots inside the mandatory RpcResult envelope for the static
|
|
27
|
+
* client panel (web/client.js).
|
|
10
28
|
*/
|
|
11
29
|
import { spawn } from "node:child_process";
|
|
12
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
30
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
13
31
|
import { dirname, join } from "node:path";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
33
|
|
|
16
34
|
export const name = "graphflow-dsh";
|
|
17
35
|
|
|
18
36
|
const PLUGIN_ID = "graphflow-dsh";
|
|
19
37
|
const AUTO_CAPTURE_ENV = "GRAPHFLOW_AUTO_CAPTURE";
|
|
38
|
+
const CAPTURE_REPLY_ENV = "GRAPHFLOW_CAPTURE_REPLY";
|
|
20
39
|
const JOURNAL_RELATIVE = join(".graphflow", "session-journal.jsonl");
|
|
40
|
+
/** Reply text is clipped before it is passed as a function arg or argv element. */
|
|
41
|
+
const REPLY_CLIP_MAX = 4000;
|
|
42
|
+
/** CLI fill children are killed after this long (detached:false + timeout). */
|
|
43
|
+
const REPLY_FILL_TIMEOUT_MS = 20_000;
|
|
44
|
+
/** One bounded retry when the fill finds no pending turn yet (record race). */
|
|
45
|
+
const REPLY_RETRY_DELAY_MS = 1_500;
|
|
21
46
|
const DEFAULT_SKILL_DESCRIPTION =
|
|
22
47
|
"图谱上下文压缩、任务规划与知识图谱编排(10 个 MCP 工具)。任何读代码、改代码、排错、中文问题之前必须先调 graphflow_context。DeepSeek Harness 下工具名为 mcp__graphflow__graphflow_*。";
|
|
23
48
|
|
|
49
|
+
/** Static-panel data channel: Connection generic RPC channel for the panel. */
|
|
50
|
+
const NODES_CHANNEL = "/gf";
|
|
51
|
+
/** Panel-data CLI children are killed after this long (timeout guard). */
|
|
52
|
+
const CAPTURE_TIMEOUT_MS = 20_000;
|
|
53
|
+
/** Captured stdout/stderr per command are capped at ~4MB (runaway guard). */
|
|
54
|
+
const CAPTURE_OUTPUT_MAX_BYTES = 4_000_000;
|
|
55
|
+
|
|
24
56
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
25
57
|
|
|
26
58
|
/** @typedef {{ spawn?: typeof spawn, env?: NodeJS.ProcessEnv, cwd?: string, readSkill?: () => { name: string, description: string, content: string, source: string, path?: string } }} GraphFlowDshPluginConfig */
|
|
@@ -34,6 +66,48 @@ export function isAutoCaptureEnabled(env = process.env) {
|
|
|
34
66
|
return !(raw === "0" || raw === "false" || raw === "off" || raw === "no" || raw === "disabled");
|
|
35
67
|
}
|
|
36
68
|
|
|
69
|
+
/**
|
|
70
|
+
* One-switch kill for the assistant-reply backfill only (the question
|
|
71
|
+
* recording keeps obeying `GRAPHFLOW_AUTO_CAPTURE`). `GRAPHFLOW_CAPTURE_REPLY`
|
|
72
|
+
* in {0,false,off,no,disabled} (case-insensitive) disables reply filling.
|
|
73
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
export function isReplyCaptureEnabled(env = process.env) {
|
|
77
|
+
const raw = env[CAPTURE_REPLY_ENV]?.trim().toLowerCase();
|
|
78
|
+
return !(raw === "0" || raw === "false" || raw === "off" || raw === "no" || raw === "disabled");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Normalize and clip reply text before it crosses a process boundary (in-process
|
|
83
|
+
* call arg or spawn argv). Collapses whitespace, strips NUL (illegal in argv),
|
|
84
|
+
* and truncates to `max` chars with an ellipsis — mirroring the runtime's own
|
|
85
|
+
* `clip` so glue and store agree. Never throws.
|
|
86
|
+
* @param {unknown} text
|
|
87
|
+
* @param {number} [max]
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function clipReplyText(text, max = REPLY_CLIP_MAX) {
|
|
91
|
+
if (typeof text !== "string") return "";
|
|
92
|
+
const normalized = text.replace(/\s+/g, " ").replace(/\u0000/g, "").trim();
|
|
93
|
+
if (normalized.length <= max) return normalized;
|
|
94
|
+
return `${normalized.slice(0, max - 1)}…`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The assistant message carried by a `session/event` payload. Live events are
|
|
99
|
+
* frozen envelopes `{ type, seq, time, data }` so the message lives at
|
|
100
|
+
* `event.data.message`; a legacy/tolerant shape with `event.message` is also
|
|
101
|
+
* accepted. Never throws.
|
|
102
|
+
* @param {object|undefined} event
|
|
103
|
+
* @returns {object|undefined}
|
|
104
|
+
*/
|
|
105
|
+
export function sessionEventMessage(event) {
|
|
106
|
+
if (!event || typeof event !== "object") return undefined;
|
|
107
|
+
const data = event.data;
|
|
108
|
+
return data && typeof data === "object" ? data.message ?? event.message : event.message;
|
|
109
|
+
}
|
|
110
|
+
|
|
37
111
|
/**
|
|
38
112
|
* Explicit success only — same contract as Claude Code SessionEnd `$2`.
|
|
39
113
|
* Missing GRAPHFLOW_HOOK_SUCCESS leaves the episode pending.
|
|
@@ -109,12 +183,48 @@ export function resolveCliCommand(packageRoot = PACKAGE_ROOT) {
|
|
|
109
183
|
return existsSync(distCli) ? distCli : "graphflow";
|
|
110
184
|
}
|
|
111
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Confirmed harness/system message-source kinds that must never become
|
|
188
|
+
* dialogue turns. `MessageSource.kind` (dsh-llm) is merge-extensible: the
|
|
189
|
+
* base map defines `user`/`plugin`/`tool`/`model`, and harness plugins add
|
|
190
|
+
* their own kinds (verified against the running dsh 1.9.16 packages):
|
|
191
|
+
* - `subagent-settled`: "Background subagent … finished/stopped/failed"
|
|
192
|
+
* settlement notices (dsh-subagent, form: notice);
|
|
193
|
+
* - `subagent-report`: "Background subagent … reported:" framed reports
|
|
194
|
+
* (dsh-subagent, form: relay);
|
|
195
|
+
* - `agent-instructions`: AGENTS.md/workspace instructions system-reminders
|
|
196
|
+
* (dsh-agent-instructions, form: instructions);
|
|
197
|
+
* - `session-reference`: recalled content lifted from another session
|
|
198
|
+
* (dsh-session-reference, form: recall);
|
|
199
|
+
* - `goal`: goal-round driver context prompts (dsh-goal-round-driver);
|
|
200
|
+
* - `skill-catalog`: the `<system-reminder><available_skills>` catalog
|
|
201
|
+
* (dsh-tool-skill, form: catalog).
|
|
202
|
+
* `plugin` also covers runtime-context snapshots (`@deepseek-ai/dsh-system-prompt`,
|
|
203
|
+
* form: snapshot), approval-policy changes (`user-approval`),
|
|
204
|
+
* Cordis run failures (`cordis-host-runner`), and this glue's own hint
|
|
205
|
+
* (`graphflow-dsh`, form: instructions).
|
|
206
|
+
*/
|
|
207
|
+
const SYSTEM_MESSAGE_SOURCE_KINDS = new Set([
|
|
208
|
+
"plugin",
|
|
209
|
+
"tool",
|
|
210
|
+
"model",
|
|
211
|
+
"subagent-settled",
|
|
212
|
+
"subagent-report",
|
|
213
|
+
"agent-instructions",
|
|
214
|
+
"session-reference",
|
|
215
|
+
"goal",
|
|
216
|
+
"skill-catalog",
|
|
217
|
+
]);
|
|
218
|
+
|
|
112
219
|
/**
|
|
113
220
|
* Whether a message originates from the human user rather than the harness.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
221
|
+
* Whitelist: only `source.kind === "user"` (web chat, headless, slash
|
|
222
|
+
* commands) qualifies. Blacklist: every confirmed harness/system kind above is
|
|
223
|
+
* rejected — subagent notices/reports, AGENTS.md instructions, session recall,
|
|
224
|
+
* goal-round context, skill catalogs, tool results, plugin injections and
|
|
225
|
+
* runtime-context snapshots must not become dialogue turns. Unknown/missing
|
|
226
|
+
* source falls back to the role check so nothing legitimate is dropped
|
|
227
|
+
* (a future harness kind with role "user" would still leak — see report).
|
|
118
228
|
* @param {object|undefined} message
|
|
119
229
|
* @returns {boolean}
|
|
120
230
|
*/
|
|
@@ -123,7 +233,7 @@ export function isUserOriginatedMessage(message) {
|
|
|
123
233
|
const kind =
|
|
124
234
|
message.source && typeof message.source === "object" ? message.source.kind : undefined;
|
|
125
235
|
if (kind === "user") return true;
|
|
126
|
-
if (
|
|
236
|
+
if (typeof kind === "string" && SYSTEM_MESSAGE_SOURCE_KINDS.has(kind)) return false;
|
|
127
237
|
return message.role !== "system";
|
|
128
238
|
}
|
|
129
239
|
|
|
@@ -181,46 +291,618 @@ export function recordDialogueFromInbox(message, cwd, config = {}, sessionId) {
|
|
|
181
291
|
|
|
182
292
|
/**
|
|
183
293
|
* Best-effort fill of one assistant reply into the latest pending dialogue
|
|
184
|
-
* turn for `sessionId`.
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
294
|
+
* turn for `sessionId`. Backfill pipeline (see fillPendingReply): co-located
|
|
295
|
+
* GraphFlow runtime first, CLI spawn fallback, deduped per session+turn,
|
|
296
|
+
* latest-wins coalescing queue, one bounded retry for the record race. Never
|
|
297
|
+
* throws.
|
|
188
298
|
* @param {object|undefined} event - an `assistant/message` session event.
|
|
189
299
|
* @param {string} [sessionId] - dialogue session name/id.
|
|
190
300
|
* @param {string} [cwd]
|
|
191
301
|
* @param {GraphFlowDshPluginConfig} [config]
|
|
192
|
-
* @returns {{attempted: boolean, reason?: string, workspace?: string}}
|
|
302
|
+
* @returns {{attempted: boolean, reason?: string, workspace?: string, queued?: boolean}}
|
|
193
303
|
*/
|
|
194
304
|
export function recordReplyFromTurn(event, sessionId, cwd, config = {}) {
|
|
195
305
|
try {
|
|
196
|
-
|
|
306
|
+
if (event?.interrupted || event?.data?.interrupted) {
|
|
307
|
+
return { attempted: false, reason: "interrupted" };
|
|
308
|
+
}
|
|
309
|
+
const text = extractMessageText(sessionEventMessage(event));
|
|
197
310
|
if (!text) {
|
|
198
311
|
return { attempted: false, reason: "no-text" };
|
|
199
312
|
}
|
|
200
|
-
if (event?.interrupted) {
|
|
201
|
-
return { attempted: false, reason: "interrupted" };
|
|
202
|
-
}
|
|
203
313
|
const env = envOf(config);
|
|
204
314
|
if (!isAutoCaptureEnabled(env)) {
|
|
205
315
|
return { attempted: false, reason: "auto-capture-off" };
|
|
206
316
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
317
|
+
return fillPendingReply(text, sessionId, cwd, config);
|
|
318
|
+
} catch {
|
|
319
|
+
return { attempted: false, reason: "error" };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Absolute path of the co-located CLI runtime module (this package's own dist
|
|
325
|
+
* build) when present, else undefined. The runtime is CJS; the glue imports it
|
|
326
|
+
* lazily and only ever in try/catch — any load failure falls back to the CLI.
|
|
327
|
+
* @param {string} [packageRoot]
|
|
328
|
+
* @returns {string|undefined}
|
|
329
|
+
*/
|
|
330
|
+
export function resolveRuntimeGraphPath(packageRoot = PACKAGE_ROOT) {
|
|
331
|
+
const candidate = join(packageRoot, "dist", "surfaces", "cli", "runtime", "graph.js");
|
|
332
|
+
return existsSync(candidate) ? candidate : undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* First existing CLI binary for reply filling, in order of preference:
|
|
337
|
+
* 1. the co-located package's own dist CLI (same build the glue runs from);
|
|
338
|
+
* 2. `$HOME/.dsh/profiles/<profile>/node_modules/@roarpeng/graphflow/dist/…`
|
|
339
|
+
* CLI (first hit — covers a source checkout whose own dist is absent);
|
|
340
|
+
* 3. bare `graphflow` (spawned through npx/npm exec).
|
|
341
|
+
* @param {string} [packageRoot]
|
|
342
|
+
* @param {string} [home]
|
|
343
|
+
* @returns {string}
|
|
344
|
+
*/
|
|
345
|
+
export function resolveCliForCapture(packageRoot = PACKAGE_ROOT, home = process.env.HOME || process.env.USERPROFILE) {
|
|
346
|
+
const packageCli = join(packageRoot, "dist", "surfaces", "cli", "index.js");
|
|
347
|
+
if (existsSync(packageCli)) return packageCli;
|
|
348
|
+
const profileCli = findProfileCli(home);
|
|
349
|
+
if (profileCli) return profileCli;
|
|
350
|
+
return "graphflow";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** @param {string|undefined} home */
|
|
354
|
+
export function findProfileCli(home) {
|
|
355
|
+
try {
|
|
356
|
+
if (!home) return undefined;
|
|
357
|
+
const profilesDir = join(home, ".dsh", "profiles");
|
|
358
|
+
if (!existsSync(profilesDir)) return undefined;
|
|
359
|
+
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
|
|
360
|
+
if (!entry.isDirectory()) continue;
|
|
361
|
+
const candidate = join(
|
|
362
|
+
profilesDir,
|
|
363
|
+
entry.name,
|
|
364
|
+
"node_modules",
|
|
365
|
+
"@roarpeng",
|
|
366
|
+
"graphflow",
|
|
367
|
+
"dist",
|
|
368
|
+
"surfaces",
|
|
369
|
+
"cli",
|
|
370
|
+
"index.js"
|
|
371
|
+
);
|
|
372
|
+
if (existsSync(candidate)) return candidate;
|
|
213
373
|
}
|
|
374
|
+
} catch {
|
|
375
|
+
// discovery is best-effort
|
|
376
|
+
}
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Parse one captured CLI result into JSON — mirrors the dynamic panel's
|
|
382
|
+
* parseCliOut (web/plugin.mjs:48-58): exitCode must be 0, stdout must
|
|
383
|
+
* JSON.parse, and a wrapping `{ data }` object is unwrapped. Returns null on
|
|
384
|
+
* any failure so the panel renders an empty state instead of an error.
|
|
385
|
+
* @param {{exitCode: number|null, stdout?: string}|undefined} result
|
|
386
|
+
* @returns {unknown|null}
|
|
387
|
+
*/
|
|
388
|
+
export function parseCliOut(result) {
|
|
389
|
+
if (!result || result.exitCode !== 0) return null;
|
|
390
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
391
|
+
if (!stdout.trim()) return null;
|
|
392
|
+
try {
|
|
393
|
+
const parsed = JSON.parse(stdout);
|
|
394
|
+
return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed;
|
|
395
|
+
} catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The workspace's own config path when it exists; passing it keeps config
|
|
401
|
+
* discovery (graphStorePath, enableDialogueThread, …) aligned with the CLI
|
|
402
|
+
* run from that workspace. The workspace root itself is always overridden by
|
|
403
|
+
* the explicit rootDir the runtime functions receive. */
|
|
404
|
+
function resolveWorkspaceConfigPath(workspace) {
|
|
405
|
+
try {
|
|
406
|
+
if (typeof workspace !== "string" || !workspace.trim()) return undefined;
|
|
407
|
+
const candidate = join(workspace, "graphflow.config.json");
|
|
408
|
+
return existsSync(candidate) ? candidate : undefined;
|
|
409
|
+
} catch {
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let captureReplyRuntimePromise;
|
|
415
|
+
let dialogueRecordRuntimePromise;
|
|
416
|
+
|
|
417
|
+
function loadCaptureAssistantReply(packageRoot = PACKAGE_ROOT) {
|
|
418
|
+
const runtimePath = resolveRuntimeGraphPath(packageRoot);
|
|
419
|
+
if (!runtimePath) return Promise.resolve(undefined);
|
|
420
|
+
if (captureReplyRuntimePromise === undefined) {
|
|
421
|
+
captureReplyRuntimePromise = import(pathToFileURL(runtimePath).href)
|
|
422
|
+
.then((mod) => mod?.captureAssistantReply ?? mod?.default?.captureAssistantReply)
|
|
423
|
+
.catch(() => undefined);
|
|
424
|
+
}
|
|
425
|
+
return captureReplyRuntimePromise;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function loadDialogueRecordRuntime(packageRoot = PACKAGE_ROOT) {
|
|
429
|
+
const runtimePath = join(packageRoot, "dist", "surfaces", "cli", "runtime", "dialogue.js");
|
|
430
|
+
if (!existsSync(runtimePath)) return Promise.resolve(undefined);
|
|
431
|
+
if (dialogueRecordRuntimePromise === undefined) {
|
|
432
|
+
dialogueRecordRuntimePromise = import(pathToFileURL(runtimePath).href)
|
|
433
|
+
.then((mod) => mod?.recordDialogueTurnRuntime ?? mod?.default?.recordDialogueTurnRuntime)
|
|
434
|
+
.catch(() => undefined);
|
|
435
|
+
}
|
|
436
|
+
return dialogueRecordRuntimePromise;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* In-process backfill: `captureAssistantReply` (the `context preview --reply`
|
|
441
|
+
* semantics — fills a pending workbench topic when one is active) plus, when it
|
|
442
|
+
* only landed on a workbench topic, a direct dialogue-turn fill via
|
|
443
|
+
* `recordDialogueTurnRuntime("", { assistantReply })` so the pending DIALOGUE
|
|
444
|
+
* turn is guaranteed to close. Any failure returns a structured non-ok result
|
|
445
|
+
* so the caller can fall back to the CLI. Never throws.
|
|
446
|
+
* @param {string} text
|
|
447
|
+
* @param {string} [sessionId]
|
|
448
|
+
* @param {string} workspace
|
|
449
|
+
* @param {GraphFlowDshPluginConfig} [config]
|
|
450
|
+
* @returns {Promise<{attempted: boolean, ok?: boolean, filled?: boolean, reason?: string, capture?: object}>}
|
|
451
|
+
*/
|
|
452
|
+
export async function captureReplyInProcess(text, sessionId, workspace, config = {}) {
|
|
453
|
+
const fn = await loadCaptureAssistantReply(config.packageRoot);
|
|
454
|
+
if (typeof fn !== "function") {
|
|
455
|
+
return { attempted: false, reason: "runtime-unavailable" };
|
|
456
|
+
}
|
|
457
|
+
const reply = clipReplyText(text);
|
|
458
|
+
if (!reply) return { attempted: false, reason: "no-text" };
|
|
459
|
+
const sessionOpts =
|
|
460
|
+
typeof sessionId === "string" && sessionId.trim() ? { sessionId } : undefined;
|
|
461
|
+
let result;
|
|
462
|
+
try {
|
|
463
|
+
result = await fn(reply, resolveWorkspaceConfigPath(workspace), workspace, sessionOpts);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
return { attempted: true, ok: false, reason: "capture-failed", error: String(error) };
|
|
466
|
+
}
|
|
467
|
+
const ok = result?.ok === true;
|
|
468
|
+
const kind = result?.capture?.kind;
|
|
469
|
+
if (ok && kind === "workbench") {
|
|
470
|
+
// The reply went to an active workbench topic; the pending dialogue turn
|
|
471
|
+
// may still be empty — close it too so `A:` is never left "(待回复)".
|
|
472
|
+
try {
|
|
473
|
+
const dialogueFn = await loadDialogueRecordRuntime(config.packageRoot);
|
|
474
|
+
if (typeof dialogueFn === "function") {
|
|
475
|
+
await dialogueFn("", {
|
|
476
|
+
rootDir: workspace,
|
|
477
|
+
assistantReply: reply,
|
|
478
|
+
...(sessionOpts ? { sessionId } : {}),
|
|
479
|
+
});
|
|
480
|
+
} else {
|
|
481
|
+
spawnCliFillArgs(["dialogue", "record", "--reply", reply, ...(sessionOpts ? ["--session", sessionId] : [])], workspace, config);
|
|
482
|
+
}
|
|
483
|
+
} catch {
|
|
484
|
+
// dialogue follow-up is best-effort
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
attempted: true,
|
|
489
|
+
ok,
|
|
490
|
+
filled: result?.filled === true,
|
|
491
|
+
reason: result?.reason,
|
|
492
|
+
...(result?.capture
|
|
493
|
+
? { capture: { kind, id: result.capture.id, filled: result.capture.filled === true } }
|
|
494
|
+
: {}),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Spawn one CLI fill command: non-detached, timeout-killed, stderr swallowed. */
|
|
499
|
+
function spawnCliFillArgs(args, workspace, config) {
|
|
500
|
+
try {
|
|
501
|
+
const env = envOf(config);
|
|
502
|
+
const spawnFn = typeof config.spawn === "function" ? config.spawn : spawn;
|
|
503
|
+
const cli = resolveCliForCapture(config.packageRoot);
|
|
214
504
|
const bin = cli === "graphflow" ? (env.GRAPHFLOW_HOOK_BIN?.trim() || "npx") : process.execPath;
|
|
215
|
-
const fullArgs = cli === "graphflow" ? ["-y", "--package=@roarpeng/graphflow", ...args] : args;
|
|
216
|
-
spawnFn(bin, fullArgs, {
|
|
505
|
+
const fullArgs = cli === "graphflow" ? ["-y", "--package=@roarpeng/graphflow", cli, ...args] : [cli, ...args];
|
|
506
|
+
const child = spawnFn(bin, fullArgs, {
|
|
217
507
|
cwd: workspace,
|
|
218
508
|
env,
|
|
219
509
|
stdio: "ignore",
|
|
220
|
-
detached:
|
|
510
|
+
detached: false,
|
|
221
511
|
windowsHide: true,
|
|
222
|
-
})
|
|
223
|
-
|
|
512
|
+
});
|
|
513
|
+
child?.on?.("error", () => {});
|
|
514
|
+
const timer = setTimeout(() => {
|
|
515
|
+
try {
|
|
516
|
+
child?.kill?.();
|
|
517
|
+
} catch {
|
|
518
|
+
// already gone
|
|
519
|
+
}
|
|
520
|
+
}, REPLY_FILL_TIMEOUT_MS);
|
|
521
|
+
timer.unref?.();
|
|
522
|
+
child?.once?.("exit", () => clearTimeout(timer));
|
|
523
|
+
} catch {
|
|
524
|
+
// fill is optional — never throw into the harness
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Spawn one CLI command and capture stdout/stderr (the static panel data
|
|
530
|
+
* channel needs the JSON output, unlike the fire-and-forget reply fills).
|
|
531
|
+
* Resolution order matches resolveCliForCapture: co-located dist CLI → first
|
|
532
|
+
* profile install → npx `-y --package=@roarpeng/graphflow graphflow`
|
|
533
|
+
* (`config.packageRoot` / `config.home` are injectable for tests). The
|
|
534
|
+
* child is timeout-killed (20s) and each stream is capped (~4MB) so a runaway
|
|
535
|
+
* CLI can never hang or exhaust the host; an external abort (the Connection
|
|
536
|
+
* RPC request signal) kills the child too. Resolves
|
|
537
|
+
* `{ exitCode, signal, stdout, stderr }` — CLI failures never reject; only
|
|
538
|
+
* infrastructure errors (spawn throwing, child "error" event) reject. Never
|
|
539
|
+
* throws synchronously.
|
|
540
|
+
* @param {string[]} args - command words after the graphflow binary.
|
|
541
|
+
* @param {string} workspace - cwd for the child.
|
|
542
|
+
* @param {GraphFlowDshPluginConfig} [config]
|
|
543
|
+
* @param {AbortSignal} [signal]
|
|
544
|
+
* @returns {Promise<{exitCode: number|null, signal: string|null, stdout: string, stderr: string}>}
|
|
545
|
+
*/
|
|
546
|
+
function spawnCliCapture(args, workspace, config, signal) {
|
|
547
|
+
return new Promise((resolve, reject) => {
|
|
548
|
+
const env = envOf(config);
|
|
549
|
+
const spawnFn = typeof config.spawn === "function" ? config.spawn : spawn;
|
|
550
|
+
let bin;
|
|
551
|
+
let fullArgs;
|
|
552
|
+
try {
|
|
553
|
+
const cli = resolveCliForCapture(config.packageRoot, config.home);
|
|
554
|
+
bin = cli === "graphflow" ? (env.GRAPHFLOW_HOOK_BIN?.trim() || "npx") : process.execPath;
|
|
555
|
+
fullArgs = cli === "graphflow" ? ["-y", "--package=@roarpeng/graphflow", cli, ...args] : [cli, ...args];
|
|
556
|
+
} catch (error) {
|
|
557
|
+
reject(error);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
let child;
|
|
561
|
+
try {
|
|
562
|
+
child = spawnFn(bin, fullArgs, {
|
|
563
|
+
cwd: workspace,
|
|
564
|
+
env,
|
|
565
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
566
|
+
windowsHide: true,
|
|
567
|
+
});
|
|
568
|
+
} catch (error) {
|
|
569
|
+
reject(error);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (!child || typeof child !== "object") {
|
|
573
|
+
reject(new Error(`spawn of ${JSON.stringify(bin)} returned no child process`));
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const output = { stdout: "", stderr: "" };
|
|
578
|
+
const sizes = { stdout: 0, stderr: 0 };
|
|
579
|
+
let settled = false;
|
|
580
|
+
let exited = false;
|
|
581
|
+
let exitCode = null;
|
|
582
|
+
let exitSignal = null;
|
|
583
|
+
let stdoutEnded = child.stdout === undefined;
|
|
584
|
+
let stderrEnded = child.stderr === undefined;
|
|
585
|
+
|
|
586
|
+
const cleanup = () => {
|
|
587
|
+
clearTimeout(timer);
|
|
588
|
+
if (signal && typeof signal.removeEventListener === "function") {
|
|
589
|
+
try {
|
|
590
|
+
signal.removeEventListener("abort", onAbort);
|
|
591
|
+
} catch {
|
|
592
|
+
// ignore
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
const finish = () => {
|
|
597
|
+
if (settled) return;
|
|
598
|
+
settled = true;
|
|
599
|
+
cleanup();
|
|
600
|
+
resolve({ exitCode, signal: exitSignal, stdout: output.stdout, stderr: output.stderr });
|
|
601
|
+
};
|
|
602
|
+
const fail = (error) => {
|
|
603
|
+
if (settled) return;
|
|
604
|
+
settled = true;
|
|
605
|
+
cleanup();
|
|
606
|
+
reject(error);
|
|
607
|
+
};
|
|
608
|
+
const maybeFinish = () => {
|
|
609
|
+
if (exited && stdoutEnded && stderrEnded) finish();
|
|
610
|
+
};
|
|
611
|
+
const timer = setTimeout(() => {
|
|
612
|
+
exitSignal = "timeout";
|
|
613
|
+
try {
|
|
614
|
+
child.kill?.();
|
|
615
|
+
} catch {
|
|
616
|
+
// already gone
|
|
617
|
+
}
|
|
618
|
+
finish();
|
|
619
|
+
}, CAPTURE_TIMEOUT_MS);
|
|
620
|
+
timer.unref?.();
|
|
621
|
+
const onAbort = () => {
|
|
622
|
+
exitSignal = "abort";
|
|
623
|
+
try {
|
|
624
|
+
child.kill?.();
|
|
625
|
+
} catch {
|
|
626
|
+
// already gone
|
|
627
|
+
}
|
|
628
|
+
finish();
|
|
629
|
+
};
|
|
630
|
+
if (signal) {
|
|
631
|
+
if (signal.aborted) {
|
|
632
|
+
onAbort();
|
|
633
|
+
} else if (typeof signal.addEventListener === "function") {
|
|
634
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const capture = (name) => (chunk) => {
|
|
639
|
+
const text = typeof chunk === "string" ? chunk : String(chunk);
|
|
640
|
+
if (sizes[name] >= CAPTURE_OUTPUT_MAX_BYTES) return;
|
|
641
|
+
const room = CAPTURE_OUTPUT_MAX_BYTES - sizes[name];
|
|
642
|
+
const kept = text.length > room ? text.slice(0, room) : text;
|
|
643
|
+
output[name] += kept;
|
|
644
|
+
sizes[name] += kept.length;
|
|
645
|
+
};
|
|
646
|
+
child.stdout?.on?.("data", capture("stdout"));
|
|
647
|
+
child.stdout?.on?.("end", () => {
|
|
648
|
+
stdoutEnded = true;
|
|
649
|
+
maybeFinish();
|
|
650
|
+
});
|
|
651
|
+
child.stderr?.on?.("data", capture("stderr"));
|
|
652
|
+
child.stderr?.on?.("end", () => {
|
|
653
|
+
stderrEnded = true;
|
|
654
|
+
maybeFinish();
|
|
655
|
+
});
|
|
656
|
+
child.on?.("error", fail);
|
|
657
|
+
child.once?.("exit", (code, sig) => {
|
|
658
|
+
exited = true;
|
|
659
|
+
exitCode = code ?? null;
|
|
660
|
+
exitSignal = sig ?? null;
|
|
661
|
+
maybeFinish();
|
|
662
|
+
});
|
|
663
|
+
child.once?.("close", (code, sig) => {
|
|
664
|
+
exited = true;
|
|
665
|
+
exitCode = code ?? null;
|
|
666
|
+
exitSignal = sig ?? null;
|
|
667
|
+
maybeFinish();
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Static-panel data fetch: run the two read-only CLI snapshots
|
|
674
|
+
* (`workbench tree --json`, `dialogue list --json --limit 50`) in the given
|
|
675
|
+
* workspace and return structured JSON. Pure function — `config.spawn`,
|
|
676
|
+
* `config.env` and `config.packageRoot` are injectable for tests (the spawn
|
|
677
|
+
* convention matches the rest of the glue); an optional `config.signal`
|
|
678
|
+
* aborts the children. CLI/parse failures map to null fields with `ok: true`
|
|
679
|
+
* (the panel shows an empty state); only infrastructure errors (spawn
|
|
680
|
+
* throwing) yield `ok: false`.
|
|
681
|
+
* @param {string} workspaceRoot
|
|
682
|
+
* @param {GraphFlowDshPluginConfig & {signal?: AbortSignal}} [config]
|
|
683
|
+
* @returns {Promise<{ok: boolean, workbench?: unknown, dialogues?: unknown, error?: string}>}
|
|
684
|
+
*/
|
|
685
|
+
export async function collectNodesData(workspaceRoot, config = {}) {
|
|
686
|
+
if (typeof workspaceRoot !== "string" || !workspaceRoot.trim()) {
|
|
687
|
+
return { ok: false, error: "no-workspace" };
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
const [wb, dl] = await Promise.all([
|
|
691
|
+
spawnCliCapture(["workbench", "tree", "--json"], workspaceRoot, config, config.signal),
|
|
692
|
+
spawnCliCapture(["dialogue", "list", "--json", "--limit", "50"], workspaceRoot, config, config.signal),
|
|
693
|
+
]);
|
|
694
|
+
return {
|
|
695
|
+
ok: true,
|
|
696
|
+
workbench: parseCliOut(wb),
|
|
697
|
+
dialogues: parseCliOut(dl),
|
|
698
|
+
};
|
|
699
|
+
} catch (error) {
|
|
700
|
+
return { ok: false, error: String(error && error.message ? error.message : error) };
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Connection instances already wired for the /gf channel (re-apply guard). */
|
|
705
|
+
const wiredRpcConnections = new WeakSet();
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Thin Connection RPC handler for the static panel: wraps collectNodesData
|
|
709
|
+
* into the mandatory RpcResult envelope. The envelope is validated by the
|
|
710
|
+
* client `serverResponseSchema` (dsh-host-apiproxy rpc.schema.js): success is
|
|
711
|
+
* `{ ok: true, value }`; failure is `{ ok: false, error: { code, message,
|
|
712
|
+
* details } }` with `bad-request` requiring `details.issues` and `internal`
|
|
713
|
+
* requiring `details: {}`.
|
|
714
|
+
* @param {string} endpoint - path segment after /gf (e.g. "nodes").
|
|
715
|
+
* @param {unknown} payload - client envelope payload (e.g. { workspaceRoot }).
|
|
716
|
+
* @param {AbortSignal} [signal]
|
|
717
|
+
* @param {GraphFlowDshPluginConfig} [config]
|
|
718
|
+
* @returns {Promise<{ok: boolean, value?: object, error?: object}>}
|
|
719
|
+
*/
|
|
720
|
+
async function rpcNodesHandler(endpoint, payload, signal, config) {
|
|
721
|
+
try {
|
|
722
|
+
if (endpoint !== "nodes") {
|
|
723
|
+
return {
|
|
724
|
+
ok: false,
|
|
725
|
+
error: { code: "bad-request", message: `unknown endpoint ${String(endpoint)}`, details: { issues: [] } },
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
const result = await collectNodesData(
|
|
729
|
+
payload && typeof payload === "object" ? payload.workspaceRoot : undefined,
|
|
730
|
+
{ ...config, signal }
|
|
731
|
+
);
|
|
732
|
+
if (result.ok !== true) {
|
|
733
|
+
const code = result.error === "no-workspace" ? "bad-request" : "internal";
|
|
734
|
+
const message = String(result.error ?? (code === "bad-request" ? "no-workspace" : "internal error"));
|
|
735
|
+
return {
|
|
736
|
+
ok: false,
|
|
737
|
+
error:
|
|
738
|
+
code === "bad-request"
|
|
739
|
+
? { code, message, details: { issues: [] } }
|
|
740
|
+
: { code, message, details: {} },
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
return {
|
|
744
|
+
ok: true,
|
|
745
|
+
value: { workbench: result.workbench ?? null, dialogues: result.dialogues ?? null },
|
|
746
|
+
};
|
|
747
|
+
} catch (error) {
|
|
748
|
+
return {
|
|
749
|
+
ok: false,
|
|
750
|
+
error: { code: "internal", message: String(error && error.message ? error.message : error), details: {} },
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Best-effort registration of the /gf Connection RPC channel
|
|
757
|
+
* (`ctx.connection.rpc.handle("/gf", handler, { authority: "trusted-host" })`
|
|
758
|
+
* — see dsh-client-connection lib/index.js:219-258: the handler is
|
|
759
|
+
* `(endpoint, payload, signal)` and `handle` returns a disposer). Duck-typed,
|
|
760
|
+
* idempotent (one channel per connection instance), and the returned disposer
|
|
761
|
+
* both unregisters via the handle disposer and is attached to `ctx.effect`
|
|
762
|
+
* when available so the harness tears it down on unload. Never throws.
|
|
763
|
+
* @param {object} ctx
|
|
764
|
+
* @param {GraphFlowDshPluginConfig} [config]
|
|
765
|
+
* @returns {(() => void)|undefined}
|
|
766
|
+
*/
|
|
767
|
+
function registerNodesRpcChannel(ctx, config) {
|
|
768
|
+
const connection = typeof ctx.get === "function" ? ctx.get("connection") : ctx.connection;
|
|
769
|
+
if (!connection || typeof connection !== "object") return undefined;
|
|
770
|
+
const rpc = connection.rpc;
|
|
771
|
+
if (!rpc || typeof rpc.handle !== "function") return undefined;
|
|
772
|
+
if (wiredRpcConnections.has(connection)) return undefined;
|
|
773
|
+
wiredRpcConnections.add(connection);
|
|
774
|
+
let disposer;
|
|
775
|
+
try {
|
|
776
|
+
disposer = rpc.handle(NODES_CHANNEL, (endpoint, payload, signal) => rpcNodesHandler(endpoint, payload, signal, config), {
|
|
777
|
+
authority: "trusted-host",
|
|
778
|
+
});
|
|
779
|
+
} catch {
|
|
780
|
+
wiredRpcConnections.delete(connection);
|
|
781
|
+
return undefined;
|
|
782
|
+
}
|
|
783
|
+
const dispose = () => {
|
|
784
|
+
if (!wiredRpcConnections.has(connection)) return;
|
|
785
|
+
wiredRpcConnections.delete(connection);
|
|
786
|
+
if (typeof disposer === "function") {
|
|
787
|
+
try {
|
|
788
|
+
disposer();
|
|
789
|
+
} catch {
|
|
790
|
+
// teardown is best-effort
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
if (typeof ctx.effect === "function") {
|
|
795
|
+
try {
|
|
796
|
+
ctx.effect(() => dispose, "graphflow-dsh: /gf nodes rpc channel");
|
|
797
|
+
} catch {
|
|
798
|
+
// effect bus missing — the apply return value still cleans up
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return dispose;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* CLI fallback backfill: `context preview --reply` (task-preferred "fill
|
|
806
|
+
* pending" semantics) followed by `dialogue record --reply`, so the pending
|
|
807
|
+
* DIALOGUE turn is closed even when the preview path only lands on a workbench
|
|
808
|
+
* topic. Both are idempotent tip fills (reply-only mode never records a new
|
|
809
|
+
* turn). Never throws.
|
|
810
|
+
* @returns {{attempted: boolean, reason?: string, workspace?: string}}
|
|
811
|
+
*/
|
|
812
|
+
export function captureReplyViaCli(text, sessionId, workspace, config = {}) {
|
|
813
|
+
try {
|
|
814
|
+
const reply = clipReplyText(text);
|
|
815
|
+
if (!reply) return { attempted: false, reason: "no-text" };
|
|
816
|
+
const sessionArgs =
|
|
817
|
+
typeof sessionId === "string" && sessionId.trim() ? ["--session", sessionId] : [];
|
|
818
|
+
spawnCliFillArgs(["context", "preview", "--reply", reply, ...sessionArgs], workspace, config);
|
|
819
|
+
spawnCliFillArgs(["dialogue", "record", "--reply", reply, ...sessionArgs], workspace, config);
|
|
820
|
+
return { attempted: true, mode: "cli", workspace };
|
|
821
|
+
} catch {
|
|
822
|
+
return { attempted: false, reason: "error" };
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Latest-wins per-session fill queue: only the most recent reply per dialogue
|
|
828
|
+
* session is kept, one fill runs per session at a time, and a just-finished
|
|
829
|
+
* turn's fill is never lost to a newer one. Module-level so every `apply`
|
|
830
|
+
* shares the same throttle.
|
|
831
|
+
*/
|
|
832
|
+
const pendingFills = new Map();
|
|
833
|
+
const inflightFills = new Set();
|
|
834
|
+
|
|
835
|
+
function delay(ms) {
|
|
836
|
+
return new Promise((resolve) => {
|
|
837
|
+
const timer = setTimeout(resolve, ms);
|
|
838
|
+
timer.unref?.();
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function runReplyFillEntry(key, entry) {
|
|
843
|
+
try {
|
|
844
|
+
let direct;
|
|
845
|
+
try {
|
|
846
|
+
direct = await captureReplyInProcess(entry.reply, entry.sessionId, entry.workspace, entry.config);
|
|
847
|
+
} catch {
|
|
848
|
+
direct = undefined;
|
|
849
|
+
}
|
|
850
|
+
if (direct?.ok === true) return;
|
|
851
|
+
if (direct?.reason === "no-pending-turn" && !entry.retried) {
|
|
852
|
+
// The question record (detached spawn) may not have landed yet — retry
|
|
853
|
+
// once, unless a newer fill superseded this one meanwhile.
|
|
854
|
+
entry.retried = true;
|
|
855
|
+
await delay(REPLY_RETRY_DELAY_MS);
|
|
856
|
+
if (!pendingFills.has(key)) await runReplyFillEntry(key, entry);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
captureReplyViaCli(entry.reply, entry.sessionId, entry.workspace, entry.config);
|
|
860
|
+
} catch {
|
|
861
|
+
// never throw into the harness
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function drainReplyFills() {
|
|
866
|
+
for (const [key, entry] of pendingFills) {
|
|
867
|
+
if (inflightFills.has(key)) continue;
|
|
868
|
+
pendingFills.delete(key);
|
|
869
|
+
inflightFills.add(key);
|
|
870
|
+
runReplyFillEntry(key, entry).finally(() => {
|
|
871
|
+
inflightFills.delete(key);
|
|
872
|
+
drainReplyFills();
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Queue one reply fill. Checks the master switch (`GRAPHFLOW_AUTO_CAPTURE`)
|
|
879
|
+
* and the reply-only switch (`GRAPHFLOW_CAPTURE_REPLY`), clips the text, then
|
|
880
|
+
* enqueues per dialogue session (omitted session id → "main"). Returns
|
|
881
|
+
* synchronously; the fill itself runs async in the background.
|
|
882
|
+
* @param {string} text
|
|
883
|
+
* @param {string} [sessionId]
|
|
884
|
+
* @param {string} [workspace]
|
|
885
|
+
* @param {GraphFlowDshPluginConfig} [config]
|
|
886
|
+
* @returns {{attempted: boolean, reason?: string, queued?: boolean, key?: string}}
|
|
887
|
+
*/
|
|
888
|
+
export function fillPendingReply(text, sessionId, workspace, config = {}) {
|
|
889
|
+
try {
|
|
890
|
+
const env = envOf(config);
|
|
891
|
+
if (!isAutoCaptureEnabled(env)) return { attempted: false, reason: "auto-capture-off" };
|
|
892
|
+
if (!isReplyCaptureEnabled(env)) return { attempted: false, reason: "reply-capture-off" };
|
|
893
|
+
const reply = clipReplyText(text);
|
|
894
|
+
if (!reply) return { attempted: false, reason: "no-text" };
|
|
895
|
+
const key = typeof sessionId === "string" && sessionId.trim() ? sessionId : "main";
|
|
896
|
+
const ws = typeof workspace === "string" && workspace.trim() ? workspace : process.cwd();
|
|
897
|
+
pendingFills.set(key, {
|
|
898
|
+
reply,
|
|
899
|
+
sessionId: key === "main" ? undefined : key,
|
|
900
|
+
workspace: ws,
|
|
901
|
+
config,
|
|
902
|
+
retried: false,
|
|
903
|
+
});
|
|
904
|
+
drainReplyFills();
|
|
905
|
+
return { attempted: true, queued: true, key, workspace: ws };
|
|
224
906
|
} catch {
|
|
225
907
|
return { attempted: false, reason: "error" };
|
|
226
908
|
}
|
|
@@ -349,6 +1031,17 @@ export function apply(ctx, config = {}) {
|
|
|
349
1031
|
// missing or duplicate skill registry — ignore
|
|
350
1032
|
}
|
|
351
1033
|
|
|
1034
|
+
// Static-panel data channel: /gf Connection RPC (registerNodesRpcChannel).
|
|
1035
|
+
// Best-effort — a missing connection service or a failed registration must
|
|
1036
|
+
// never affect the rest of the glue. The disposer is returned from apply so
|
|
1037
|
+
// Cordis tears the channel down on unload.
|
|
1038
|
+
let disposeNodesRpc;
|
|
1039
|
+
try {
|
|
1040
|
+
disposeNodesRpc = registerNodesRpcChannel(ctx, config);
|
|
1041
|
+
} catch {
|
|
1042
|
+
// the /gf data channel is optional
|
|
1043
|
+
}
|
|
1044
|
+
|
|
352
1045
|
const hinted = new WeakSet();
|
|
353
1046
|
const closed = new Set();
|
|
354
1047
|
|
|
@@ -407,23 +1100,51 @@ export function apply(ctx, config = {}) {
|
|
|
407
1100
|
// event bus missing
|
|
408
1101
|
}
|
|
409
1102
|
|
|
410
|
-
|
|
1103
|
+
// Per-session tracking for the reply backfill. Session events are durable
|
|
1104
|
+
// envelopes `{ type, seq, time, data }`; the assistant message lives at
|
|
1105
|
+
// `event.data.message` (NOT `event.message` — the earlier wiring read the
|
|
1106
|
+
// wrong field, so every fill was a silent "no-text" no-op).
|
|
1107
|
+
const lastReplyBySession = new WeakMap(); // session -> { turn, text }
|
|
1108
|
+
const filledTurnsBySession = new WeakMap(); // session -> Set<turn>
|
|
411
1109
|
|
|
412
1110
|
try {
|
|
413
|
-
// Auto fill every assistant reply into the
|
|
414
|
-
// the same dsh session
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
1111
|
+
// Auto fill every COMPLETED turn's final assistant reply into the pending
|
|
1112
|
+
// dialogue turn of the same dsh session. `assistant/message` fires per
|
|
1113
|
+
// STEP (intermediate tool-call steps included), so it only feeds a
|
|
1114
|
+
// per-turn "last text" buffer; the durable `turn/end` event is the commit
|
|
1115
|
+
// point — one fill per (session, turn), skipped for interrupted/aborted
|
|
1116
|
+
// turns and for turns whose final message carried no text. Fills are
|
|
1117
|
+
// scoped by the subject session id, so a subagent's reply closes its own
|
|
1118
|
+
// dialogue session and can never overwrite the parent's pending turn.
|
|
418
1119
|
listen(ctx, "session/event", (session, event) => {
|
|
419
1120
|
try {
|
|
420
|
-
if (
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
1121
|
+
if (!session || typeof session !== "object") return;
|
|
1122
|
+
const type = event?.type;
|
|
1123
|
+
if (type === "assistant/message") {
|
|
1124
|
+
if (event?.data?.interrupted || event?.interrupted) return;
|
|
1125
|
+
const text = extractMessageText(sessionEventMessage(event));
|
|
1126
|
+
if (!text) return;
|
|
1127
|
+
const turn = event?.data?.turn ?? event?.turn;
|
|
1128
|
+
if (typeof turn !== "number") return;
|
|
1129
|
+
lastReplyBySession.set(session, { turn, text });
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
if (type === "turn/end") {
|
|
1133
|
+
const reason = event?.data?.reason ?? event?.reason;
|
|
1134
|
+
const kind = reason && typeof reason === "object" ? reason.kind : undefined;
|
|
1135
|
+
if (kind === "interrupted" || kind === "aborted") return;
|
|
1136
|
+
const turn = event?.data?.turn ?? event?.turn;
|
|
1137
|
+
if (typeof turn !== "number") return;
|
|
1138
|
+
const entry = lastReplyBySession.get(session);
|
|
1139
|
+
if (!entry || entry.turn !== turn) return;
|
|
1140
|
+
let filled = filledTurnsBySession.get(session);
|
|
1141
|
+
if (filled?.has(turn)) return; // already committed
|
|
1142
|
+
filled ??= new Set();
|
|
1143
|
+
filled.add(turn);
|
|
1144
|
+
filledTurnsBySession.set(session, filled);
|
|
1145
|
+
lastReplyBySession.delete(session);
|
|
1146
|
+
fillPendingReply(entry.text, session?.id, session?.header?.cwd ?? config.cwd, config);
|
|
1147
|
+
}
|
|
427
1148
|
} catch {
|
|
428
1149
|
// reply fill is optional
|
|
429
1150
|
}
|
|
@@ -451,4 +1172,9 @@ export function apply(ctx, config = {}) {
|
|
|
451
1172
|
} catch {
|
|
452
1173
|
// event bus missing
|
|
453
1174
|
}
|
|
1175
|
+
|
|
1176
|
+
// Cordis uses apply's return value as the plugin disposer: tear down the
|
|
1177
|
+
// /gf channel on unload (idempotent; the harness may also dispose it via
|
|
1178
|
+
// ctx.effect when available).
|
|
1179
|
+
return disposeNodesRpc;
|
|
454
1180
|
}
|