novahiz 0.7.0 → 0.7.1

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
@@ -5,6 +5,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.1] - 2026-10-08
9
+
10
+ Patch release: two installation-level repairs found while cutting 0.7.0 — the
11
+ memory MCP degraded on machines without a home catalog, and the docs reading
12
+ layer collapsed under the SQLite planner of node 22.
13
+
14
+ ### Fixed
15
+
16
+ - **`packageRoot()` walk-up** (`src/spec.ts`): the community-path assumption
17
+ (entry script exactly one directory below the package root) held for `bin/`
18
+ and `src/` but not `mcp/<server>/index.mjs` — two levels down it resolved to
19
+ `mcp/catalog/` (absent), so on any machine whose home has no catalog yet
20
+ (CI, fresh `npm install -g novahiz` before the installer ran) every memory
21
+ call returned `failed: true` « Invalid or missing catalog file ». The
22
+ resolver now walks up to the nearest `package.json`, preferring
23
+ `name: "novahiz"`, pinned by a regression test that reproduces the exact CI
24
+ condition (`tests/memory-p5.test.ts`, `NOVAHIZ_HOME` empty) — red CI on
25
+ Linux since 0.7.0, dead memory tools on the documented community path.
26
+ - **Docs search plan pinned with CROSS JOIN** (`mcp/novahiz-docs/src/store.ts`):
27
+ SQLite 3.51.3 (node:sqlite of node 22.23.x) reorders the plain JOIN — it
28
+ drives from `chunks(library)` and re-evaluates the full FTS MATCH for every
29
+ chunk of the library (50 × ~6 ms ≈ 315 ms instead of ~14 ms), collapsing
30
+ the 100 ms reading budget (742-1085 ms observed on CI runners). SQLite
31
+ never reorders a CROSS JOIN (`optoverview` 7.1.2, `lang_select` 2.2), so
32
+ the plan is now pinned on every runtime: 315.8 → 13.3 ms on 3.51.3,
33
+ unchanged 14.1 ms on 3.53.3 — same result set, only the plan changes.
34
+
8
35
  ## [0.7.0] - 2026-10-08
9
36
 
10
37
  Feature release: the Obsidian vault becomes a first-class citizen of the
package/dist/spec.js CHANGED
@@ -282,15 +282,40 @@ function readCatalog(path) {
282
282
  // gates lifecycle scripts, and the postinstall only previews anyway), so a
283
283
  // brand-new machine has no catalog in its home yet — every command used to die
284
284
  // with ENOENT right after the documented install. The package ships its own
285
- // catalog/: fall back to it before failing. The entry script (bin/novahiz.mjs,
286
- // dist/cli.js or src/cli.ts) always sits exactly one directory below the
287
- // package root, so dirname(argv[1])/.. resolves in every supported shape.
285
+ // catalog/: fall back to it before failing.
286
+ // Audit 2026-10-08: the old one-level assumption (dirname(argv[1])/..) held for
287
+ // bin/ and src/ but NOT for mcp/<server>/index.mjs — two levels down it pointed
288
+ // at mcp/catalog/ (absent), so on any machine without a home catalog (CI,
289
+ // fresh npm-global) the memory MCP degraded on every call: red CI on Linux,
290
+ // memory_write/search dead for the community path. Walk up instead: the package
291
+ // root is the nearest ancestor carrying a package.json, preferring name
292
+ // "novahiz"; a stray nested manifest is kept only as a last resort.
288
293
  // Exported: doctor resolves the shipped skills/ the same way.
289
294
  export function packageRoot() {
290
295
  const entry = process.argv[1];
291
296
  if (!entry || entry.length === 0)
292
297
  return null;
293
- return join(dirname(resolve(entry)), "..");
298
+ let dir = dirname(resolve(entry));
299
+ let loose = null;
300
+ for (let depth = 0; depth < 6; depth += 1) {
301
+ const manifest = join(dir, "package.json");
302
+ if (existsSync(manifest)) {
303
+ try {
304
+ const name = JSON.parse(readFileSync(manifest, "utf8")).name;
305
+ if (name === "novahiz")
306
+ return dir;
307
+ loose ??= dir;
308
+ }
309
+ catch {
310
+ // manifest illisible: on remonte quand meme.
311
+ }
312
+ }
313
+ const parent = dirname(dir);
314
+ if (parent === dir)
315
+ break;
316
+ dir = parent;
317
+ }
318
+ return loose;
294
319
  }
295
320
  // The home copy always wins when present, because that is what the installer
296
321
  // refreshes.
@@ -148,10 +148,19 @@ export function search(db: DatabaseSync, query: string, options: SearchOptions =
148
148
  if (match === null) return [];
149
149
  const limit = Math.min(Math.max(Math.trunc(options.limit ?? 5), 1), 50);
150
150
  const filterLibrary = options.library !== undefined && options.library.length > 0;
151
+ // CROSS JOIN et non JOIN : SQLite ne reordonne JAMAIS les operandes d'un
152
+ // CROSS JOIN (optoverview 7.1.2 / lang_select 2.2 — le mecanisme documente
153
+ // pour imposer l'ordre des boucles imbriquees). Avec un JOIN simple, le
154
+ // planner de SQLite 3.51.3 (node 22.23.x) sort chunks (index library) en
155
+ // boucle externe et re-evalue le MATCH complet pour chaque chunk : 50 x
156
+ // ~6 ms = ~315 ms au lieu de ~14 ms — le budget de lecture de 100 ms
157
+ // s'ecroule, CI rouge sur node 22. Le resultat est identique (jointure
158
+ // interne), seul le plan change. Bench du 2026-10-08 : 315.8 -> 13.3 ms
159
+ // sous 3.51.3, 14.0 -> 14.1 ms sous 3.53.3.
151
160
  const sql =
152
161
  "SELECT c.library, c.version, c.source_url, c.license, c.fetched_at, c.heading_path, c.ord, " +
153
162
  "snippet(chunks_fts, 0, '[', ']', '…', 16) AS snippet, bm25(chunks_fts) AS score " +
154
- "FROM chunks_fts JOIN chunks c ON c.id = chunks_fts.rowid " +
163
+ "FROM chunks_fts CROSS JOIN chunks c ON c.id = chunks_fts.rowid " +
155
164
  `WHERE chunks_fts MATCH ?${filterLibrary ? " AND c.library = ?" : ""} ` +
156
165
  "ORDER BY bm25(chunks_fts) LIMIT ?";
157
166
  const params: Array<string | number> = [match];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novahiz",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Enforce AI agent skills before every file edit: prompt classifier, skill catalog, and pre-edit gate.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/spec.ts CHANGED
@@ -474,14 +474,36 @@ function readCatalog<T>(path: string): T {
474
474
  // gates lifecycle scripts, and the postinstall only previews anyway), so a
475
475
  // brand-new machine has no catalog in its home yet — every command used to die
476
476
  // with ENOENT right after the documented install. The package ships its own
477
- // catalog/: fall back to it before failing. The entry script (bin/novahiz.mjs,
478
- // dist/cli.js or src/cli.ts) always sits exactly one directory below the
479
- // package root, so dirname(argv[1])/.. resolves in every supported shape.
477
+ // catalog/: fall back to it before failing.
478
+ // Audit 2026-10-08: the old one-level assumption (dirname(argv[1])/..) held for
479
+ // bin/ and src/ but NOT for mcp/<server>/index.mjs — two levels down it pointed
480
+ // at mcp/catalog/ (absent), so on any machine without a home catalog (CI,
481
+ // fresh npm-global) the memory MCP degraded on every call: red CI on Linux,
482
+ // memory_write/search dead for the community path. Walk up instead: the package
483
+ // root is the nearest ancestor carrying a package.json, preferring name
484
+ // "novahiz"; a stray nested manifest is kept only as a last resort.
480
485
  // Exported: doctor resolves the shipped skills/ the same way.
481
486
  export function packageRoot(): string | null {
482
487
  const entry = process.argv[1];
483
488
  if (!entry || entry.length === 0) return null;
484
- return join(dirname(resolve(entry)), "..");
489
+ let dir = dirname(resolve(entry));
490
+ let loose: string | null = null;
491
+ for (let depth = 0; depth < 6; depth += 1) {
492
+ const manifest = join(dir, "package.json");
493
+ if (existsSync(manifest)) {
494
+ try {
495
+ const name = (JSON.parse(readFileSync(manifest, "utf8")) as { name?: string }).name;
496
+ if (name === "novahiz") return dir;
497
+ loose ??= dir;
498
+ } catch {
499
+ // manifest illisible: on remonte quand meme.
500
+ }
501
+ }
502
+ const parent = dirname(dir);
503
+ if (parent === dir) break;
504
+ dir = parent;
505
+ }
506
+ return loose;
485
507
  }
486
508
 
487
509
  // The home copy always wins when present, because that is what the installer