@mercury-fw/core 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +38 -0
- package/dist/index.d.ts +23 -0
- package/dist/src/admin/cli-routes.d.ts +22 -0
- package/dist/src/admin/env-file.d.ts +1 -0
- package/dist/src/admin/model-routes.d.ts +26 -0
- package/dist/src/admin/qdrant-scroll.d.ts +34 -0
- package/dist/src/admin/server.d.ts +40 -0
- package/dist/src/admin/wiki-routes.d.ts +31 -0
- package/dist/src/compose.d.ts +42 -0
- package/dist/src/config/define-config.d.ts +31 -0
- package/dist/src/cron/idle-session-cron.d.ts +80 -0
- package/dist/src/cron/idle-session-scanner.d.ts +16 -0
- package/dist/src/cron/self-review-cron.d.ts +55 -0
- package/dist/src/cron/semantic-consolidation.d.ts +71 -0
- package/dist/src/memory/embedder.d.ts +9 -0
- package/dist/src/memory/episodic-store.d.ts +121 -0
- package/dist/src/memory/memory-provider.d.ts +51 -0
- package/dist/src/memory/semantic-facts-store.d.ts +37 -0
- package/dist/src/memory/tool-corrections-store.d.ts +26 -0
- package/dist/src/memory/verbatim-archive-store.d.ts +86 -0
- package/dist/src/model/client.d.ts +24 -0
- package/dist/src/model/context-size.d.ts +30 -0
- package/dist/src/plugins/manifest.d.ts +29 -0
- package/dist/src/plugins/plugin-loader.d.ts +85 -0
- package/dist/src/router/channel-loader.d.ts +30 -0
- package/dist/src/router/provider.d.ts +7 -0
- package/dist/src/router/terminal-provider.d.ts +37 -0
- package/dist/src/router/terminal.d.ts +41 -0
- package/dist/src/router/tool-log.d.ts +65 -0
- package/dist/src/router/turn-runner.d.ts +86 -0
- package/dist/src/session/agent-turn.d.ts +266 -0
- package/dist/src/session/context-primer.d.ts +16 -0
- package/dist/src/session/episodic-summarizer.d.ts +25 -0
- package/dist/src/session/history.d.ts +95 -0
- package/dist/src/session/pending-confirmation.d.ts +8 -0
- package/dist/src/session/read-skill-tool.d.ts +4 -0
- package/dist/src/session/semantic-fact-extractor.d.ts +45 -0
- package/dist/src/session/step-info.d.ts +24 -0
- package/dist/src/session/summarizer.d.ts +23 -0
- package/dist/src/session/system-prompt.d.ts +38 -0
- package/dist/src/session/tool-correction-extractor.d.ts +43 -0
- package/dist/src/session/tool-log-buffer.d.ts +24 -0
- package/dist/src/session/tool-log-recall-tool.d.ts +18 -0
- package/dist/src/session/tool-start-hook.d.ts +57 -0
- package/dist/src/tools/display-store.d.ts +36 -0
- package/dist/src/tools/present-tool.d.ts +23 -0
- package/dist/src/wiki/frontmatter-schema.d.ts +53 -0
- package/dist/src/wiki/index-entry.d.ts +15 -0
- package/dist/src/wiki/orphan-detector.d.ts +1 -0
- package/dist/src/wiki/self-review-runner.d.ts +48 -0
- package/dist/src/wiki/self-review-tools.d.ts +22 -0
- package/dist/src/wiki/vault-cli.d.ts +2 -0
- package/dist/src/wiki/vault-init.d.ts +7 -0
- package/dist/src/wiki/wiki-note.d.ts +62 -0
- package/dist/src/wiki/wiki-read.d.ts +27 -0
- package/dist/src/wiki/wiki-tools.d.ts +7 -0
- package/index.ts +23 -0
- package/package.json +49 -0
- package/src/admin/cli-routes.ts +48 -0
- package/src/admin/env-file.ts +29 -0
- package/src/admin/model-routes.ts +71 -0
- package/src/admin/public/index.html +416 -0
- package/src/admin/qdrant-scroll.ts +45 -0
- package/src/admin/server.ts +188 -0
- package/src/admin/wiki-routes.ts +93 -0
- package/src/compose.ts +599 -0
- package/src/config/define-config.ts +35 -0
- package/src/cron/.gitkeep +0 -0
- package/src/cron/idle-session-cron.ts +144 -0
- package/src/cron/idle-session-scanner.ts +37 -0
- package/src/cron/self-review-cron.ts +103 -0
- package/src/cron/semantic-consolidation.ts +228 -0
- package/src/memory/.gitkeep +0 -0
- package/src/memory/embedder.ts +15 -0
- package/src/memory/episodic-store.ts +183 -0
- package/src/memory/memory-provider.ts +98 -0
- package/src/memory/semantic-facts-store.ts +89 -0
- package/src/memory/tool-corrections-store.ts +72 -0
- package/src/memory/verbatim-archive-store.ts +202 -0
- package/src/model/client.ts +33 -0
- package/src/model/context-size.ts +42 -0
- package/src/plugins/manifest.ts +47 -0
- package/src/plugins/plugin-loader.ts +205 -0
- package/src/router/channel-loader.ts +56 -0
- package/src/router/provider.ts +7 -0
- package/src/router/terminal-provider.ts +155 -0
- package/src/router/terminal.ts +151 -0
- package/src/router/tool-log.ts +116 -0
- package/src/router/turn-runner.ts +205 -0
- package/src/session/agent-turn.ts +391 -0
- package/src/session/context-primer.ts +134 -0
- package/src/session/episodic-summarizer.ts +38 -0
- package/src/session/history.ts +168 -0
- package/src/session/pending-confirmation.ts +8 -0
- package/src/session/read-skill-tool.ts +38 -0
- package/src/session/semantic-fact-extractor.ts +69 -0
- package/src/session/step-info.ts +27 -0
- package/src/session/summarizer.ts +36 -0
- package/src/session/system-prompt.ts +142 -0
- package/src/session/tool-correction-extractor.ts +133 -0
- package/src/session/tool-log-buffer.ts +73 -0
- package/src/session/tool-log-recall-tool.ts +38 -0
- package/src/session/tool-start-hook.ts +164 -0
- package/src/tools/display-store.ts +89 -0
- package/src/tools/present-tool.ts +41 -0
- package/src/wiki/.gitkeep +0 -0
- package/src/wiki/frontmatter-schema.ts +49 -0
- package/src/wiki/index-entry.ts +59 -0
- package/src/wiki/orphan-detector.ts +61 -0
- package/src/wiki/self-review-runner.ts +133 -0
- package/src/wiki/self-review-tools.ts +162 -0
- package/src/wiki/vault-cli.ts +143 -0
- package/src/wiki/vault-init.ts +43 -0
- package/src/wiki/wiki-note.ts +326 -0
- package/src/wiki/wiki-read.ts +122 -0
- package/src/wiki/wiki-tools.ts +112 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for the terminal channel's tool-call visibility (see
|
|
3
|
+
* `src/index.ts`'s `onStepFinish` wiring): tool results can be
|
|
4
|
+
* arbitrarily large — a single Jira issue search can return tens of KB
|
|
5
|
+
* of raw API JSON — so printing them in full on every turn isn't
|
|
6
|
+
* readable. `truncateForDisplay` bounds what gets printed live;
|
|
7
|
+
* `parseDumpCommand` + `writeDump` back the terminal-only `/dump`
|
|
8
|
+
* command, for when the full untruncated output is actually needed.
|
|
9
|
+
*
|
|
10
|
+
* This module is stateless and doesn't know about turns or sessions —
|
|
11
|
+
* `src/index.ts` owns the per-turn step history and decides when to call
|
|
12
|
+
* these.
|
|
13
|
+
*/
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import type { StepInfo } from "../session/step-info.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Stringifies `value` as JSON, truncating to `maxChars` and appending a
|
|
19
|
+
* marker with the real total length and a pointer to `/dump` when it
|
|
20
|
+
* doesn't fit — so a human watching the terminal sees something bounded
|
|
21
|
+
* but still knows more is available and how to get it.
|
|
22
|
+
*/
|
|
23
|
+
export function truncateForDisplay(value: unknown, maxChars: number): string {
|
|
24
|
+
const json = JSON.stringify(value);
|
|
25
|
+
if (json.length <= maxChars) {
|
|
26
|
+
return json;
|
|
27
|
+
}
|
|
28
|
+
return `${json.slice(0, maxChars)}… (truncated, ${json.length} chars total — run /dump to write the full output to a file)`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Builds the file `/dump` writes to when the user doesn't give an
|
|
33
|
+
* explicit path. Lands in the OS temp dir, not the process's cwd — in
|
|
34
|
+
* the Docker image that's `/app`, owned by root, where the `mercury`
|
|
35
|
+
* user can read/execute existing files but not create new ones (every
|
|
36
|
+
* default-path `/dump` failed with EACCES until this). Includes a
|
|
37
|
+
* timestamp (colons/dots replaced since they aren't valid in filenames
|
|
38
|
+
* on every filesystem) so repeated `/dump` calls land in separate files
|
|
39
|
+
* instead of silently overwriting a fixed default each time.
|
|
40
|
+
*/
|
|
41
|
+
export function defaultDumpPath(now: Date = new Date()): string {
|
|
42
|
+
return `${tmpdir()}/mercury-last-tools-${now.toISOString().replace(/[:.]/g, "-")}.json`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parses a `/dump [path]` command line. Returns null for anything that
|
|
47
|
+
* isn't exactly this command (including regular conversation input, and
|
|
48
|
+
* a slash-prefixed word that merely starts with "dump") — the caller
|
|
49
|
+
* uses this to tell a real command from a message meant for the model.
|
|
50
|
+
* `path` is undefined when none was given — the caller decides the
|
|
51
|
+
* default (see `defaultDumpPath`), since computing "now" here would make
|
|
52
|
+
* this function's output depend on when it happens to run.
|
|
53
|
+
*/
|
|
54
|
+
export function parseDumpCommand(line: string): { path: string | undefined } | null {
|
|
55
|
+
const match = line.trim().match(/^\/dump(?:\s+(\S+))?$/);
|
|
56
|
+
if (!match) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return { path: match[1] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Writes `steps` to `path` as indented JSON — human-readable, since this
|
|
64
|
+
* is the path a person opens by hand to inspect what a tool actually
|
|
65
|
+
* returned, not something machine-parsed downstream.
|
|
66
|
+
*/
|
|
67
|
+
export async function writeDump(path: string, steps: StepInfo[]): Promise<void> {
|
|
68
|
+
await Bun.write(path, JSON.stringify(steps, null, 2));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Describes what happened to the tool call identified by `toolCallId`
|
|
73
|
+
* within `step`: its result if it executed, the `tool-error` content
|
|
74
|
+
* part if it failed before executing (e.g. arguments that don't match
|
|
75
|
+
* the tool's schema — there's no `toolResults` entry for this case, it
|
|
76
|
+
* only shows up in `content`), or an explicit "(none)" if neither is
|
|
77
|
+
* present. Printing "(none)" for an actual failure was the bug this
|
|
78
|
+
* fixes — it read as "nothing happened" when something had, in fact,
|
|
79
|
+
* gone wrong and been silently dropped from view.
|
|
80
|
+
*/
|
|
81
|
+
export function describeToolOutcome(step: StepInfo, toolCallId: string, maxChars: number): string {
|
|
82
|
+
const result = step.toolResults.find((r) => r.toolCallId === toolCallId);
|
|
83
|
+
if (result) {
|
|
84
|
+
return `[tool result] ${truncateForDisplay(result.output, maxChars)}`;
|
|
85
|
+
}
|
|
86
|
+
const errorPart = step.content.find((p) => p.type === "tool-error" && p.toolCallId === toolCallId);
|
|
87
|
+
if (errorPart) {
|
|
88
|
+
return `[tool error] ${truncateForDisplay(errorPart.error, maxChars)}`;
|
|
89
|
+
}
|
|
90
|
+
return "[tool result] (none)";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Formats real token counts for display next to the terminal prompt:
|
|
95
|
+
* `usedTokens` is the real `inputTokens` the last turn's call reported
|
|
96
|
+
* (see `src/session/agent-turn.ts`'s `onUsage`), `maxTokens` is the
|
|
97
|
+
* context length Ollama actually has the model loaded with right now
|
|
98
|
+
* (see `src/model/context-size.ts`) — not an estimate, and not the
|
|
99
|
+
* model's architectural maximum, which can overstate what's really
|
|
100
|
+
* usable. A model degrading over a long conversation is hard to tell
|
|
101
|
+
* apart by eye from "the context is genuinely near full" — this gives a
|
|
102
|
+
* live number instead of having to guess.
|
|
103
|
+
*
|
|
104
|
+
* `maxTokens` is `null` before the model has been loaded at least once
|
|
105
|
+
* this process (nothing to report yet) — the denominator is omitted
|
|
106
|
+
* rather than showing a misleading `/~0k`. `usedTokens` is `undefined`
|
|
107
|
+
* before the first turn has completed, shown as `?` for the same reason.
|
|
108
|
+
*/
|
|
109
|
+
export function formatContextUsage(usedTokens: number | undefined, maxTokens: number | null): string {
|
|
110
|
+
const used = usedTokens === undefined ? "?" : Math.floor(usedTokens / 1000);
|
|
111
|
+
if (maxTokens === null) {
|
|
112
|
+
return `[~${used}k tokens] `;
|
|
113
|
+
}
|
|
114
|
+
const max = Math.floor(maxTokens / 1000);
|
|
115
|
+
return `[~${used}k/~${max}k tokens] `;
|
|
116
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deduplicates what `src/index.ts` used to do twice: two near-identical
|
|
3
|
+
* ~50-line closures, one per channel, that (1) tracked the session for
|
|
4
|
+
* Layer-3 capture when the channel has a real per-user identity, (2) ran
|
|
5
|
+
* `runTurn` with a channel-specific tool set/system prompt/output sink,
|
|
6
|
+
* and (3) mirrored new messages to Qdrant and extracted procedural
|
|
7
|
+
* corrections once the turn resolved. `createTurnRunner` is that shared
|
|
8
|
+
* body, parameterized entirely by `Provider`/`InboundTurn`/`TurnSink`
|
|
9
|
+
* (`src/router/provider.ts`) so it doesn't know or care which provider a
|
|
10
|
+
* given turn came from — same channel-agnostic spirit as `runTurn` itself.
|
|
11
|
+
*/
|
|
12
|
+
import type { LanguageModel, Tool } from "ai";
|
|
13
|
+
import { runTurn } from "../session/agent-turn.ts";
|
|
14
|
+
import type { StepInfo } from "../session/step-info.ts";
|
|
15
|
+
// `PostTurnGuard` is part of the plugin contract (a plugin's `build()` may
|
|
16
|
+
// return these to inspect/rewrite the model's finished text), so it lives in
|
|
17
|
+
// `@mercury-fw/plugin-types` and is re-exported here for the core callers that
|
|
18
|
+
// import it from this module. No plugin ships one today; the mechanism stays
|
|
19
|
+
// generic for future use. A guard that throws is caught by the core and never
|
|
20
|
+
// blocks delivery — a guard failure is a quality miss, not a reason to
|
|
21
|
+
// withhold an already-generated answer.
|
|
22
|
+
import type { PostTurnGuard } from "@mercury-fw/plugin-types";
|
|
23
|
+
export type { PostTurnGuard };
|
|
24
|
+
import type { SessionHistory } from "../session/history.ts";
|
|
25
|
+
import { recordStep } from "../session/tool-log-buffer.ts";
|
|
26
|
+
import type { HandleTurn, InboundTurn, TurnSink } from "./provider.ts";
|
|
27
|
+
|
|
28
|
+
export type TurnRunnerDeps = {
|
|
29
|
+
model: LanguageModel;
|
|
30
|
+
/** Both variants, precomposed by the composition root; selected per turn by `turn.multiUser`. */
|
|
31
|
+
systemPrompts: { singleUser: string; multiUser: string };
|
|
32
|
+
buildTools: (
|
|
33
|
+
sessionKey: string,
|
|
34
|
+
wikiUserId: string,
|
|
35
|
+
onToolStart?: TurnSink["onToolStart"],
|
|
36
|
+
onToolFinish?: TurnSink["onToolFinish"],
|
|
37
|
+
) => Record<string, Tool>;
|
|
38
|
+
/**
|
|
39
|
+
* `userId` is forwarded (not interpreted here) so a provider's own
|
|
40
|
+
* closure can decide whether to seed a first-ever session with a
|
|
41
|
+
* context primer (see `src/session/context-primer.ts`) — building one
|
|
42
|
+
* needs a real per-user identity, which only some providers have.
|
|
43
|
+
*/
|
|
44
|
+
getOrCreateHistory: (sessionKey: string, trackForCapture: boolean, userId: string | undefined) => Promise<SessionHistory> | SessionHistory;
|
|
45
|
+
/** Layer-3 session tracking (sessionUsers map + idle scanner touch). Only for turns that carry a userId. */
|
|
46
|
+
trackSession: (sessionKey: string, userId: string, at: number) => void;
|
|
47
|
+
/** Refreshes this turn's tool-status callbacks for out-of-band capture messages. */
|
|
48
|
+
registerCaptureCallback: (sessionKey: string, onToolStart: TurnSink["onToolStart"], onToolFinish: TurnSink["onToolFinish"]) => void;
|
|
49
|
+
/** Mid-conversation Layer-3 capture threshold check. Only for turns that carry a userId. */
|
|
50
|
+
maybeCapture: (sessionKey: string, history: SessionHistory) => Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Archives one message of the verbatim user↔model exchange (issue #4).
|
|
53
|
+
* Wired by the composition root to the verbatim-archive provider; absent
|
|
54
|
+
* on an instance with no such provider. Only called for turns that carry a
|
|
55
|
+
* `userId` (the archive is per-user, like Layer-3 capture), keyed on the
|
|
56
|
+
* space-independent `wikiUserId`, and only ever with the model's own answer
|
|
57
|
+
* text — never the appended `present` displays.
|
|
58
|
+
*/
|
|
59
|
+
captureVerbatim?: (msg: {
|
|
60
|
+
sessionKey: string;
|
|
61
|
+
userId: string;
|
|
62
|
+
role: "user" | "assistant";
|
|
63
|
+
content: string;
|
|
64
|
+
}) => Promise<void>;
|
|
65
|
+
processToolCorrections: (steps: StepInfo[], onToolStart: TurnSink["onToolStart"], onToolFinish: TurnSink["onToolFinish"]) => Promise<void>;
|
|
66
|
+
logStep: (prefix: string, step: StepInfo) => void;
|
|
67
|
+
/** Test seam; defaults to the real `recordStep`. */
|
|
68
|
+
recordStepFn?: typeof recordStep;
|
|
69
|
+
/** Test seam; defaults to the real `runTurn`. */
|
|
70
|
+
runTurnFn?: typeof runTurn;
|
|
71
|
+
/**
|
|
72
|
+
* Post-turn guards contributed by loaded plugins, run in order over the
|
|
73
|
+
* model's finished text (see `PostTurnGuard`). Empty/absent on an instance
|
|
74
|
+
* with no plugin that registers one. The composition root builds these; the
|
|
75
|
+
* core knows nothing about what any of them does.
|
|
76
|
+
*/
|
|
77
|
+
postTurnGuards?: PostTurnGuard[];
|
|
78
|
+
/**
|
|
79
|
+
* Test seam; defaults to `console.log`. Receives a guard's own `log` line,
|
|
80
|
+
* or the core's own note when a guard throws. Exists to measure real-world
|
|
81
|
+
* guard frequency before investing further.
|
|
82
|
+
*/
|
|
83
|
+
logPostTurnGuardFn?: (message: string) => void;
|
|
84
|
+
/** Test seam; defaults to `Date.now`. */
|
|
85
|
+
now?: () => number;
|
|
86
|
+
/**
|
|
87
|
+
* Returns the display artifacts the model surfaced via `present` this turn
|
|
88
|
+
* (see `display-store.ts`), in stash order, to append after the model's
|
|
89
|
+
* text. Absent on an instance with no display store — nothing is appended.
|
|
90
|
+
* The old unconditional splicing of every tool-produced display is gone:
|
|
91
|
+
* an artifact is shown only when the model explicitly presented it.
|
|
92
|
+
*/
|
|
93
|
+
takeSurfacedDisplays?: (sessionKey: string) => string[];
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Builds the shared `HandleTurn` every provider's driver calls once it has a real message to run through the model. */
|
|
97
|
+
export function createTurnRunner(deps: TurnRunnerDeps): HandleTurn {
|
|
98
|
+
const postTurnGuards = deps.postTurnGuards ?? [];
|
|
99
|
+
const logPostTurnGuard = deps.logPostTurnGuardFn ?? ((message: string) => console.log(message));
|
|
100
|
+
|
|
101
|
+
return async (turn: InboundTurn, sink: TurnSink): Promise<void> => {
|
|
102
|
+
const tracked = turn.userId !== undefined;
|
|
103
|
+
if (tracked) {
|
|
104
|
+
deps.trackSession(turn.sessionKey, turn.userId as string, (deps.now ?? Date.now)());
|
|
105
|
+
deps.registerCaptureCallback(turn.sessionKey, sink.onToolStart, sink.onToolFinish);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const steps: StepInfo[] = [];
|
|
109
|
+
let history: SessionHistory;
|
|
110
|
+
// The model's own answer text (post-guards, before any surfaced `present`
|
|
111
|
+
// display is appended) — what the verbatim archive stores as the
|
|
112
|
+
// assistant's message. Assigned once the turn resolves successfully.
|
|
113
|
+
let assistantText = "";
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Inside the try, not before it: a failure building the history
|
|
117
|
+
// (e.g. the context-primer's Qdrant query) must still release the
|
|
118
|
+
// sink (see TurnSink.dispose's doc comment) — a stuck-note timer
|
|
119
|
+
// already scheduled when the sink was constructed keeps running
|
|
120
|
+
// otherwise, firing on its own 60s schedule regardless of whether
|
|
121
|
+
// the turn itself already failed and was reported.
|
|
122
|
+
history = await deps.getOrCreateHistory(turn.sessionKey, tracked, turn.userId);
|
|
123
|
+
const text = await (deps.runTurnFn ?? runTurn)(history, turn.text, {
|
|
124
|
+
model: deps.model,
|
|
125
|
+
tools: deps.buildTools(turn.sessionKey, turn.wikiUserId, sink.onToolStart, sink.onToolFinish),
|
|
126
|
+
system: turn.multiUser ? deps.systemPrompts.multiUser : deps.systemPrompts.singleUser,
|
|
127
|
+
onTextChunk: sink.onTextChunk,
|
|
128
|
+
onReasoningChunk: sink.onReasoningChunk,
|
|
129
|
+
onReasoningEnd: sink.onReasoningEnd,
|
|
130
|
+
onStepFinish: (step) => {
|
|
131
|
+
steps.push(step);
|
|
132
|
+
deps.logStep(turn.logPrefix, step);
|
|
133
|
+
(deps.recordStepFn ?? recordStep)(turn.channel, turn.sessionKey, step);
|
|
134
|
+
sink.onStep?.(step);
|
|
135
|
+
},
|
|
136
|
+
onUsage: sink.onUsage,
|
|
137
|
+
abortSignal: turn.abortSignal,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
let correctedText = text;
|
|
141
|
+
// Plugin-contributed post-turn guards, run in order. `shouldRun` gates
|
|
142
|
+
// both the rewrite and its status indicator (so a guard that doesn't
|
|
143
|
+
// engage shows nothing), and reuses the same onToolStart/onToolFinish
|
|
144
|
+
// machinery already shared with real tool calls and Layer-3 capture pings
|
|
145
|
+
// — terminal's dim-print and Google Chat's status-card patching both
|
|
146
|
+
// handle any (label, detail?, toolCallId?) triple generically. A guard
|
|
147
|
+
// that throws is caught here and never blocks delivery.
|
|
148
|
+
for (const guard of postTurnGuards) {
|
|
149
|
+
if (!guard.shouldRun(correctedText)) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
sink.onToolStart(guard.statusLabel, undefined, guard.statusId);
|
|
153
|
+
try {
|
|
154
|
+
const guarded = await guard.run(correctedText, steps);
|
|
155
|
+
sink.onToolFinish?.(guard.statusId, guarded.outcome);
|
|
156
|
+
if (guarded.log !== undefined) {
|
|
157
|
+
logPostTurnGuard(guarded.log);
|
|
158
|
+
}
|
|
159
|
+
correctedText = guarded.text;
|
|
160
|
+
} catch (err) {
|
|
161
|
+
sink.onToolFinish?.(guard.statusId, "failed");
|
|
162
|
+
logPostTurnGuard(
|
|
163
|
+
`[post-turn-guard] guard "${guard.statusId}" threw, kept text unchanged: ${String(err instanceof Error ? err.message : err)}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (correctedText !== text) {
|
|
169
|
+
history.replaceLastAssistantMessage(correctedText);
|
|
170
|
+
}
|
|
171
|
+
assistantText = correctedText;
|
|
172
|
+
|
|
173
|
+
// Append only what the model chose to `present` this turn — never the
|
|
174
|
+
// whole set of tool-produced displays. Appending (not replacing) keeps
|
|
175
|
+
// the already-streamed prefix intact, so terminal.ts's safe-slice holds.
|
|
176
|
+
const surfaced = deps.takeSurfacedDisplays?.(turn.sessionKey) ?? [];
|
|
177
|
+
const finalText = [correctedText, ...surfaced].filter((part) => part !== "").join("\n\n");
|
|
178
|
+
await sink.finalize(finalText);
|
|
179
|
+
} finally {
|
|
180
|
+
sink.dispose();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (tracked) {
|
|
184
|
+
await deps.maybeCapture(turn.sessionKey, history);
|
|
185
|
+
// Fail-soft: the verbatim archive is pure enrichment (principle #3).
|
|
186
|
+
// The answer is already delivered; a capture failure must never
|
|
187
|
+
// propagate out of this awaited handler and take the process down.
|
|
188
|
+
if (deps.captureVerbatim) {
|
|
189
|
+
// The archive is per-person and space-independent, so it keys on
|
|
190
|
+
// wikiUserId — Mercury's canonical per-user id, the same identity the
|
|
191
|
+
// wiki notes use — not the space-scoped session.
|
|
192
|
+
const userId = turn.wikiUserId;
|
|
193
|
+
try {
|
|
194
|
+
await deps.captureVerbatim({ sessionKey: turn.sessionKey, userId, role: "user", content: turn.text });
|
|
195
|
+
await deps.captureVerbatim({ sessionKey: turn.sessionKey, userId, role: "assistant", content: assistantText });
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.log(
|
|
198
|
+
`[verbatim-archive] capture failed, turn unaffected: ${String(err instanceof Error ? err.message : err)}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await deps.processToolCorrections(steps, sink.onToolStart, sink.onToolFinish);
|
|
204
|
+
};
|
|
205
|
+
}
|