@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,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queries Ollama directly (its native HTTP API, not the AI SDK — which
|
|
3
|
+
* has no concept of this) for the real context window size the running
|
|
4
|
+
* model is currently loaded with.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists, instead of just reading the model's architectural max
|
|
7
|
+
* context length (available via `/api/show`'s `model_info`): Ollama can
|
|
8
|
+
* load a model with a smaller effective context window than what the
|
|
9
|
+
* model architecture supports (e.g. capped by `OLLAMA_CONTEXT_LENGTH` on
|
|
10
|
+
* the server, or a per-request `num_ctx`), so the architectural max can
|
|
11
|
+
* overstate what's actually usable. `/api/ps` reports what's actually
|
|
12
|
+
* loaded right now — verified live against a real Ollama server (GB10,
|
|
13
|
+
* qwen3.5:35b): it loaded with the full architectural context (262144),
|
|
14
|
+
* but that's specific to this deployment's configuration, not something
|
|
15
|
+
* to assume in general.
|
|
16
|
+
*
|
|
17
|
+
* Used by: `src/index.ts`, to show a real (not estimated) context-usage
|
|
18
|
+
* indicator next to the terminal prompt — see `src/router/tool-log.ts`'s
|
|
19
|
+
* `formatContextUsage`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the context length Ollama has the given model loaded with
|
|
24
|
+
* right now, or `null` if that model isn't currently loaded (e.g. before
|
|
25
|
+
* its first use this process) — `/api/ps` only lists loaded models, so
|
|
26
|
+
* there's nothing to report yet. Querying again after the model has
|
|
27
|
+
* been used at least once will find it.
|
|
28
|
+
*
|
|
29
|
+
* @param fetchFn - Test seam; defaults to the real global `fetch`.
|
|
30
|
+
*/
|
|
31
|
+
export async function getLoadedContextLength(
|
|
32
|
+
host: string,
|
|
33
|
+
model: string,
|
|
34
|
+
fetchFn: typeof fetch = fetch,
|
|
35
|
+
): Promise<number | null> {
|
|
36
|
+
const response = await fetchFn(`${host}/api/ps`);
|
|
37
|
+
const data = (await response.json()) as {
|
|
38
|
+
models?: Array<{ model: string; context_length?: number }>;
|
|
39
|
+
};
|
|
40
|
+
const entry = data.models?.find((m) => m.model === model);
|
|
41
|
+
return entry?.context_length ?? null;
|
|
42
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the read-only installation manifest the HTTP surface exposes (4b):
|
|
3
|
+
* what this instance actually loaded — the core's plugin apiVersion, each
|
|
4
|
+
* plugin (name, declared apiVersion, whether it activated, the skills it
|
|
5
|
+
* contributes, whether it has a `build()`), the active CLI binaries, and the
|
|
6
|
+
* skill descriptors. Pure and side-effect-free so it's unit-tested directly.
|
|
7
|
+
*
|
|
8
|
+
* A plugin no longer exposes a central config, so activation is reported from
|
|
9
|
+
* the loader's `activated` set rather than inferred from a config map. The
|
|
10
|
+
* active CLI binaries are the activated plugins plus the file-based CLIs (the
|
|
11
|
+
* residual with no owning plugin).
|
|
12
|
+
*/
|
|
13
|
+
import { PLUGIN_API_VERSION, type Plugin, type Skill } from "@mercury-fw/plugin-types";
|
|
14
|
+
|
|
15
|
+
export type PluginManifest = {
|
|
16
|
+
coreApiVersion: number;
|
|
17
|
+
plugins: Array<{
|
|
18
|
+
name: string;
|
|
19
|
+
apiVersion: number;
|
|
20
|
+
active: boolean;
|
|
21
|
+
skills: string[];
|
|
22
|
+
hasBuild: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
activeClis: string[];
|
|
25
|
+
skills: Array<{ name: string; description: string }>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function buildPluginManifest(
|
|
29
|
+
plugins: Plugin[],
|
|
30
|
+
activatedPluginNames: string[],
|
|
31
|
+
fileCliBinaries: string[],
|
|
32
|
+
skills: Skill[],
|
|
33
|
+
): PluginManifest {
|
|
34
|
+
const activated = new Set(activatedPluginNames);
|
|
35
|
+
return {
|
|
36
|
+
coreApiVersion: PLUGIN_API_VERSION,
|
|
37
|
+
plugins: plugins.map((p) => ({
|
|
38
|
+
name: p.name,
|
|
39
|
+
apiVersion: p.apiVersion,
|
|
40
|
+
active: activated.has(p.name),
|
|
41
|
+
skills: (p.skills ?? []).map((s) => s.name),
|
|
42
|
+
hasBuild: Boolean(p.build),
|
|
43
|
+
})),
|
|
44
|
+
activeClis: [...activatedPluginNames, ...fileCliBinaries].sort(),
|
|
45
|
+
skills: skills.map((s) => ({ name: s.name, description: s.description })),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generic, fail-soft plugin loader. Turns a hand-listed set of plugin
|
|
3
|
+
* modules into what the composition root wires into a running Mercury: the
|
|
4
|
+
* per-plugin tool bundles the model-facing tools are built from, their status
|
|
5
|
+
* describers, the system-prompt fragments, and the post-turn guards. It knows
|
|
6
|
+
* nothing about any specific plugin — or about CLIs — the composition root
|
|
7
|
+
* names them, this loop processes them identically.
|
|
8
|
+
*
|
|
9
|
+
* Two properties it guarantees, both required by the plan's fail-soft step:
|
|
10
|
+
* - a plugin contributes only when it is enabled on this instance (its name is
|
|
11
|
+
* in MERCURY_CLIS);
|
|
12
|
+
* - a plugin that fails — its `build()` throws — degrades as a single unit:
|
|
13
|
+
* none of its contributions land, the failure is
|
|
14
|
+
* logged with detail, and every other plugin and the process itself carry
|
|
15
|
+
* on. This is the CLAUDE.md lesson made structural: one plugin's bad tick
|
|
16
|
+
* never takes down the rest.
|
|
17
|
+
*
|
|
18
|
+
* The contract types (`Plugin`, `SessionToolContext`, `CliPostProcessor`,
|
|
19
|
+
* `PostTurnGuard`, and the `build()` context) live in `@mercury-fw/plugin-types`,
|
|
20
|
+
* the shared package both the core and the plugins import, so neither mirrors
|
|
21
|
+
* the other. This module keeps only the core-runtime pieces: how a hand-listed
|
|
22
|
+
* set of plugins is loaded and what the load produces.
|
|
23
|
+
*/
|
|
24
|
+
import type { LanguageModel } from "ai";
|
|
25
|
+
import { PLUGIN_API_VERSION } from "@mercury-fw/plugin-types";
|
|
26
|
+
import type { Plugin, CliPostProcessor, PostTurnGuard, Skill, SessionToolContext } from "@mercury-fw/plugin-types";
|
|
27
|
+
import type { Tool } from "ai";
|
|
28
|
+
|
|
29
|
+
/** One plugin's tool contribution, kept paired so the composition root can build
|
|
30
|
+
* its tools from its own post-processor: the plugin authored both, but a
|
|
31
|
+
* formatter decorator wraps the post-processor after `build()` returns, so the
|
|
32
|
+
* factory receives the final one at invocation rather than closing over it. */
|
|
33
|
+
export interface SessionToolBundle {
|
|
34
|
+
build: (ctx: SessionToolContext, postProcess?: CliPostProcessor) => Record<string, Tool>;
|
|
35
|
+
postProcess?: CliPostProcessor;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What the loader hands back to the composition root, aggregated across every
|
|
39
|
+
* plugin that loaded. The core knows nothing about CLIs: a plugin's tool is an
|
|
40
|
+
* opaque `SessionToolBundle` it contributed, not a config the core assembles. */
|
|
41
|
+
export interface LoadedPlugins {
|
|
42
|
+
promptFragments: string[];
|
|
43
|
+
skills: Skill[];
|
|
44
|
+
/** Per-plugin tool factories + their post-processors; the composition root
|
|
45
|
+
* invokes each per turn with the session context (see `SessionToolBundle`). */
|
|
46
|
+
sessionToolBundles: SessionToolBundle[];
|
|
47
|
+
/** Merged across plugins, keyed by the tool name each contributes, turning a
|
|
48
|
+
* tool call's input into its status label. */
|
|
49
|
+
toolStatusDescribers: Record<string, (input: unknown) => string>;
|
|
50
|
+
postTurnGuards: PostTurnGuard[];
|
|
51
|
+
/** Names of the plugins that fully activated — for read-only introspection
|
|
52
|
+
* (the manifest), since a plugin's tool is opaque and there's no central
|
|
53
|
+
* config map to infer activation from anymore. */
|
|
54
|
+
activated: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Everything the loader needs from the composition root: which plugins are
|
|
58
|
+
* enabled on this instance, and the runtime context every `build()` gets. */
|
|
59
|
+
export interface PluginLoadContext {
|
|
60
|
+
enabledClis: string[];
|
|
61
|
+
model: LanguageModel;
|
|
62
|
+
env: Record<string, string | undefined>;
|
|
63
|
+
log: (msg: string) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Orders `plugins` so every plugin comes after each of its declared `dependsOn`
|
|
68
|
+
* (a stable topological sort: among plugins with no ordering constraint between
|
|
69
|
+
* them, listing order is preserved). Only edges to a plugin actually present in
|
|
70
|
+
* the set are honored — an unknown dependency name creates no edge and is
|
|
71
|
+
* caught later, at activation, as an absent dependency. Any plugin left over
|
|
72
|
+
* after the sort is part of, or downstream of, a dependency **cycle**: it comes
|
|
73
|
+
* back in `cyclic`, never in `ordered`, so a cycle degrades fail-soft (the
|
|
74
|
+
* caller skips those plugins) instead of dropping the whole load.
|
|
75
|
+
*/
|
|
76
|
+
export function orderByDependencies(plugins: Plugin[]): { ordered: Plugin[]; cyclic: Plugin[] } {
|
|
77
|
+
const present = new Set(plugins.map((p) => p.name));
|
|
78
|
+
const remainingDeps = new Map<string, number>();
|
|
79
|
+
for (const p of plugins) {
|
|
80
|
+
// Distinct present dependencies only: the emit loop decrements once per
|
|
81
|
+
// dependency, so counting a name listed twice would wedge the plugin at a
|
|
82
|
+
// count that never reaches zero — misreported as a cycle (regression).
|
|
83
|
+
remainingDeps.set(p.name, new Set((p.dependsOn ?? []).filter((d) => present.has(d))).size);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const ordered: Plugin[] = [];
|
|
87
|
+
const emitted = new Set<string>();
|
|
88
|
+
// Repeatedly emit every plugin whose remaining dependencies are all already
|
|
89
|
+
// emitted, scanning in listing order so independent plugins keep it. Loop
|
|
90
|
+
// until a full pass emits nothing — whatever is left then is cyclic.
|
|
91
|
+
let progressed = true;
|
|
92
|
+
while (progressed) {
|
|
93
|
+
progressed = false;
|
|
94
|
+
for (const p of plugins) {
|
|
95
|
+
if (emitted.has(p.name)) continue;
|
|
96
|
+
if ((remainingDeps.get(p.name) ?? 0) !== 0) continue;
|
|
97
|
+
emitted.add(p.name);
|
|
98
|
+
ordered.push(p);
|
|
99
|
+
progressed = true;
|
|
100
|
+
for (const other of plugins) {
|
|
101
|
+
if (!emitted.has(other.name) && (other.dependsOn ?? []).includes(p.name)) {
|
|
102
|
+
remainingDeps.set(other.name, (remainingDeps.get(other.name) ?? 0) - 1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const cyclic = plugins.filter((p) => !emitted.has(p.name));
|
|
109
|
+
return { ordered, cyclic };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Loads every plugin in `plugins`, returning their combined contributions.
|
|
114
|
+
* Plugins load in dependency-first order (see `orderByDependencies`), not
|
|
115
|
+
* listing order — though listing order is preserved among plugins with no
|
|
116
|
+
* dependency between them. Never throws — a plugin that fails is logged and
|
|
117
|
+
* skipped, its contributions staged and merged only once the whole plugin
|
|
118
|
+
* succeeds so a later failure can't leave it half-wired. A plugin whose
|
|
119
|
+
* declared `dependsOn` isn't fully activated (a dependency disabled, failed,
|
|
120
|
+
* unknown, or itself skipped) is skipped fail-soft too, transitively.
|
|
121
|
+
*/
|
|
122
|
+
export async function loadPlugins(plugins: Plugin[], ctx: PluginLoadContext): Promise<LoadedPlugins> {
|
|
123
|
+
const promptFragments: string[] = [];
|
|
124
|
+
const skills: Skill[] = [];
|
|
125
|
+
const sessionToolBundles: SessionToolBundle[] = [];
|
|
126
|
+
const toolStatusDescribers: Record<string, (input: unknown) => string> = {};
|
|
127
|
+
const postTurnGuards: PostTurnGuard[] = [];
|
|
128
|
+
|
|
129
|
+
const { ordered, cyclic } = orderByDependencies(plugins);
|
|
130
|
+
// A plugin caught in a cycle can't be ordered, so it can't load. Report it
|
|
131
|
+
// only when it's enabled — a disabled plugin contributes nothing regardless,
|
|
132
|
+
// same silence as any other disabled plugin.
|
|
133
|
+
for (const plugin of cyclic) {
|
|
134
|
+
if (ctx.enabledClis.includes(plugin.name)) {
|
|
135
|
+
ctx.log(`plugin "${plugin.name}" not activated: part of or depends on a dependency cycle`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Names that fully activated — a dependency must be in here for a dependent
|
|
140
|
+
// to load. Populated in dependency-first order, so a dependent is always
|
|
141
|
+
// processed after its dependencies have had their chance.
|
|
142
|
+
const activated = new Set<string>();
|
|
143
|
+
|
|
144
|
+
for (const plugin of ordered) {
|
|
145
|
+
// Not enabled on this instance: contribute nothing, and don't even
|
|
146
|
+
// validate the config — same as a CLI left out of MERCURY_CLIS.
|
|
147
|
+
if (!ctx.enabledClis.includes(plugin.name)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// Contract-version skew: core and plugin are versioned and installed
|
|
151
|
+
// separately, so a plugin built against a different contract than this core
|
|
152
|
+
// supports is possible. Refuse it fail-soft rather than run it against a
|
|
153
|
+
// shape it may not match.
|
|
154
|
+
if (plugin.apiVersion !== PLUGIN_API_VERSION) {
|
|
155
|
+
ctx.log(
|
|
156
|
+
`plugin "${plugin.name}" not activated: apiVersion ${plugin.apiVersion} ` +
|
|
157
|
+
`incompatible with this core (supports ${PLUGIN_API_VERSION})`,
|
|
158
|
+
);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
// Every declared dependency must have fully activated first. Because we
|
|
162
|
+
// process in dependency-first order, a dependency that was going to load
|
|
163
|
+
// already has; anything still missing is disabled, failed, unknown, or
|
|
164
|
+
// itself skipped — so this dependent degrades fail-soft too. Checked before
|
|
165
|
+
// touching this plugin's own build, so a doomed plugin does no work.
|
|
166
|
+
const missingDeps = (plugin.dependsOn ?? []).filter((dep) => !activated.has(dep));
|
|
167
|
+
if (missingDeps.length > 0) {
|
|
168
|
+
ctx.log(
|
|
169
|
+
`plugin "${plugin.name}" not activated: dependency ${missingDeps.map((d) => `"${d}"`).join(", ")} absent or failed`,
|
|
170
|
+
);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
// Stage into locals first: build() can throw, and a plugin must degrade
|
|
175
|
+
// as a unit — nothing of it lands unless all of it succeeds.
|
|
176
|
+
const contributions = plugin.build ? plugin.build({ model: ctx.model, env: ctx.env, log: ctx.log }) : {};
|
|
177
|
+
|
|
178
|
+
if (plugin.systemPromptFragment !== undefined) {
|
|
179
|
+
promptFragments.push(plugin.systemPromptFragment);
|
|
180
|
+
}
|
|
181
|
+
if (plugin.skills !== undefined) {
|
|
182
|
+
skills.push(...plugin.skills);
|
|
183
|
+
}
|
|
184
|
+
if (contributions.sessionTools) {
|
|
185
|
+
sessionToolBundles.push({
|
|
186
|
+
build: contributions.sessionTools,
|
|
187
|
+
postProcess: contributions.postProcess,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (contributions.toolStatusDescribers) {
|
|
191
|
+
Object.assign(toolStatusDescribers, contributions.toolStatusDescribers);
|
|
192
|
+
}
|
|
193
|
+
if (contributions.postTurnGuards) {
|
|
194
|
+
postTurnGuards.push(...contributions.postTurnGuards);
|
|
195
|
+
}
|
|
196
|
+
// Fully wired — only now does it count as a satisfied dependency for
|
|
197
|
+
// anything that declared `dependsOn` on it.
|
|
198
|
+
activated.add(plugin.name);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
ctx.log(`plugin "${plugin.name}" failed to load, skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { promptFragments, skills, sessionToolBundles, toolStatusDescribers, postTurnGuards, activated: [...activated] };
|
|
205
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads the hand-listed channel plugins into the providers the composition root
|
|
3
|
+
* starts — the channel-side mirror of `plugins/plugin-loader.ts`. It knows
|
|
4
|
+
* nothing about any specific channel: the composition root names them, this
|
|
5
|
+
* loop builds them identically.
|
|
6
|
+
*
|
|
7
|
+
* A channel is active when it's declared in `mercury.config.ts`'s `channels`
|
|
8
|
+
* (there is no separate env gate): declared = active. Each channel self-gates on
|
|
9
|
+
* its real config via `build()` returning `undefined` (Google Chat with no
|
|
10
|
+
* subscription; HTTP always builds).
|
|
11
|
+
*
|
|
12
|
+
* Fail-soft, same guarantees as the tool-plugin loader:
|
|
13
|
+
* - an `apiVersion` mismatch is refused before `build()` runs;
|
|
14
|
+
* - a `build()` that throws degrades as a single unit (logged, skipped) while
|
|
15
|
+
* every other channel and the process carry on;
|
|
16
|
+
* - a `build()` that returns `undefined` means "present but inert" (the
|
|
17
|
+
* instance isn't configured for it) — not started, not an error.
|
|
18
|
+
*/
|
|
19
|
+
import { CHANNEL_API_VERSION, type ChannelPlugin, type ChannelRuntimeContext, type Provider } from "@mercury-fw/channel-types";
|
|
20
|
+
|
|
21
|
+
/** Everything the loader needs from the composition root: the runtime context every `build()` gets. */
|
|
22
|
+
export type LoadChannelsContext = {
|
|
23
|
+
runtime: ChannelRuntimeContext;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** One built channel, kept with its plugin name so the composition root can start it and look it up (e.g. the cron `Notifier`). */
|
|
27
|
+
export type LoadedChannel = { name: string; provider: Provider };
|
|
28
|
+
|
|
29
|
+
/** Builds every declared channel in `channels`, returning the providers that actually constructed (skipping incompatible, inert, or failed ones). */
|
|
30
|
+
export function loadChannels(channels: ChannelPlugin[], ctx: LoadChannelsContext): LoadedChannel[] {
|
|
31
|
+
const loaded: LoadedChannel[] = [];
|
|
32
|
+
|
|
33
|
+
for (const channel of channels) {
|
|
34
|
+
if (channel.apiVersion !== CHANNEL_API_VERSION) {
|
|
35
|
+
ctx.runtime.log(
|
|
36
|
+
`channel "${channel.name}" not activated: apiVersion ${channel.apiVersion} ` +
|
|
37
|
+
`incompatible with this core (supports ${CHANNEL_API_VERSION})`,
|
|
38
|
+
);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const provider = channel.build(ctx.runtime);
|
|
43
|
+
// undefined = present but inert (this instance isn't configured for it).
|
|
44
|
+
if (provider === undefined) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
loaded.push({ name: channel.name, provider });
|
|
48
|
+
} catch (err) {
|
|
49
|
+
ctx.runtime.log(
|
|
50
|
+
`channel "${channel.name}" failed to load, skipped: ${err instanceof Error ? err.message : String(err)}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return loaded;
|
|
56
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel contract, re-exported from `@mercury-fw/channel-types`. A thin shim so
|
|
3
|
+
* the core modules importing from `router/provider.ts` don't change; the
|
|
4
|
+
* definitions moved to the shared package so channel plugins can import them
|
|
5
|
+
* without depending on the app.
|
|
6
|
+
*/
|
|
7
|
+
export type { Provider, InboundTurn, TurnSink, HandleTurn, Notifier } from "@mercury-fw/channel-types";
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal channel's `Provider` implementation — wraps
|
|
3
|
+
* `startTerminalRepl` (`src/router/terminal.ts`, unmodified) and owns
|
|
4
|
+
* everything specific to running Mercury from a terminal: the `/dump`
|
|
5
|
+
* command, bare-token confirmation interception, the dim/italic
|
|
6
|
+
* tool-status rendering, and the live context-usage prompt suffix.
|
|
7
|
+
*
|
|
8
|
+
* A single operator, one conversation at a time — there is no real
|
|
9
|
+
* per-user identity here, so `notify` just writes to stderr rather than
|
|
10
|
+
* actually reaching anyone; implemented (not left optional) so a caller
|
|
11
|
+
* that expects a `Notifier` never silently loses a message.
|
|
12
|
+
*/
|
|
13
|
+
import { startTerminalRepl } from "./terminal.ts";
|
|
14
|
+
import { tryConfirm, type ConfirmationStore } from "@mercury-fw/confirm-engine";
|
|
15
|
+
import { parseDumpCommand, defaultDumpPath, writeDump, formatContextUsage } from "./tool-log.ts";
|
|
16
|
+
import { getLoadedContextLength } from "../model/context-size.ts";
|
|
17
|
+
import { detectPendingConfirmation } from "../session/pending-confirmation.ts";
|
|
18
|
+
import { PENDING_CONFIRMATION_NOTE } from "../session/agent-turn.ts";
|
|
19
|
+
import type { Provider, HandleTurn, TurnSink } from "./provider.ts";
|
|
20
|
+
import type { StepInfo } from "../session/step-info.ts";
|
|
21
|
+
import type { writeConfirmationNote } from "../wiki/wiki-note.ts";
|
|
22
|
+
|
|
23
|
+
const TERMINAL_SESSION_KEY = "terminal";
|
|
24
|
+
|
|
25
|
+
export type TerminalProviderDeps = {
|
|
26
|
+
confirmDeps: {
|
|
27
|
+
store: ConfirmationStore;
|
|
28
|
+
vaultPath: string;
|
|
29
|
+
writeConfirmationNoteFn: typeof writeConfirmationNote;
|
|
30
|
+
now?: () => Date;
|
|
31
|
+
};
|
|
32
|
+
ollamaHost: string;
|
|
33
|
+
ollamaModel: string;
|
|
34
|
+
/** Test seam; defaults to the real `getLoadedContextLength`. */
|
|
35
|
+
getLoadedContextLengthFn?: typeof getLoadedContextLength;
|
|
36
|
+
/** Test seam; defaults to the real `startTerminalRepl`. */
|
|
37
|
+
startTerminalReplFn?: typeof startTerminalRepl;
|
|
38
|
+
/** Test seam; defaults to the real `tryConfirm`. */
|
|
39
|
+
tryConfirmFn?: typeof tryConfirm;
|
|
40
|
+
/** Test seam; defaults to `console.error`. */
|
|
41
|
+
stderrWrite?: (s: string) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Builds the terminal's `Provider`. */
|
|
45
|
+
export function createTerminalProvider(deps: TerminalProviderDeps): Provider {
|
|
46
|
+
const stderrWrite = deps.stderrWrite ?? ((s: string) => console.error(s));
|
|
47
|
+
const getLoadedContextLengthFn = deps.getLoadedContextLengthFn ?? getLoadedContextLength;
|
|
48
|
+
const tryConfirmFn = deps.tryConfirmFn ?? tryConfirm;
|
|
49
|
+
const startTerminalReplFn = deps.startTerminalReplFn ?? startTerminalRepl;
|
|
50
|
+
|
|
51
|
+
let lastSteps: StepInfo[] = [];
|
|
52
|
+
let lastInputTokens: number | undefined;
|
|
53
|
+
let contextLength: number | null = null;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
async start(handleTurn: HandleTurn): Promise<void> {
|
|
57
|
+
await startTerminalReplFn(
|
|
58
|
+
async (input, onChunk) => {
|
|
59
|
+
const dumpCommand = parseDumpCommand(input);
|
|
60
|
+
if (dumpCommand) {
|
|
61
|
+
const path = dumpCommand.path ?? defaultDumpPath();
|
|
62
|
+
await writeDump(path, lastSteps);
|
|
63
|
+
return `wrote ${lastSteps.length} tool step(s) from the last turn to ${path}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Same deterministic interception as every other channel — never
|
|
67
|
+
// let running a previously-approved mutation depend on the model.
|
|
68
|
+
const confirmReply = await tryConfirmFn(input, TERMINAL_SESSION_KEY, {
|
|
69
|
+
...deps.confirmDeps,
|
|
70
|
+
userId: TERMINAL_SESSION_KEY,
|
|
71
|
+
});
|
|
72
|
+
if (confirmReply !== null) {
|
|
73
|
+
return confirmReply;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
lastSteps = [];
|
|
77
|
+
let finalText = "";
|
|
78
|
+
// Keyed by the SDK's own reasoning-block id: a tool-calling turn
|
|
79
|
+
// can reason more than once (before a tool call, again after
|
|
80
|
+
// seeing its result), each burst getting its own header/close
|
|
81
|
+
// rather than being mistaken for a continuation of the first.
|
|
82
|
+
const reasoningIdsStarted = new Set<string>();
|
|
83
|
+
const dim = (label: string) => onChunk(`\x1b[2m\x1b[3m${label}\x1b[0m\n`);
|
|
84
|
+
const sink: TurnSink = {
|
|
85
|
+
onToolStart: dim,
|
|
86
|
+
// PENDING_CONFIRMATION_NOTE is dropped here: onStep (below)
|
|
87
|
+
// already printed the specific instruction (command + token)
|
|
88
|
+
// for the same step — the generic note would be a second,
|
|
89
|
+
// redundant line saying nothing new.
|
|
90
|
+
onTextChunk: (chunk) => {
|
|
91
|
+
if (chunk !== PENDING_CONFIRMATION_NOTE) onChunk(chunk);
|
|
92
|
+
},
|
|
93
|
+
// Only ever fires for a model that actually supports Ollama's
|
|
94
|
+
// extended thinking (see src/index.ts's OLLAMA_THINK) — a
|
|
95
|
+
// non-reasoning model means this is simply never called, so
|
|
96
|
+
// there's no header and no output at all for that turn.
|
|
97
|
+
onReasoningChunk: (chunk, id) => {
|
|
98
|
+
if (!reasoningIdsStarted.has(id)) {
|
|
99
|
+
reasoningIdsStarted.add(id);
|
|
100
|
+
dim("Sto pensando…");
|
|
101
|
+
}
|
|
102
|
+
onChunk(`\x1b[2m\x1b[3m${chunk}\x1b[0m`);
|
|
103
|
+
},
|
|
104
|
+
onReasoningEnd: (id) => {
|
|
105
|
+
if (reasoningIdsStarted.has(id)) onChunk("\n");
|
|
106
|
+
},
|
|
107
|
+
onStep: (step) => {
|
|
108
|
+
lastSteps.push(step);
|
|
109
|
+
// cli-tool.ts no longer tells the model to relay a token as
|
|
110
|
+
// text (that's channel-specific now) — the terminal has to
|
|
111
|
+
// say it itself, from the structured step data.
|
|
112
|
+
const pending = detectPendingConfirmation(step);
|
|
113
|
+
if (pending) {
|
|
114
|
+
onChunk(`Azione in sospeso: \`${pending.summary}\` — scrivi: ${pending.token}\n`);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
onUsage: (tokens) => {
|
|
118
|
+
lastInputTokens = tokens;
|
|
119
|
+
},
|
|
120
|
+
finalize: async (text) => {
|
|
121
|
+
finalText = text;
|
|
122
|
+
},
|
|
123
|
+
dispose: () => {},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
await handleTurn(
|
|
127
|
+
{
|
|
128
|
+
channel: "terminal",
|
|
129
|
+
multiUser: false,
|
|
130
|
+
text: input,
|
|
131
|
+
sessionKey: TERMINAL_SESSION_KEY,
|
|
132
|
+
wikiUserId: TERMINAL_SESSION_KEY,
|
|
133
|
+
logPrefix: "",
|
|
134
|
+
},
|
|
135
|
+
sink,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Lazy, on-demand: /api/ps only reports models actually loaded,
|
|
139
|
+
// and the model isn't loaded until its first real call.
|
|
140
|
+
if (contextLength === null) {
|
|
141
|
+
contextLength = await getLoadedContextLengthFn(deps.ollamaHost, deps.ollamaModel);
|
|
142
|
+
}
|
|
143
|
+
return finalText;
|
|
144
|
+
},
|
|
145
|
+
undefined,
|
|
146
|
+
{ promptSuffix: () => formatContextUsage(lastInputTokens, contextLength) },
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async notify(userId: string, text: string): Promise<{ sessionKey: string }> {
|
|
151
|
+
stderrWrite(`[notify] to ${userId}: ${text}`);
|
|
152
|
+
return { sessionKey: TERMINAL_SESSION_KEY };
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stdin/stdout REPL loop behind the dev console: it feeds whatever it reads
|
|
3
|
+
* into a generic `handleInput` callback and writes back whatever that callback
|
|
4
|
+
* returns.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: the dev REPL (`bun run repl`) is a debug/bootstrap console,
|
|
7
|
+
* not a production channel — a single operator, identity-less, no memory
|
|
8
|
+
* capture. This file is the *only* place that touches stdin/stdout;
|
|
9
|
+
* `handleInput` itself doesn't know it's talking to a terminal.
|
|
10
|
+
*
|
|
11
|
+
* `io.input`/`io.output` are injectable specifically so this file is
|
|
12
|
+
* unit-testable without spawning a real subprocess or touching real stdin —
|
|
13
|
+
* the real entrypoint calls it with no `io` argument and gets the real terminal.
|
|
14
|
+
*
|
|
15
|
+
* Used by: `terminal-provider.ts`, wired from the `repl.ts` entrypoint (never by
|
|
16
|
+
* the headless service). `handleInput` there is a closure over `handleTurn`;
|
|
17
|
+
* it also receives an `onChunk` callback (see `startTerminalRepl`'s doc comment)
|
|
18
|
+
* forwarded as the sink's text streaming, so a model response prints as it
|
|
19
|
+
* streams in rather than going silent for the whole answer.
|
|
20
|
+
*/
|
|
21
|
+
import * as readline from "node:readline";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Writes to the real process stdout. Appends a newline by default — the
|
|
25
|
+
* prompt is the one caller that passes `{ newline: false }`, since it
|
|
26
|
+
* must stay on the same line as whatever the user types next.
|
|
27
|
+
*/
|
|
28
|
+
function realOutputWrite(s: string, opts?: { newline?: boolean }): void {
|
|
29
|
+
process.stdout.write(opts?.newline === false ? s : s + "\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Written before the first input and again after every result, so a
|
|
34
|
+
* human at the terminal can tell an answer has finished and the next
|
|
35
|
+
* question can be typed — without this, a multi-line answer and the
|
|
36
|
+
* start of a new question were visually indistinguishable.
|
|
37
|
+
*/
|
|
38
|
+
export const PROMPT = "> ";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Runs the REPL loop: read a line, pass it to `handleInput`, write the
|
|
42
|
+
* result, repeat until the input source is exhausted (EOF).
|
|
43
|
+
*
|
|
44
|
+
* If `handleInput` rejects, the error is written as a line (so a long-
|
|
45
|
+
* running debug session can see what went wrong) and the loop continues
|
|
46
|
+
* with the next line — one bad turn shouldn't kill the whole session.
|
|
47
|
+
*
|
|
48
|
+
* @param handleInput - Turns one line of input into one line of output.
|
|
49
|
+
* Knows nothing about this being a terminal. Receives `onChunk`, which
|
|
50
|
+
* it may call zero or more times with pieces of the answer as they
|
|
51
|
+
* become available (e.g. while streaming a model response) — each
|
|
52
|
+
* call is written immediately, without waiting for `handleInput` to
|
|
53
|
+
* resolve. If `onChunk` is never called, the function's returned
|
|
54
|
+
* string is written instead once `handleInput` resolves, exactly as
|
|
55
|
+
* before streaming existed — this is what keeps the `/dump` command
|
|
56
|
+
* and any other non-streaming reply working unchanged.
|
|
57
|
+
* @param io - Test seam. Defaults to real stdin/stdout when omitted.
|
|
58
|
+
* @param opts.promptSuffix - Optional, called fresh right before every
|
|
59
|
+
* prompt (including the very first one) and written ahead of it — e.g.
|
|
60
|
+
* a live context-usage indicator (see `src/router/tool-log.ts`'s
|
|
61
|
+
* `formatContextUsage`), recomputed each time since the value changes
|
|
62
|
+
* turn to turn.
|
|
63
|
+
*/
|
|
64
|
+
export async function startTerminalRepl(
|
|
65
|
+
handleInput: (input: string, onChunk: (chunk: string) => void) => Promise<string>,
|
|
66
|
+
io?: {
|
|
67
|
+
input?: AsyncIterable<string>;
|
|
68
|
+
output?: { write(s: string, opts?: { newline?: boolean }): void };
|
|
69
|
+
},
|
|
70
|
+
opts?: { promptSuffix?: () => string },
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
// Real stdin. Passing `output` (needed for readline to own prompt
|
|
73
|
+
// rendering — see below) only when stdin is an actual TTY: passing it
|
|
74
|
+
// against a non-TTY stdin (a Docker container with nothing attached at
|
|
75
|
+
// all — distinct from a *piped* stdin, e.g. `printf ... | docker
|
|
76
|
+
// compose exec -T ...`, which still has real lines to read and works
|
|
77
|
+
// fine either way) crashed the whole process on startup — observed
|
|
78
|
+
// live: `AbortError: Stream reader cancelled via releaseLock()`,
|
|
79
|
+
// thrown from inside readline's own stream handling, not something
|
|
80
|
+
// this file can catch around. Without `output`, the same
|
|
81
|
+
// already-closed stdin just ends the loop with zero lines, same as
|
|
82
|
+
// before today's arrow-key fix — confirmed safe by every previous
|
|
83
|
+
// detached `docker compose up -d` in this project's history.
|
|
84
|
+
const isTTY = !io?.input && process.stdin.isTTY;
|
|
85
|
+
const rl = io?.input
|
|
86
|
+
? null
|
|
87
|
+
: isTTY
|
|
88
|
+
? readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
89
|
+
: readline.createInterface({ input: process.stdin });
|
|
90
|
+
// readline in TTY mode intercepts Ctrl+C and emits 'SIGINT' on the interface
|
|
91
|
+
// instead of letting it propagate as a process signal — without this handler
|
|
92
|
+
// Ctrl+C does nothing and the only way to stop Mercury is kill from outside.
|
|
93
|
+
// Not unit-tested: this path requires the real readline in TTY mode, which
|
|
94
|
+
// the io.input test seam bypasses entirely.
|
|
95
|
+
if (isTTY && rl) {
|
|
96
|
+
rl.on("SIGINT", () => process.exit(0));
|
|
97
|
+
}
|
|
98
|
+
const input = io?.input ?? rl!;
|
|
99
|
+
const output = io?.output ?? { write: realOutputWrite };
|
|
100
|
+
|
|
101
|
+
const writePrompt = () => {
|
|
102
|
+
const suffix = opts?.promptSuffix?.() ?? "";
|
|
103
|
+
if (isTTY && rl) {
|
|
104
|
+
rl.setPrompt(suffix + PROMPT);
|
|
105
|
+
rl.prompt();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (suffix) {
|
|
109
|
+
output.write(suffix, { newline: false });
|
|
110
|
+
}
|
|
111
|
+
output.write(PROMPT, { newline: false });
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
writePrompt();
|
|
115
|
+
for await (const line of input) {
|
|
116
|
+
let streamed = false;
|
|
117
|
+
let streamedText = "";
|
|
118
|
+
const onChunk = (chunk: string) => {
|
|
119
|
+
streamed = true;
|
|
120
|
+
streamedText += chunk;
|
|
121
|
+
output.write(chunk, { newline: false });
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
const result = await handleInput(line, onChunk);
|
|
125
|
+
if (!streamed) {
|
|
126
|
+
output.write(result);
|
|
127
|
+
} else if (result.startsWith(streamedText)) {
|
|
128
|
+
// The common case: nothing was streamed beyond what handleInput
|
|
129
|
+
// returns, or turn-runner.ts only appended text after generation
|
|
130
|
+
// finished (e.g. a display the model surfaced via `present`) — the
|
|
131
|
+
// already-streamed prefix is still there, so only the new suffix
|
|
132
|
+
// needs writing.
|
|
133
|
+
output.write(result.slice(streamedText.length));
|
|
134
|
+
} else {
|
|
135
|
+
// The final result no longer extends what was already streamed —
|
|
136
|
+
// e.g. turn-runner.ts's issue-list correction replaced the text
|
|
137
|
+
// with a rewrite or the fixed fallback. What's already printed
|
|
138
|
+
// can't be un-printed, so show the real final answer in full
|
|
139
|
+
// instead of a slice computed against text that isn't there
|
|
140
|
+
// anymore.
|
|
141
|
+
output.write(`\n\n[risposta corretta rispetto a quanto già mostrato sopra]\n\n${result}`);
|
|
142
|
+
}
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (streamed) {
|
|
145
|
+
output.write("");
|
|
146
|
+
}
|
|
147
|
+
output.write(`error: ${String(err instanceof Error ? err.message : err)}`);
|
|
148
|
+
}
|
|
149
|
+
writePrompt();
|
|
150
|
+
}
|
|
151
|
+
}
|