@mercury-fw/core 0.31.1 → 0.33.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 CHANGED
@@ -1,5 +1,41 @@
1
1
  # @mercury-fw/core
2
2
 
3
+ ## 0.33.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a493d9b: - A plugin whose CLI keeps its login in a folder declares it in its `package.json` (`mercury.cliCredentials`): `{ folder }` under `~/.config`, the default, or `{ path }` anywhere else under the home. Any plugin's CLI gets the login mechanism, not only the first-party ones.
8
+ - The core unpacks each declared login from its env variable onto the credentials volume (`~/.config`) at startup, only when the folder isn't there yet, for the service and the REPL alike; a folder declared elsewhere in the home lives on the volume under `~/.config/mercury-home`, linked from its usual place. It warns about a declared folder with neither the folder nor the variable.
9
+ - `mfw credentials set|reset <plugin>` names the plugin by its package or its CLI's folder (`@mercury-fw/plugin-jira` or `jira-cli`), read from the app's installed plugins; the short name (`jira`) is no longer accepted, and reset asks for the folder's name.
10
+ - `mfw create` no longer writes `docker-entrypoint.sh` or the credentials variables in the env example, and always mounts the `cli-credentials` volume; an existing app's entrypoint keeps working alongside.
11
+ - The generated README explains how a plugin's CLI gets its login without listing plugins.
12
+ - jira, bitbucket and atlassian-admin declare their CLI's login folder; their READMEs point to `mfw credentials set`.
13
+
14
+ ### Patch Changes
15
+
16
+ - Updated dependencies [a493d9b]
17
+ - @mercury-fw/utils@0.33.0
18
+ - @mercury-fw/plugin-types@0.33.0
19
+ - @mercury-fw/channel-types@0.33.0
20
+ - @mercury-fw/cli-engine@0.33.0
21
+ - @mercury-fw/confirm-engine@0.33.0
22
+
23
+ ## 0.32.0
24
+
25
+ ### Minor Changes
26
+
27
+ - 1fa3a97: - Every tool plugin declared in `mercury.config.ts` loads: `MERCURY_CLIS` is no longer read. An app that used it to keep a declared plugin off now gets that plugin on; remove the plugin from the config instead.
28
+ - A new app's env example has no `MERCURY_CLIS`, and its config's comment says every declared plugin and channel is active.
29
+ - A plugin caught in a dependency cycle is always reported at startup.
30
+ - The plugins' READMEs no longer ask to list them in `MERCURY_CLIS`.
31
+
32
+ ### Patch Changes
33
+
34
+ - @mercury-fw/plugin-types@0.32.0
35
+ - @mercury-fw/channel-types@0.32.0
36
+ - @mercury-fw/cli-engine@0.32.0
37
+ - @mercury-fw/confirm-engine@0.32.0
38
+
3
39
  ## 0.31.1
4
40
 
5
41
  ### Patch Changes
package/README.md CHANGED
@@ -27,7 +27,6 @@ A scaffolded app's `src/index.ts` (the service) and `src/repl.ts` (the REPL) are
27
27
  | `OLLAMA_THINK` | `false` for a model that doesn't support thinking. |
28
28
  | `QDRANT_URL` | Qdrant, for the episodic memory (default `http://qdrant:6333`). |
29
29
  | `WIKI_VAULT_PATH` | Where the wiki lives. Required. |
30
- | `MERCURY_CLIS` | The tool plugins this deployment turns on, by name, comma-separated. |
31
30
 
32
31
  Qdrant being unreachable degrades memory, it doesn't stop the agent.
33
32
 
@@ -15,8 +15,8 @@ import type { Plugin } from "@mercury-fw/plugin-types";
15
15
  import type { ChannelPlugin } from "@mercury-fw/channel-types";
16
16
  import type { Persona } from "../session/system-prompt.ts";
17
17
  /** The shape of a Mercury instance's composition config: the tool plugins
18
- * (gated by MERCURY_CLIS) and the channel plugins (enabled by being declared
19
- * here — declared = active), each loaded by its own loader, plus the
18
+ * and the channel plugins (each enabled by being declared here: declared =
19
+ * active), each loaded by its own loader, plus the
20
20
  * assistant's persona (its identity and tone; the defaults when left out). */
21
21
  export type MercuryConfig = {
22
22
  plugins: Plugin[];
@@ -0,0 +1,13 @@
1
+ /** Where to read the declarations and where to unpack them. */
2
+ export interface MaterializeOptions {
3
+ /** The app's folder (its package.json and node_modules). */
4
+ appDir: string;
5
+ /** The home the CLIs run with; its `.config` is the credentials volume. */
6
+ homeDir: string;
7
+ env: Record<string, string | undefined>;
8
+ log: (msg: string) => void;
9
+ }
10
+ /** Unpacks every declared login that's missing from the volume and has its
11
+ * variable set, and links one declared outside `~/.config`; warns about one
12
+ * that has neither, and logs each dependency it couldn't read. */
13
+ export declare function materializeCliCredentials({ appDir, homeDir, env, log }: MaterializeOptions): Promise<void>;
@@ -6,9 +6,8 @@
6
6
  * nothing about any specific plugin — or about CLIs — the composition root
7
7
  * names them, this loop processes them identically.
8
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);
9
+ * Two properties it guarantees:
10
+ * - every plugin the app declares is loaded: declaring it is what enables it;
12
11
  * - a plugin that fails — its `build()` throws — degrades as a single unit:
13
12
  * none of its contributions land, the failure is
14
13
  * logged with detail, and every other plugin and the process itself carry
@@ -50,10 +49,9 @@ export interface LoadedPlugins {
50
49
  * config map to infer activation from anymore. */
51
50
  activated: string[];
52
51
  }
53
- /** Everything the loader needs from the composition root: which plugins are
54
- * enabled on this instance, and the runtime context every `build()` gets. */
52
+ /** Everything the loader needs from the composition root: the runtime
53
+ * context every `build()` gets. */
55
54
  export interface PluginLoadContext {
56
- enabledClis: string[];
57
55
  model: LanguageModel;
58
56
  env: Record<string, string | undefined>;
59
57
  log: (msg: string) => void;
@@ -79,7 +77,7 @@ export declare function orderByDependencies(plugins: Plugin[]): {
79
77
  * dependency between them. Never throws — a plugin that fails is logged and
80
78
  * skipped, its contributions staged and merged only once the whole plugin
81
79
  * succeeds so a later failure can't leave it half-wired. A plugin whose
82
- * declared `dependsOn` isn't fully activated (a dependency disabled, failed,
83
- * unknown, or itself skipped) is skipped fail-soft too, transitively.
80
+ * declared `dependsOn` isn't fully activated (a dependency failed, unknown,
81
+ * or itself skipped) is skipped fail-soft too, transitively.
84
82
  */
85
83
  export declare function loadPlugins(plugins: Plugin[], ctx: PluginLoadContext): Promise<LoadedPlugins>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/core",
3
- "version": "0.31.1",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,10 +31,11 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@mercury-fw/channel-types": "0.31.1",
35
- "@mercury-fw/cli-engine": "0.31.1",
36
- "@mercury-fw/confirm-engine": "0.31.1",
37
- "@mercury-fw/plugin-types": "0.31.1",
34
+ "@mercury-fw/channel-types": "0.33.0",
35
+ "@mercury-fw/cli-engine": "0.33.0",
36
+ "@mercury-fw/confirm-engine": "0.33.0",
37
+ "@mercury-fw/plugin-types": "0.33.0",
38
+ "@mercury-fw/utils": "0.33.0",
38
39
  "@qdrant/js-client-rest": "^1.19.0",
39
40
  "ai": "^7.0.126",
40
41
  "ai-sdk-ollama": "^4.4.0",
package/src/compose.ts CHANGED
@@ -21,6 +21,7 @@ import { createConfirmationStore, createStageConfirmation, tryConfirm, resolveCo
21
21
  import { createDisplayStore } from "./tools/display-store.ts";
22
22
  import { createPresentTool } from "./tools/present-tool.ts";
23
23
  import { loadPlugins } from "./plugins/plugin-loader.ts";
24
+ import { materializeCliCredentials } from "./credentials/materialize.ts";
24
25
  import type { MercuryConfig } from "./config/define-config.ts";
25
26
  import { createSessionHistory, type SessionHistory, type Message } from "./session/history.ts";
26
27
  import { createSummarizer } from "./session/summarizer.ts";
@@ -66,6 +67,7 @@ import { listWikiFilesInRoots, readWikiFile, readWikiFileInRoots, readIndexFile
66
67
  import { runRawTriagePass, runIndexAndOrphanPass, runContradictionCheckPass } from "./wiki/self-review-runner.ts";
67
68
  import { startSelfReviewCron } from "./cron/self-review-cron.ts";
68
69
  import { resolve as resolvePath } from "node:path";
70
+ import { homedir } from "node:os";
69
71
  import type { Tool } from "ai";
70
72
  import { startAdminServer } from "./admin/server.ts";
71
73
  // The HTTP surface's read routes (4b) reuse the admin panel's per-domain
@@ -123,11 +125,6 @@ function requireEnv(name: string): string {
123
125
  * app) reads its own `mercury.config.ts` and passes it in. See {@link ComposedApp}.
124
126
  */
125
127
  export async function composeMercury(config: MercuryConfig): Promise<ComposedApp> {
126
- const enabledClis = (process.env.MERCURY_CLIS ?? "")
127
- .split(",")
128
- .map((s) => s.trim())
129
- .filter(Boolean);
130
-
131
128
  // The model is constructed up front, before the plugins load and the system
132
129
  // prompt is built: a plugin's post-turn guard can be model-backed (Jira's
133
130
  // issue-list corrector is), and the system prompt is assembled from the
@@ -149,13 +146,20 @@ export async function composeMercury(config: MercuryConfig): Promise<ComposedApp
149
146
  // handed in — this file no longer names them. The loader processes an opaque
150
147
  // list (see plugins/plugin-loader.ts): each supplies its allowlist as data, a
151
148
  // system-prompt fragment, and a `build()` that turns the runtime context into
152
- // post-processors and post-turn guards. A plugin contributes only when it's
153
- // both listed in MERCURY_CLIS and its allowlist validates; one that fails
154
- // degrades only itself.
149
+ // post-processors and post-turn guards. Every declared plugin loads; one
150
+ // that fails (an invalid allowlist, missing configuration) degrades only itself.
155
151
  const plugins = config.plugins;
156
152
 
153
+ // A plugin's CLI that keeps its login in a folder finds it in place before
154
+ // the plugin loads: unpacked from the env file on a fresh credentials volume.
155
+ await materializeCliCredentials({
156
+ appDir: process.cwd(),
157
+ homeDir: homedir(),
158
+ env: process.env,
159
+ log: (msg) => console.error(msg),
160
+ });
161
+
157
162
  const loadedPlugins = await loadPlugins(plugins, {
158
- enabledClis,
159
163
  model,
160
164
  env: process.env,
161
165
  log: (msg) => console.error(msg),
@@ -16,8 +16,8 @@ import type { ChannelPlugin } from "@mercury-fw/channel-types";
16
16
  import type { Persona } from "../session/system-prompt.ts";
17
17
 
18
18
  /** The shape of a Mercury instance's composition config: the tool plugins
19
- * (gated by MERCURY_CLIS) and the channel plugins (enabled by being declared
20
- * here — declared = active), each loaded by its own loader, plus the
19
+ * and the channel plugins (each enabled by being declared here: declared =
20
+ * active), each loaded by its own loader, plus the
21
21
  * assistant's persona (its identity and tone; the defaults when left out). */
22
22
  export type MercuryConfig = {
23
23
  plugins: Plugin[];
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Unpacks each plugin-declared CLI credentials variable onto the config folder
3
+ * at startup. A plugin whose CLI keeps its login in a folder declares it in its
4
+ * package.json (`mercury.cliCredentials`, read by `@mercury-fw/utils`); the
5
+ * app's env file carries that folder packed into a variable (`mfw credentials
6
+ * set` writes it). In the container `~/.config` is the credentials volume, so
7
+ * this unpacks only while a CLI's folder isn't there yet: what the CLI writes
8
+ * back afterwards, like a refreshed token, stays across redeploys and an older
9
+ * variable never overwrites it. A login declared elsewhere in the home lives
10
+ * on the volume too (`volumePath`), and the home path is made a link to it at
11
+ * every start, since the rest of the home is the image's and starts over each
12
+ * time. Called by `composeMercury` before the plugins load, so the service and
13
+ * the REPL both get it. Never throws: a CLI without its login degrades only
14
+ * that plugin's calls. It works on the home it's given, so an app run outside
15
+ * its container (against the Docker-first rule) would unpack into the real
16
+ * home: only with a credentials variable set and the folder missing there.
17
+ */
18
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readlinkSync, renameSync, rmSync, symlinkSync } from "node:fs";
19
+ import { basename, dirname, join } from "node:path";
20
+ import { appCliCredentials, volumePath, type CliCredentials } from "@mercury-fw/utils";
21
+
22
+ /** Where to read the declarations and where to unpack them. */
23
+ export interface MaterializeOptions {
24
+ /** The app's folder (its package.json and node_modules). */
25
+ appDir: string;
26
+ /** The home the CLIs run with; its `.config` is the credentials volume. */
27
+ homeDir: string;
28
+ env: Record<string, string | undefined>;
29
+ log: (msg: string) => void;
30
+ }
31
+
32
+ /** Unpacks every declared login that's missing from the volume and has its
33
+ * variable set, and links one declared outside `~/.config`; warns about one
34
+ * that has neither, and logs each dependency it couldn't read. */
35
+ export async function materializeCliCredentials({ appDir, homeDir, env, log }: MaterializeOptions): Promise<void> {
36
+ let declared: CliCredentials[];
37
+ try {
38
+ const read = appCliCredentials(appDir);
39
+ for (const problem of read.problems) log(`CLI credentials: ${problem}`);
40
+ declared = read.declared;
41
+ } catch (err) {
42
+ log(`CLI credentials not unpacked: ${err instanceof Error ? err.message : String(err)}`);
43
+ return;
44
+ }
45
+ for (const c of declared) {
46
+ const target = join(homeDir, volumePath(c.path));
47
+ if (!existsSync(target)) {
48
+ const value = env[c.variable];
49
+ if (value === undefined || value === "") {
50
+ log(
51
+ `${c.package}: no login for its CLI (no ${c.name} folder, ${c.variable} not set): run mfw credentials set ${c.name}`,
52
+ );
53
+ continue;
54
+ }
55
+ try {
56
+ await unpack(value, basename(c.path), target);
57
+ } catch (err) {
58
+ log(`${c.package}: could not unpack ${c.variable} into ${c.name}: ${err instanceof Error ? err.message : String(err)}`);
59
+ continue;
60
+ }
61
+ }
62
+ const link = join(homeDir, c.path);
63
+ if (link === target) continue;
64
+ try {
65
+ linkTo(target, link, (msg) => log(`${c.package}: ${msg}`));
66
+ } catch (err) {
67
+ log(`${c.package}: could not link ${link} to the credentials volume: ${err instanceof Error ? err.message : String(err)}`);
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Makes `link` a symlink to `target` unless it already is one; anything else
73
+ * at `link` is never replaced, only reported. */
74
+ function linkTo(target: string, link: string, log: (msg: string) => void): void {
75
+ const existing = lstatSync(link, { throwIfNoEntry: false });
76
+ if (existing === undefined) {
77
+ mkdirSync(dirname(link), { recursive: true });
78
+ symlinkSync(target, link);
79
+ } else if (!existing.isSymbolicLink() || readlinkSync(link) !== target) {
80
+ log(`${link} is already there and isn't a link to the credentials volume: the CLI won't find its login`);
81
+ }
82
+ }
83
+
84
+ /** Extracts only `member` from the base64 tar.gz into a staging folder next to
85
+ * `target`, then moves it to `target`: a failed extraction leaves nothing
86
+ * behind that would count as present on the next start. */
87
+ async function unpack(value: string, member: string, target: string): Promise<void> {
88
+ mkdirSync(dirname(target), { recursive: true });
89
+ const staging = mkdtempSync(join(dirname(target), `.${member}-`));
90
+ try {
91
+ const proc = Bun.spawn(["tar", "-xzf", "-", "-C", staging, member], {
92
+ stdin: Buffer.from(value, "base64"),
93
+ stdout: "ignore",
94
+ stderr: "pipe",
95
+ });
96
+ const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
97
+ if (code !== 0 || !existsSync(join(staging, member))) {
98
+ throw new Error(stderr.trim() || `tar exited with ${code}`);
99
+ }
100
+ renameSync(join(staging, member), target);
101
+ } finally {
102
+ rmSync(staging, { recursive: true, force: true });
103
+ }
104
+ }
@@ -6,9 +6,8 @@
6
6
  * nothing about any specific plugin — or about CLIs — the composition root
7
7
  * names them, this loop processes them identically.
8
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);
9
+ * Two properties it guarantees:
10
+ * - every plugin the app declares is loaded: declaring it is what enables it;
12
11
  * - a plugin that fails — its `build()` throws — degrades as a single unit:
13
12
  * none of its contributions land, the failure is
14
13
  * logged with detail, and every other plugin and the process itself carry
@@ -54,10 +53,9 @@ export interface LoadedPlugins {
54
53
  activated: string[];
55
54
  }
56
55
 
57
- /** Everything the loader needs from the composition root: which plugins are
58
- * enabled on this instance, and the runtime context every `build()` gets. */
56
+ /** Everything the loader needs from the composition root: the runtime
57
+ * context every `build()` gets. */
59
58
  export interface PluginLoadContext {
60
- enabledClis: string[];
61
59
  model: LanguageModel;
62
60
  env: Record<string, string | undefined>;
63
61
  log: (msg: string) => void;
@@ -116,8 +114,8 @@ export function orderByDependencies(plugins: Plugin[]): { ordered: Plugin[]; cyc
116
114
  * dependency between them. Never throws — a plugin that fails is logged and
117
115
  * skipped, its contributions staged and merged only once the whole plugin
118
116
  * 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.
117
+ * declared `dependsOn` isn't fully activated (a dependency failed, unknown,
118
+ * or itself skipped) is skipped fail-soft too, transitively.
121
119
  */
122
120
  export async function loadPlugins(plugins: Plugin[], ctx: PluginLoadContext): Promise<LoadedPlugins> {
123
121
  const promptFragments: string[] = [];
@@ -127,13 +125,9 @@ export async function loadPlugins(plugins: Plugin[], ctx: PluginLoadContext): Pr
127
125
  const postTurnGuards: PostTurnGuard[] = [];
128
126
 
129
127
  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.
128
+ // A plugin caught in a cycle can't be ordered, so it can't load.
133
129
  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
- }
130
+ ctx.log(`plugin "${plugin.name}" not activated: part of or depends on a dependency cycle`);
137
131
  }
138
132
 
139
133
  // Names that fully activated — a dependency must be in here for a dependent
@@ -142,11 +136,6 @@ export async function loadPlugins(plugins: Plugin[], ctx: PluginLoadContext): Pr
142
136
  const activated = new Set<string>();
143
137
 
144
138
  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
139
  // Contract-version skew: core and plugin are versioned and installed
151
140
  // separately, so a plugin built against a different contract than this core
152
141
  // supports is possible. Refuse it fail-soft rather than run it against a
@@ -160,8 +149,8 @@ export async function loadPlugins(plugins: Plugin[], ctx: PluginLoadContext): Pr
160
149
  }
161
150
  // Every declared dependency must have fully activated first. Because we
162
151
  // 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
152
+ // already has; anything still missing failed, is unknown, or was itself
153
+ // skipped — so this dependent degrades fail-soft too. Checked before
165
154
  // touching this plugin's own build, so a doomed plugin does no work.
166
155
  const missingDeps = (plugin.dependsOn ?? []).filter((dep) => !activated.has(dep));
167
156
  if (missingDeps.length > 0) {