@mercury-fw/core 0.25.0 → 0.26.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,38 @@
1
1
  # @mercury-fw/core
2
2
 
3
+ ## 0.26.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8b92abb: - `mfw` operates an app from inside its folder: `start`, `stop` and `restart` (with `--no-cache`), `logs`, `repl`, `shell`, all as `docker compose` calls.
8
+ - `mfw vault` maintains the wiki vault (`list`, `read`, `grep`, `write-curated`, `write-raw`) in a one-off container.
9
+ - `mfw memory list` and `mfw memory read <collection>` read the memory on Qdrant, newest first where the collection has a timestamp index.
10
+ - `mfw reset memory` and `mfw reset wiki` delete a memory volume after you type the app's name, then bring the service back up empty; after a memory reset a running app is restarted, so it sets up its collections again.
11
+ - The command line is declared with commander: `--help` at every level, a suggestion for a mistyped command, and every argument checked before anything runs.
12
+ - A scaffolded app lists `@mercury-fw/cli` among its devDependencies, at the framework's version, and its README runs it through `bunx mfw`.
13
+ - A scaffolded app's `@types/bun` and `typescript` use caret ranges instead of exact versions.
14
+ - The core ships a read-only memory CLI (`src/memory/memory-cli.ts`) next to the vault one, which is what `mfw memory` runs.
15
+ - The vault CLI says so when asked to read a note that doesn't exist, instead of printing a stack trace.
16
+
17
+ ### Patch Changes
18
+
19
+ - @mercury-fw/plugin-types@0.26.0
20
+ - @mercury-fw/channel-types@0.26.0
21
+ - @mercury-fw/cli-engine@0.26.0
22
+ - @mercury-fw/confirm-engine@0.26.0
23
+
24
+ ## 0.25.1
25
+
26
+ ### Patch Changes
27
+
28
+ - 5613e5e: - Mercury starts even when Qdrant isn't answering yet: the memory collections are set up in the background and retried until Qdrant is reachable, instead of crashing the process at startup.
29
+ - A new session's context primer goes on without the last-session recap when Qdrant doesn't answer, instead of failing the turn.
30
+ - The scaffolded app's `docker-compose.yml` restarts the app service unless it was stopped.
31
+ - @mercury-fw/plugin-types@0.25.1
32
+ - @mercury-fw/channel-types@0.25.1
33
+ - @mercury-fw/cli-engine@0.25.1
34
+ - @mercury-fw/confirm-engine@0.25.1
35
+
3
36
  ## 0.25.0
4
37
 
5
38
  ### Minor Changes
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Layer 3's startup setup (creating the Qdrant collections and their indexes),
3
+ * run in the background so an unreachable Qdrant never stops Mercury from
4
+ * starting: the episodic store is enrichment and fails soft (principle 3).
5
+ */
6
+ /**
7
+ * Runs `setup` now and, while it fails, again every `retryMs`, on a timer that
8
+ * doesn't keep the process alive. Logs once when Qdrant goes missing and once
9
+ * when it's back, never per retry. `done` resolves after the first success.
10
+ */
11
+ export declare function setUpWhenReachable(setup: () => Promise<void>, { log, retryMs }: {
12
+ log: (msg: string) => void;
13
+ retryMs?: number;
14
+ }): {
15
+ done: Promise<void>;
16
+ };
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bun
2
+ type ScrollOffset = string | number | Record<string, unknown> | null;
3
+ /** The slice of the Qdrant client this CLI uses. */
4
+ export type MemoryCliClient = {
5
+ getCollections(): Promise<{
6
+ collections: Array<{
7
+ name: string;
8
+ }>;
9
+ }>;
10
+ count(collection: string, params: {
11
+ exact: boolean;
12
+ }): Promise<{
13
+ count: number;
14
+ }>;
15
+ scroll(collection: string, params: {
16
+ limit: number;
17
+ offset?: ScrollOffset;
18
+ with_payload: boolean;
19
+ order_by?: {
20
+ key: string;
21
+ direction: "asc" | "desc";
22
+ };
23
+ }): Promise<{
24
+ points: Array<{
25
+ id: string | number;
26
+ payload?: Record<string, unknown> | null;
27
+ }>;
28
+ next_page_offset?: ScrollOffset;
29
+ }>;
30
+ };
31
+ type Io = {
32
+ client: MemoryCliClient;
33
+ out: (line: string) => void;
34
+ err: (line: string) => void;
35
+ };
36
+ /** Runs the CLI on `argv` against `io.client`; returns the exit code. */
37
+ export declare function runMemoryCli(argv: string[], { client, out, err }: Io): Promise<number>;
38
+ export {};
@@ -7,6 +7,8 @@ export type ContextPrimerDeps = {
7
7
  listWikiFilesInRootsFn: typeof listWikiFilesInRoots;
8
8
  readWikiFileInRootsFn: typeof readWikiFileInRoots;
9
9
  readIndexFileFn: typeof readIndexFile;
10
+ /** Where a skipped section says why (the recap, when Qdrant doesn't answer). */
11
+ log: (msg: string) => void;
10
12
  };
11
13
  /**
12
14
  * Text of the primer for `userId`, built from injected deps only — never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/core",
3
- "version": "0.25.0",
3
+ "version": "0.26.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.25.0",
35
- "@mercury-fw/cli-engine": "0.25.0",
36
- "@mercury-fw/confirm-engine": "0.25.0",
37
- "@mercury-fw/plugin-types": "0.25.0",
34
+ "@mercury-fw/channel-types": "0.26.0",
35
+ "@mercury-fw/cli-engine": "0.26.0",
36
+ "@mercury-fw/confirm-engine": "0.26.0",
37
+ "@mercury-fw/plugin-types": "0.26.0",
38
38
  "@qdrant/js-client-rest": "^1.19.0",
39
39
  "ai": "^7.0.77",
40
40
  "ai-sdk-ollama": "^4.2.0",
package/src/compose.ts CHANGED
@@ -56,6 +56,7 @@ import { ensureVerbatimCollection, listVerbatimBySession, listVerbatimSessions }
56
56
  import { createVerbatimArchiveProvider } from "./memory/memory-provider.ts";
57
57
  import { ensureSemanticFactsCollection, storeSemanticFact, searchSemanticFactsByTopic } from "./memory/semantic-facts-store.ts";
58
58
  import { ensureToolCorrectionsCollection, storeToolCorrection, searchToolCorrectionsByTopic } from "./memory/tool-corrections-store.ts";
59
+ import { setUpWhenReachable } from "./memory/collection-setup.ts";
59
60
  import { consolidateSemanticFact, consolidateToolCorrection, type ToolCorrectionConsolidationDeps } from "./cron/semantic-consolidation.ts";
60
61
  import { createToolCorrectionExtractor } from "./session/tool-correction-extractor.ts";
61
62
  import { createEmbedder } from "./memory/embedder.ts";
@@ -227,14 +228,12 @@ export async function composeMercury(config: MercuryConfig): Promise<ComposedApp
227
228
  const qdrant = new QdrantClient({ url: process.env.QDRANT_URL ?? "http://qdrant:6333" });
228
229
  const episodicCollection = process.env.QDRANT_EPISODIC_COLLECTION ?? "episodic_memory";
229
230
  const episodicVectorSize = Number(process.env.QDRANT_EPISODIC_VECTOR_SIZE ?? "768");
230
- await ensureEpisodicCollection(qdrant, episodicCollection, episodicVectorSize);
231
231
 
232
232
  // Verbatim conversation archive (#4): a distinct collection holding the raw
233
233
  // user<->model exchange, lossless and durable — separate from the lossy
234
234
  // Layer-1 window and the derived episodic summaries above.
235
235
  const verbatimCollection = process.env.QDRANT_VERBATIM_COLLECTION ?? "verbatim_archive";
236
236
  const verbatimVectorSize = Number(process.env.QDRANT_VERBATIM_VECTOR_SIZE ?? "768");
237
- await ensureVerbatimCollection(qdrant, verbatimCollection, verbatimVectorSize);
238
237
  const verbatimProvider = createVerbatimArchiveProvider({ client: qdrant, collectionName: verbatimCollection, embed });
239
238
 
240
239
  // Semantic consolidation (D-22/D-34): a separate Qdrant collection from
@@ -242,7 +241,6 @@ export async function composeMercury(config: MercuryConfig): Promise<ComposedApp
242
241
  // vector embedded on the topic alone (see semantic-facts-store.ts for why).
243
242
  const semanticFactsCollection = process.env.QDRANT_SEMANTIC_FACTS_COLLECTION ?? "semantic_facts";
244
243
  const semanticFactsVectorSize = Number(process.env.QDRANT_SEMANTIC_FACTS_VECTOR_SIZE ?? "768");
245
- await ensureSemanticFactsCollection(qdrant, semanticFactsCollection, semanticFactsVectorSize);
246
244
  const extractFacts = createSemanticFactExtractor(model);
247
245
 
248
246
  // Idempotent self-heal: the vault lives on a named Docker volume, empty on
@@ -322,7 +320,17 @@ export async function composeMercury(config: MercuryConfig): Promise<ComposedApp
322
320
  // the tool-call trace only exists in memory for the duration of its turn.
323
321
  const toolCorrectionsCollection = process.env.QDRANT_TOOL_CORRECTIONS_COLLECTION ?? "tool_corrections";
324
322
  const toolCorrectionsVectorSize = Number(process.env.QDRANT_TOOL_CORRECTIONS_VECTOR_SIZE ?? "768");
325
- await ensureToolCorrectionsCollection(qdrant, toolCorrectionsCollection, toolCorrectionsVectorSize);
323
+ // Every Layer-3 collection, set up in the background: Mercury starts even
324
+ // while Qdrant isn't answering yet, and memory switches on once it does.
325
+ setUpWhenReachable(
326
+ async () => {
327
+ await ensureEpisodicCollection(qdrant, episodicCollection, episodicVectorSize);
328
+ await ensureVerbatimCollection(qdrant, verbatimCollection, verbatimVectorSize);
329
+ await ensureSemanticFactsCollection(qdrant, semanticFactsCollection, semanticFactsVectorSize);
330
+ await ensureToolCorrectionsCollection(qdrant, toolCorrectionsCollection, toolCorrectionsVectorSize);
331
+ },
332
+ { log: (msg) => console.error(`[memory] ${msg}`) },
333
+ );
326
334
  const extractToolCorrections = createToolCorrectionExtractor(model, undefined, {
327
335
  log: (msg) => console.error(`[cron] ${msg}`),
328
336
  });
@@ -464,6 +472,7 @@ export async function composeMercury(config: MercuryConfig): Promise<ComposedApp
464
472
  listWikiFilesInRootsFn: listWikiFilesInRoots,
465
473
  readWikiFileInRootsFn: readWikiFileInRoots,
466
474
  readIndexFileFn: readIndexFile,
475
+ log: (msg) => console.error(`[memory] ${msg}`),
467
476
  });
468
477
  return getOrCreateHistory(key, trackForCapture, primer);
469
478
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Layer 3's startup setup (creating the Qdrant collections and their indexes),
3
+ * run in the background so an unreachable Qdrant never stops Mercury from
4
+ * starting: the episodic store is enrichment and fails soft (principle 3).
5
+ */
6
+
7
+ /**
8
+ * Runs `setup` now and, while it fails, again every `retryMs`, on a timer that
9
+ * doesn't keep the process alive. Logs once when Qdrant goes missing and once
10
+ * when it's back, never per retry. `done` resolves after the first success.
11
+ */
12
+ export function setUpWhenReachable(
13
+ setup: () => Promise<void>,
14
+ { log, retryMs = 5000 }: { log: (msg: string) => void; retryMs?: number },
15
+ ): { done: Promise<void> } {
16
+ let failing = false;
17
+ const done = new Promise<void>((resolve) => {
18
+ const attempt = async () => {
19
+ try {
20
+ await setup();
21
+ if (failing) log("Qdrant reachable, memory collections ready");
22
+ resolve();
23
+ } catch (err) {
24
+ if (!failing) {
25
+ log(`Qdrant unreachable, Layer-3 memory is off until it answers (retrying every ${retryMs / 1000}s): ${String(err)}`);
26
+ }
27
+ failing = true;
28
+ setTimeout(attempt, retryMs).unref();
29
+ }
30
+ };
31
+ void attempt();
32
+ });
33
+ return { done };
34
+ }
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Read-only CLI for Layer-3 memory on Qdrant — runs INSIDE the Mercury
4
+ * container (where `QDRANT_URL` reaches Qdrant): `mfw memory` runs it in a
5
+ * one-off container, by this path.
6
+ * `list` shows the collections and their sizes, `read` a collection's points:
7
+ * newest first where the collection has a `timestamp` payload index (episodic
8
+ * memory, the verbatim archive), in Qdrant's own order otherwise, and it says
9
+ * which. Nothing here writes.
10
+ */
11
+ import { parseArgs } from "node:util";
12
+ import { QdrantClient } from "@qdrant/js-client-rest";
13
+ import { scrollCollection } from "../admin/qdrant-scroll.ts";
14
+
15
+ type ScrollOffset = string | number | Record<string, unknown> | null;
16
+
17
+ /** The slice of the Qdrant client this CLI uses. */
18
+ export type MemoryCliClient = {
19
+ getCollections(): Promise<{ collections: Array<{ name: string }> }>;
20
+ count(collection: string, params: { exact: boolean }): Promise<{ count: number }>;
21
+ scroll(
22
+ collection: string,
23
+ params: {
24
+ limit: number;
25
+ offset?: ScrollOffset;
26
+ with_payload: boolean;
27
+ order_by?: { key: string; direction: "asc" | "desc" };
28
+ },
29
+ ): Promise<{
30
+ points: Array<{ id: string | number; payload?: Record<string, unknown> | null }>;
31
+ next_page_offset?: ScrollOffset;
32
+ }>;
33
+ };
34
+
35
+ type Io = { client: MemoryCliClient; out: (line: string) => void; err: (line: string) => void };
36
+
37
+ const USAGE = [
38
+ "Usage: mfw memory <command> [args]",
39
+ "",
40
+ "Commands:",
41
+ " list the collections and their points",
42
+ " read <collection> [--limit N] a collection's points, newest first where it can (default 20)",
43
+ ];
44
+
45
+ /** A point as the lines `read` prints: its id, then one indented line per payload field. */
46
+ function pointLines(point: { id: string | number; payload?: Record<string, unknown> | null }): string[] {
47
+ const fields = Object.entries(point.payload ?? {}).map(
48
+ ([key, value]) => ` ${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`,
49
+ );
50
+ return [String(point.id), ...fields, ""];
51
+ }
52
+
53
+ /** Whether `err` is Qdrant refusing to order by a field it has no index on
54
+ * (HTTP 400, "No range index for `order_by` key"). */
55
+ function isMissingOrderIndex(err: unknown): boolean {
56
+ const e = err as { status?: number; message?: string; data?: unknown };
57
+ const text = `${e?.message ?? ""} ${JSON.stringify(e?.data ?? "")}`;
58
+ return (e?.status === 400 || /\b400\b/.test(text)) && /index/i.test(text);
59
+ }
60
+
61
+ /** Runs the CLI on `argv` against `io.client`; returns the exit code. */
62
+ export async function runMemoryCli(argv: string[], { client, out, err }: Io): Promise<number> {
63
+ const usage = () => {
64
+ USAGE.forEach((line) => err(line));
65
+ return 1;
66
+ };
67
+ let parsed;
68
+ try {
69
+ parsed = parseArgs({ args: argv, options: { limit: { type: "string" } }, allowPositionals: true });
70
+ } catch {
71
+ return usage();
72
+ }
73
+ const [command, collection, ...extra] = parsed.positionals;
74
+ if (extra.length > 0 || (command === "list" && (collection !== undefined || parsed.values.limit !== undefined))) {
75
+ return usage();
76
+ }
77
+ if (command !== "list" && !(command === "read" && collection !== undefined)) return usage();
78
+
79
+ const limitText = parsed.values.limit ?? "20";
80
+ const limit = Number(limitText);
81
+ if (!/^\d+$/.test(limitText) || limit < 1) {
82
+ err(`--limit takes a positive whole number (got "${limitText}").`);
83
+ return 1;
84
+ }
85
+
86
+ let names: string[];
87
+ try {
88
+ names = (await client.getCollections()).collections.map((c) => c.name).sort();
89
+ } catch (e) {
90
+ err(`Can't reach Qdrant: ${String(e)}`);
91
+ return 1;
92
+ }
93
+
94
+ try {
95
+ if (command === "list") {
96
+ if (names.length === 0) {
97
+ out("No collections yet.");
98
+ return 0;
99
+ }
100
+ for (const name of names) {
101
+ out(`${name} ${(await client.count(name, { exact: true })).count} points`);
102
+ }
103
+ return 0;
104
+ }
105
+
106
+ if (!names.includes(collection as string)) {
107
+ err(`No collection "${collection}". There are: ${names.join(", ") || "none"}.`);
108
+ return 1;
109
+ }
110
+ let points: Array<{ id: string | number; payload?: Record<string, unknown> | null }>;
111
+ try {
112
+ // Qdrant orders by a payload field only when it has an index on it.
113
+ ({ points } = await client.scroll(collection as string, {
114
+ limit,
115
+ with_payload: true,
116
+ order_by: { key: "timestamp", direction: "desc" },
117
+ }));
118
+ } catch (e) {
119
+ if (!isMissingOrderIndex(e)) throw e;
120
+ err(`${collection} has no timestamp index: points in Qdrant's own order.`);
121
+ ({ points } = await scrollCollection(client, collection as string, { limit }));
122
+ }
123
+ points.flatMap(pointLines).forEach((line) => out(line));
124
+ return 0;
125
+ } catch (e) {
126
+ err(`Qdrant error: ${String(e)}`);
127
+ return 1;
128
+ }
129
+ }
130
+
131
+ if (import.meta.main) {
132
+ const client = new QdrantClient({ url: process.env.QDRANT_URL ?? "http://qdrant:6333" });
133
+ process.exit(
134
+ await runMemoryCli(process.argv.slice(2), {
135
+ client,
136
+ out: (line) => console.log(line),
137
+ err: (line) => console.error(line),
138
+ }),
139
+ );
140
+ }
@@ -21,6 +21,8 @@ export type ContextPrimerDeps = {
21
21
  listWikiFilesInRootsFn: typeof listWikiFilesInRoots;
22
22
  readWikiFileInRootsFn: typeof readWikiFileInRoots;
23
23
  readIndexFileFn: typeof readIndexFile;
24
+ /** Where a skipped section says why (the recap, when Qdrant doesn't answer). */
25
+ log: (msg: string) => void;
24
26
  };
25
27
 
26
28
  const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n/;
@@ -78,7 +80,12 @@ async function pendingConfirmationTokens(userId: string, deps: ContextPrimerDeps
78
80
  * `index.ts` supplies the real Qdrant-backed episodic query and wiki reads.
79
81
  */
80
82
  export async function buildContextPrimer(userId: string, deps: ContextPrimerDeps): Promise<string> {
81
- const entries = await deps.getLastSessionEntries(userId);
83
+ // Episodic memory is enrichment: without Qdrant the primer goes on without
84
+ // the recap instead of failing the turn.
85
+ const entries = await deps.getLastSessionEntries(userId).catch((err: unknown) => {
86
+ deps.log(`last session for ${userId} unavailable, primer built without it: ${String(err)}`);
87
+ return [];
88
+ });
82
89
  // Checked regardless of `entries` — a pending confirmation isn't tied to
83
90
  // "was there a prior episodic session", it's simply still open right now.
84
91
  const pendingTokens = await pendingConfirmationTokens(userId, deps);
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
3
  * Maintenance CLI for the wiki vault — runs INSIDE the Mercury container
4
- * (the vault is a Docker named volume, not a host path, see `scripts/vault.sh`
5
- * and CLAUDE.md § "Manutenzione della vault wiki"). Thin argv wrapper around
4
+ * (the vault is a Docker named volume, not a host path): `mfw vault` runs it
5
+ * in a one-off container, by this path. Thin argv wrapper around
6
6
  * functions that already exist and are already tested (`wiki-note.ts`,
7
7
  * `vault-init.ts`) — no new write/read logic here, only routing.
8
8
  *
@@ -29,7 +29,7 @@ import { writeCuratedNote, writeRawEntry } from "./wiki-note.ts";
29
29
  function usage(): never {
30
30
  console.error(
31
31
  [
32
- "Usage: vault-cli <command> [args]",
32
+ "Usage: mfw vault <command> [args]",
33
33
  "",
34
34
  "Commands:",
35
35
  " write-curated <curated/...path.md> [--author NAME] body read from stdin",
@@ -107,7 +107,12 @@ async function main(): Promise<void> {
107
107
  case "read": {
108
108
  const relativePath = args[0];
109
109
  if (!relativePath) usage();
110
- console.log(await Bun.file(`${vaultPath}/${relativePath}`).text());
110
+ const note = Bun.file(`${vaultPath}/${relativePath}`);
111
+ if (!(await note.exists())) {
112
+ console.error(`no note at ${relativePath} (mfw vault list shows them)`);
113
+ process.exit(1);
114
+ }
115
+ console.log(await note.text());
111
116
  break;
112
117
  }
113
118