@mercury-fw/core 0.31.0 → 0.32.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,32 @@
1
1
  # @mercury-fw/core
2
2
 
3
+ ## 0.32.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+ - A new app's env example has no `MERCURY_CLIS`, and its config's comment says every declared plugin and channel is active.
9
+ - A plugin caught in a dependency cycle is always reported at startup.
10
+ - The plugins' READMEs no longer ask to list them in `MERCURY_CLIS`.
11
+
12
+ ### Patch Changes
13
+
14
+ - @mercury-fw/plugin-types@0.32.0
15
+ - @mercury-fw/channel-types@0.32.0
16
+ - @mercury-fw/cli-engine@0.32.0
17
+ - @mercury-fw/confirm-engine@0.32.0
18
+
19
+ ## 0.31.1
20
+
21
+ ### Patch Changes
22
+
23
+ - 7926aa2: - Wiki grep ignores case, for the agent, the nightly review, `mfw vault grep` and the HTTP `/wiki/grep` route.
24
+ - The agent's `write_file` and the nightly review's `write_curated` take a path starting with `curated/`, as listing, reading and grepping give it, instead of writing under `curated/curated/`.
25
+ - @mercury-fw/plugin-types@0.31.1
26
+ - @mercury-fw/channel-types@0.31.1
27
+ - @mercury-fw/cli-engine@0.31.1
28
+ - @mercury-fw/confirm-engine@0.31.1
29
+
3
30
  ## 0.31.0
4
31
 
5
32
  ### 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[];
@@ -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>;
@@ -1,3 +1,6 @@
1
+ /** `path` relative to curated/: a vault-relative one (`curated/x.md`, as
2
+ * listing, reading and grepping give it) loses its leading `curated/`. */
3
+ export declare function relativeToCurated(path: string): string;
1
4
  /** Writes a curated doc at `curated/<relativePath>` (e.g. "standards/jira-fields.md"). */
2
5
  export declare function writeCuratedNote(vaultPath: string, relativePath: string, fields: {
3
6
  author?: string;
@@ -19,7 +19,8 @@ export type WikiGrepMatch = {
19
19
  line: number;
20
20
  text: string;
21
21
  };
22
- /** Searches every file under `roots` for `pattern` (a regular expression), line by line. */
22
+ /** Searches every file under `roots` for `pattern` (a regular expression,
23
+ * case-insensitive: a note's wording isn't the question's), line by line. */
23
24
  export declare function grepWikiInRoots(vaultPath: string, roots: string[], pattern: string): Promise<WikiGrepMatch[]>;
24
25
  /** Searches every file visible to `userId` for `pattern` (a regular expression), line by line. */
25
26
  export declare function grepWiki(vaultPath: string, userId: string, pattern: string): Promise<WikiGrepMatch[]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/core",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,10 +31,10 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@mercury-fw/channel-types": "0.31.0",
35
- "@mercury-fw/cli-engine": "0.31.0",
36
- "@mercury-fw/confirm-engine": "0.31.0",
37
- "@mercury-fw/plugin-types": "0.31.0",
34
+ "@mercury-fw/channel-types": "0.32.0",
35
+ "@mercury-fw/cli-engine": "0.32.0",
36
+ "@mercury-fw/confirm-engine": "0.32.0",
37
+ "@mercury-fw/plugin-types": "0.32.0",
38
38
  "@qdrant/js-client-rest": "^1.19.0",
39
39
  "ai": "^7.0.126",
40
40
  "ai-sdk-ollama": "^4.4.0",
@@ -31,7 +31,8 @@ export async function readWikiVaultFile(vaultPath: string, relativePath: string)
31
31
  }
32
32
 
33
33
  export async function grepWikiVault(vaultPath: string, pattern: string): Promise<WikiGrepMatch[]> {
34
- const regex = new RegExp(pattern);
34
+ // Case-insensitive, like every other wiki grep.
35
+ const regex = new RegExp(pattern, "i");
35
36
  const files = await listWikiVault(vaultPath);
36
37
  const matches: WikiGrepMatch[] = [];
37
38
  for (const file of files) {
package/src/compose.ts CHANGED
@@ -123,11 +123,6 @@ function requireEnv(name: string): string {
123
123
  * app) reads its own `mercury.config.ts` and passes it in. See {@link ComposedApp}.
124
124
  */
125
125
  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
126
  // The model is constructed up front, before the plugins load and the system
132
127
  // prompt is built: a plugin's post-turn guard can be model-backed (Jira's
133
128
  // issue-list corrector is), and the system prompt is assembled from the
@@ -149,13 +144,11 @@ export async function composeMercury(config: MercuryConfig): Promise<ComposedApp
149
144
  // handed in — this file no longer names them. The loader processes an opaque
150
145
  // list (see plugins/plugin-loader.ts): each supplies its allowlist as data, a
151
146
  // 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.
147
+ // post-processors and post-turn guards. Every declared plugin loads; one
148
+ // that fails (an invalid allowlist, missing configuration) degrades only itself.
155
149
  const plugins = config.plugins;
156
150
 
157
151
  const loadedPlugins = await loadPlugins(plugins, {
158
- enabledClis,
159
152
  model,
160
153
  env: process.env,
161
154
  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[];
@@ -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) {
@@ -19,7 +19,7 @@ import type { ExecutableTool } from "@mercury-fw/plugin-types";
19
19
  import { tool } from "ai";
20
20
  import { z } from "zod";
21
21
  import { listWikiFilesInRoots, readWikiFileInRoots, grepWikiInRoots, selfReviewRoots, readIndexFile } from "./wiki-read.ts";
22
- import { writeCuratedNote, writeIndexFile, deleteRawEntry, deleteCuratedEntry } from "./wiki-note.ts";
22
+ import { writeCuratedNote, writeIndexFile, deleteRawEntry, deleteCuratedEntry, relativeToCurated } from "./wiki-note.ts";
23
23
  import { normalizeIndexKey, upsertIndexEntry, removeIndexEntry } from "./index-entry.ts";
24
24
 
25
25
  export type SelfReviewToolsDeps = { vaultPath: string };
@@ -76,11 +76,12 @@ export function createSelfReviewTools(
76
76
  });
77
77
 
78
78
  const write_curated = tool({
79
- description: 'Create or overwrite a curated doc. "path" is relative to curated/, e.g. "standards/jira-fields.md".',
79
+ description:
80
+ 'Create or overwrite a curated doc. "path" is relative to curated/, e.g. "standards/jira-fields.md"; the path list_files and grep give ("curated/standards/jira-fields.md") works too.',
80
81
  inputSchema: z.object({ path: z.string().min(1), content: z.string() }),
81
82
  execute: async ({ path, content }) => {
82
83
  try {
83
- await writeCuratedNote(vaultPath, path, {}, content);
84
+ await writeCuratedNote(vaultPath, relativeToCurated(path), {}, content);
84
85
  return { ok: true as const };
85
86
  } catch (err) {
86
87
  return { ok: false as const, error: String(err) };
@@ -127,7 +127,7 @@ async function main(): Promise<void> {
127
127
  case "grep": {
128
128
  const pattern = args[0];
129
129
  if (!pattern) usage();
130
- const regex = new RegExp(pattern);
130
+ const regex = new RegExp(pattern, "i");
131
131
  const glob = new Bun.Glob("**/*.md");
132
132
  for await (const file of glob.scan({ cwd: vaultPath })) {
133
133
  const content = await Bun.file(`${vaultPath}/${file}`).text();
@@ -201,6 +201,12 @@ async function deleteVaultFile(vaultPath: string, fullPath: string, commitMessag
201
201
  });
202
202
  }
203
203
 
204
+ /** `path` relative to curated/: a vault-relative one (`curated/x.md`, as
205
+ * listing, reading and grepping give it) loses its leading `curated/`. */
206
+ export function relativeToCurated(path: string): string {
207
+ return path.replace(/^curated\//, "");
208
+ }
209
+
204
210
  /** Writes a curated doc at `curated/<relativePath>` (e.g. "standards/jira-fields.md"). */
205
211
  export async function writeCuratedNote(
206
212
  vaultPath: string,
@@ -88,9 +88,10 @@ export async function readWikiFile(vaultPath: string, userId: string, relativePa
88
88
 
89
89
  export type WikiGrepMatch = { path: string; line: number; text: string };
90
90
 
91
- /** Searches every file under `roots` for `pattern` (a regular expression), line by line. */
91
+ /** Searches every file under `roots` for `pattern` (a regular expression,
92
+ * case-insensitive: a note's wording isn't the question's), line by line. */
92
93
  export async function grepWikiInRoots(vaultPath: string, roots: string[], pattern: string): Promise<WikiGrepMatch[]> {
93
- const regex = new RegExp(pattern);
94
+ const regex = new RegExp(pattern, "i");
94
95
  const files = await listWikiFilesInRoots(vaultPath, roots);
95
96
  const matches: WikiGrepMatch[] = [];
96
97
 
@@ -15,7 +15,7 @@ import type { ExecutableTool } from "@mercury-fw/plugin-types";
15
15
  import { tool } from "ai";
16
16
  import { z } from "zod";
17
17
  import { listWikiFiles, readWikiFile, grepWiki, readWikiFileInRoots } from "./wiki-read.ts";
18
- import { writeCuratedNote } from "./wiki-note.ts";
18
+ import { writeCuratedNote, relativeToCurated } from "./wiki-note.ts";
19
19
 
20
20
  export type WikiToolsDeps = { vaultPath: string; userId: string };
21
21
 
@@ -55,12 +55,13 @@ export function createWikiTools(
55
55
  const write_file = tool({
56
56
  description:
57
57
  'Write or update a curated wiki document (team knowledge — conventions, standards, decisions). "path" ' +
58
- 'is relative to curated/, e.g. "standards/jira-fields.md". This can only write under curated/ — your ' +
58
+ 'is relative to curated/, e.g. "standards/jira-fields.md"; the path grep and read_file give ' +
59
+ '("curated/standards/jira-fields.md") works too. This can only write under curated/ — your ' +
59
60
  "own semantic notes are managed automatically by the memory consolidation process, not through this tool.",
60
61
  inputSchema: z.object({ path: z.string().min(1), content: z.string() }),
61
62
  execute: async ({ path, content }) => {
62
63
  try {
63
- await writeCuratedNote(vaultPath, path, { last_updated: new Date().toISOString().slice(0, 10) }, content);
64
+ await writeCuratedNote(vaultPath, relativeToCurated(path), { last_updated: new Date().toISOString().slice(0, 10) }, content);
64
65
  return { ok: true as const };
65
66
  } catch (err) {
66
67
  return { ok: false as const, error: String(err) };
@@ -70,7 +71,7 @@ export function createWikiTools(
70
71
 
71
72
  const grep = tool({
72
73
  description:
73
- "Search wiki documents (curated/ plus your own inferred/ notes) for a regular expression pattern. " +
74
+ "Search wiki documents (curated/ plus your own inferred/ notes) for a regular expression pattern, ignoring case. " +
74
75
  "Returns matching lines with their file path and line number.",
75
76
  inputSchema: z.object({ pattern: z.string().min(1) }),
76
77
  execute: async ({ pattern }) => {