@remnic/core 9.3.687 → 9.3.689

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.
Files changed (75) hide show
  1. package/dist/access-boundary.js +8 -8
  2. package/dist/access-cli.js +16 -16
  3. package/dist/access-http.js +11 -11
  4. package/dist/access-mcp.js +10 -10
  5. package/dist/access-operations.js +9 -9
  6. package/dist/access-service.js +7 -7
  7. package/dist/active-recall.js +3 -3
  8. package/dist/{chunk-ZPTISBQU.js → chunk-2SNKUSQC.js} +4 -4
  9. package/dist/{chunk-MCQDSY4G.js → chunk-3K37VPM3.js} +2 -2
  10. package/dist/{chunk-6GJS4BFH.js → chunk-A7UZR6RA.js} +2 -2
  11. package/dist/{chunk-U7D7NP4J.js → chunk-ED35D32I.js} +2 -2
  12. package/dist/{chunk-2KAYTPPT.js → chunk-JPETDXED.js} +5 -5
  13. package/dist/{chunk-B43NZNMG.js → chunk-JZBFL7RI.js} +7 -7
  14. package/dist/chunk-K6MV3TRQ.js +56 -0
  15. package/dist/chunk-K6MV3TRQ.js.map +1 -0
  16. package/dist/{chunk-QO3AILZN.js → chunk-OO42R444.js} +2 -2
  17. package/dist/{chunk-NN7QYW5W.js → chunk-OWFY6NGQ.js} +2 -2
  18. package/dist/{chunk-S2OU5DZY.js → chunk-QSQW54U5.js} +4 -4
  19. package/dist/{chunk-QVMXQGT7.js → chunk-RHXFYIHA.js} +23 -23
  20. package/dist/{chunk-CTOQEZSN.js → chunk-SAEZIIID.js} +2 -2
  21. package/dist/{chunk-DCWIQFNA.js → chunk-T422SYM6.js} +5 -5
  22. package/dist/{chunk-FE6DQUNJ.js → chunk-UHUZXWDX.js} +3 -3
  23. package/dist/{chunk-473JIN2U.js → chunk-UXLZOVCN.js} +3 -3
  24. package/dist/{chunk-2SJCWLQD.js → chunk-W63OY3J7.js} +2 -2
  25. package/dist/{chunk-FUCUR2OZ.js → chunk-XU7363OX.js} +2 -2
  26. package/dist/{chunk-KFBOZYME.js → chunk-Z2M6YTAJ.js} +3 -3
  27. package/dist/cli.js +24 -24
  28. package/dist/config.js +3 -3
  29. package/dist/connectors/index.d.ts +10 -2
  30. package/dist/connectors/index.js +2 -2
  31. package/dist/conversation-index/backend.js +2 -2
  32. package/dist/emit-legacy-tools.js +2 -2
  33. package/dist/index.d.ts +11 -0
  34. package/dist/index.js +36 -33
  35. package/dist/index.js.map +1 -1
  36. package/dist/lcm/engine.js +2 -2
  37. package/dist/lcm/index.js +4 -4
  38. package/dist/namespaces/migrate.js +7 -7
  39. package/dist/namespaces/search.js +6 -6
  40. package/dist/operator-toolkit.js +11 -11
  41. package/dist/orchestrator.js +10 -10
  42. package/dist/recall-pipeline-stages.d.ts +167 -0
  43. package/dist/recall-pipeline-stages.js +61 -0
  44. package/dist/recall-pipeline-stages.js.map +1 -0
  45. package/dist/resume-bundles.js +4 -4
  46. package/dist/search/factory.js +5 -5
  47. package/dist/search/index.js +9 -9
  48. package/package.json +2 -2
  49. package/src/connectors/paths.test.ts +192 -0
  50. package/src/connectors/paths.ts +106 -20
  51. package/src/memory-extension/claude-code-publisher.ts +1 -0
  52. package/src/memory-extension/codex-publisher.ts +1 -0
  53. package/src/memory-extension/hermes-publisher.ts +1 -0
  54. package/src/memory-extension/types.ts +11 -0
  55. package/src/recall-pipeline-stages.test.ts +269 -0
  56. package/src/recall-pipeline-stages.ts +294 -0
  57. package/dist/chunk-3BQOQYRB.js +0 -33
  58. package/dist/chunk-3BQOQYRB.js.map +0 -1
  59. /package/dist/{chunk-ZPTISBQU.js.map → chunk-2SNKUSQC.js.map} +0 -0
  60. /package/dist/{chunk-MCQDSY4G.js.map → chunk-3K37VPM3.js.map} +0 -0
  61. /package/dist/{chunk-6GJS4BFH.js.map → chunk-A7UZR6RA.js.map} +0 -0
  62. /package/dist/{chunk-U7D7NP4J.js.map → chunk-ED35D32I.js.map} +0 -0
  63. /package/dist/{chunk-2KAYTPPT.js.map → chunk-JPETDXED.js.map} +0 -0
  64. /package/dist/{chunk-B43NZNMG.js.map → chunk-JZBFL7RI.js.map} +0 -0
  65. /package/dist/{chunk-QO3AILZN.js.map → chunk-OO42R444.js.map} +0 -0
  66. /package/dist/{chunk-NN7QYW5W.js.map → chunk-OWFY6NGQ.js.map} +0 -0
  67. /package/dist/{chunk-S2OU5DZY.js.map → chunk-QSQW54U5.js.map} +0 -0
  68. /package/dist/{chunk-QVMXQGT7.js.map → chunk-RHXFYIHA.js.map} +0 -0
  69. /package/dist/{chunk-CTOQEZSN.js.map → chunk-SAEZIIID.js.map} +0 -0
  70. /package/dist/{chunk-DCWIQFNA.js.map → chunk-T422SYM6.js.map} +0 -0
  71. /package/dist/{chunk-FE6DQUNJ.js.map → chunk-UHUZXWDX.js.map} +0 -0
  72. /package/dist/{chunk-473JIN2U.js.map → chunk-UXLZOVCN.js.map} +0 -0
  73. /package/dist/{chunk-2SJCWLQD.js.map → chunk-W63OY3J7.js.map} +0 -0
  74. /package/dist/{chunk-FUCUR2OZ.js.map → chunk-XU7363OX.js.map} +0 -0
  75. /package/dist/{chunk-KFBOZYME.js.map → chunk-Z2M6YTAJ.js.map} +0 -0
@@ -0,0 +1,192 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import {
8
+ getConnectorsConfigRoot,
9
+ getLegacyConnectorsConfigRoot,
10
+ getActiveConnectorsConfigRoot,
11
+ getRegistryPath,
12
+ getConnectorsDir,
13
+ hasLegacyConnectorEntries,
14
+ REGISTRY_DIR_NAME,
15
+ LEGACY_REGISTRY_DIR_NAME,
16
+ } from "./paths.js";
17
+
18
+ /**
19
+ * Issue #1518 — the connector registry path moved from
20
+ * `~/.config/engram/.engram-connectors/` to `~/.config/remnic/.remnic-connectors/`,
21
+ * with a read-fallback so existing engram-era installs keep resolving until
22
+ * the user migrates. These tests lock in that contract.
23
+ */
24
+
25
+ function withXdgHome<T>(body: (xdg: string) => T): T {
26
+ const xdg = fs.mkdtempSync(path.join(os.tmpdir(), "remnic-paths-test-"));
27
+ const prev = process.env.XDG_CONFIG_HOME;
28
+ process.env.XDG_CONFIG_HOME = xdg;
29
+ try {
30
+ return body(xdg);
31
+ } finally {
32
+ if (prev === undefined) delete process.env.XDG_CONFIG_HOME;
33
+ else process.env.XDG_CONFIG_HOME = prev;
34
+ fs.rmSync(xdg, { recursive: true, force: true });
35
+ }
36
+ }
37
+
38
+ test("getConnectorsConfigRoot: canonical root is remnic/, not engram/ (#1518)", () => {
39
+ withXdgHome((xdg) => {
40
+ const root = getConnectorsConfigRoot();
41
+ assert.equal(root, path.join(xdg, "remnic"));
42
+ assert.notEqual(root, path.join(xdg, "engram"));
43
+ });
44
+ });
45
+
46
+ test("getLegacyConnectorsConfigRoot: legacy root is still engram/ (read-fallback)", () => {
47
+ withXdgHome((xdg) => {
48
+ assert.equal(getLegacyConnectorsConfigRoot(), path.join(xdg, "engram"));
49
+ });
50
+ });
51
+
52
+ test("REGISTRY_DIR_NAME: canonical subdir is .remnic-connectors", () => {
53
+ assert.equal(REGISTRY_DIR_NAME, ".remnic-connectors");
54
+ assert.equal(LEGACY_REGISTRY_DIR_NAME, ".engram-connectors");
55
+ });
56
+
57
+ test("getConnectorsDir: fresh install writes to remnic/.remnic-connectors/connectors", () => {
58
+ withXdgHome((xdg) => {
59
+ // Neither remnic nor engram exists → canonical remnic wins.
60
+ const dir = getConnectorsDir();
61
+ assert.equal(
62
+ dir,
63
+ path.join(xdg, "remnic", ".remnic-connectors", "connectors"),
64
+ "fresh install must resolve to the canonical remnic path",
65
+ );
66
+ });
67
+ });
68
+
69
+ test("getRegistryPath: fresh install writes registry.json under remnic/.remnic-connectors", () => {
70
+ withXdgHome((xdg) => {
71
+ const reg = getRegistryPath();
72
+ assert.equal(
73
+ reg,
74
+ path.join(xdg, "remnic", ".remnic-connectors", "registry.json"),
75
+ );
76
+ });
77
+ });
78
+
79
+ test("read-fallback: when ONLY engram/ exists, active root resolves to engram (#1518 back-compat)", () => {
80
+ withXdgHome((xdg) => {
81
+ // Seed the legacy engram tree — no remnic dir.
82
+ fs.mkdirSync(path.join(xdg, "engram", ".engram-connectors", "connectors"), {
83
+ recursive: true,
84
+ });
85
+ assert.equal(getActiveConnectorsConfigRoot(), path.join(xdg, "engram"));
86
+ assert.equal(
87
+ getConnectorsDir(),
88
+ path.join(xdg, "engram", ".engram-connectors", "connectors"),
89
+ );
90
+ assert.equal(
91
+ getRegistryPath(),
92
+ path.join(xdg, "engram", ".engram-connectors", "registry.json"),
93
+ );
94
+ });
95
+ });
96
+
97
+ test("read-fallback: once remnic/ exists, it wins over engram (post-migration)", () => {
98
+ withXdgHome((xdg) => {
99
+ // Seed BOTH trees — the user has started migrating.
100
+ fs.mkdirSync(path.join(xdg, "engram", ".engram-connectors", "connectors"), {
101
+ recursive: true,
102
+ });
103
+ fs.mkdirSync(path.join(xdg, "remnic", ".remnic-connectors", "connectors"), {
104
+ recursive: true,
105
+ });
106
+ assert.equal(getActiveConnectorsConfigRoot(), path.join(xdg, "remnic"));
107
+ assert.equal(
108
+ getConnectorsDir(),
109
+ path.join(xdg, "remnic", ".remnic-connectors", "connectors"),
110
+ );
111
+ });
112
+ });
113
+
114
+ test("read-fallback: a bare config-root dir without its registry subdir does NOT trigger fallback (#1620 round 1)", () => {
115
+ withXdgHome((xdg) => {
116
+ // A stray engram/ dir (no .engram-connectors inside) is NOT evidence of a
117
+ // connector install. The probe targets the registry subdir, so this falls
118
+ // through to the canonical remnic root.
119
+ fs.mkdirSync(path.join(xdg, "engram"), { recursive: true });
120
+ assert.equal(
121
+ getActiveConnectorsConfigRoot(),
122
+ path.join(xdg, "remnic"),
123
+ "bare engram/ without .engram-connectors must not trigger the legacy fallback",
124
+ );
125
+ });
126
+ });
127
+
128
+ test("read-fallback: stray remnic/config.json must not hide legacy engram connector data (#1620 round 1 regression)", () => {
129
+ // The cursor-reported bug: the daemon creates ~/.config/remnic/config.json
130
+ // for its own setup, so the bare remnic/ config root exists on a machine
131
+ // that has NEVER installed a connector under remnic. The active root must
132
+ // still resolve to engram when connector data lives there, otherwise
133
+ // loadRegistry would read an empty remnic tree and legacy installs would
134
+ // look uninstalled.
135
+ withXdgHome((xdg) => {
136
+ // Daemon setup created remnic/config.json (no .remnic-connectors).
137
+ fs.mkdirSync(path.join(xdg, "remnic"), { recursive: true });
138
+ fs.writeFileSync(path.join(xdg, "remnic", "config.json"), "{}\n");
139
+ // Real connector data still lives under the legacy engram tree.
140
+ fs.mkdirSync(path.join(xdg, "engram", ".engram-connectors", "connectors"), {
141
+ recursive: true,
142
+ });
143
+ assert.equal(getActiveConnectorsConfigRoot(), path.join(xdg, "engram"));
144
+ assert.equal(
145
+ getConnectorsDir(),
146
+ path.join(xdg, "engram", ".engram-connectors", "connectors"),
147
+ );
148
+ });
149
+ });
150
+
151
+ test("hasLegacyConnectorEntries: fresh canonical install is NOT legacy evidence (#1620 review)", () => {
152
+ // The codex P2 concern: a fresh post-rename install writes its first
153
+ // connector under remnic/.remnic-connectors/. That must NOT flip
154
+ // resolveEmitLegacyTools, because the user never had engram.* aliases.
155
+ withXdgHome((xdg) => {
156
+ fs.mkdirSync(
157
+ path.join(xdg, "remnic", ".remnic-connectors", "connectors"),
158
+ { recursive: true },
159
+ );
160
+ fs.writeFileSync(
161
+ path.join(xdg, "remnic", ".remnic-connectors", "connectors", "claude-code.json"),
162
+ "{}\n",
163
+ );
164
+ assert.equal(
165
+ hasLegacyConnectorEntries(),
166
+ false,
167
+ "canonical remnic/ connector entries are not legacy evidence",
168
+ );
169
+ });
170
+ });
171
+
172
+ test("hasLegacyConnectorEntries: legacy engram entries ARE sticky-legacy evidence (#1550)", () => {
173
+ // An unmigrated install with connector data under engram/.engram-connectors
174
+ // must still report legacy evidence so its clients keep seeing engram_* aliases.
175
+ withXdgHome((xdg) => {
176
+ fs.mkdirSync(
177
+ path.join(xdg, "engram", ".engram-connectors", "connectors"),
178
+ { recursive: true },
179
+ );
180
+ fs.writeFileSync(
181
+ path.join(xdg, "engram", ".engram-connectors", "connectors", "claude-code.json"),
182
+ "{}\n",
183
+ );
184
+ assert.equal(hasLegacyConnectorEntries(), true);
185
+ });
186
+ });
187
+
188
+ test("hasLegacyConnectorEntries: missing legacy dir is not legacy evidence (fresh install)", () => {
189
+ withXdgHome(() => {
190
+ assert.equal(hasLegacyConnectorEntries(), false);
191
+ });
192
+ });
@@ -4,46 +4,132 @@ import path from "node:path";
4
4
  import { readEnvVar, resolveHomeDir } from "../runtime/env.js";
5
5
 
6
6
  /**
7
- * Connector registry directory name. Kept under the legacy `engram` config
8
- * root for backward compatibility with existing installs; the rename to a
9
- * `remnic` path (with a legacy read-fallback) is tracked in #1518.
7
+ * Connector registry directory name. Kept under the canonical `remnic` config
8
+ * root for new installs; legacy installs that still live under `engram` are
9
+ * read-transparent via {@link getActiveConnectorsConfigRoot} (issue #1518).
10
10
  */
11
- export const REGISTRY_DIR_NAME = ".engram-connectors";
11
+ export const REGISTRY_DIR_NAME = ".remnic-connectors";
12
12
 
13
13
  /**
14
- * Single source of truth for the connectors config root
15
- * (`$XDG_CONFIG_HOME/engram` or `~/.config/engram`). Issue #1527 flagged this
16
- * derivation as previously duplicated across call sites — add new callers
17
- * here, never re-derive the path inline.
14
+ * Legacy registry directory name from the engram era. Used only for the
15
+ * read-fallback so existing installs keep resolving until the user migrates.
16
+ */
17
+ export const LEGACY_REGISTRY_DIR_NAME = ".engram-connectors";
18
+
19
+ /**
20
+ * Config-root segment for the canonical (post-rename) layout. Issue #1518
21
+ * point 6: the path `~/.config/engram/...` is confusing when the product is
22
+ * called Remnic, so new writes land under `remnic/`.
23
+ */
24
+ const CONFIG_ROOT_SEGMENT = "remnic";
25
+
26
+ /**
27
+ * Config-root segment for the legacy engram-era layout. Read-fallback only.
28
+ */
29
+ const LEGACY_CONFIG_ROOT_SEGMENT = "engram";
30
+
31
+ /**
32
+ * Single source of truth for the canonical connectors config root
33
+ * (`$XDG_CONFIG_HOME/remnic` or `~/.config/remnic`). New writes ALWAYS go
34
+ * here. Issue #1527 flagged this derivation as previously duplicated across
35
+ * call sites — add new callers here, never re-derive the path inline.
18
36
  */
19
37
  export function getConnectorsConfigRoot(): string {
20
38
  const xdgConfigHome = readEnvVar("XDG_CONFIG_HOME");
21
39
  return xdgConfigHome
22
- ? path.join(xdgConfigHome, "engram")
23
- : path.join(resolveHomeDir(), ".config", "engram");
40
+ ? path.join(xdgConfigHome, CONFIG_ROOT_SEGMENT)
41
+ : path.join(resolveHomeDir(), ".config", CONFIG_ROOT_SEGMENT);
24
42
  }
25
43
 
26
- /** Path of the connector registry manifest file. */
44
+ /**
45
+ * Legacy config root from the engram era (`$XDG_CONFIG_HOME/engram` or
46
+ * `~/.config/engram`). Exposed for tests and the migration path only —
47
+ * production code should call {@link getActiveConnectorsConfigRoot} so the
48
+ * read-fallback is applied uniformly.
49
+ */
50
+ export function getLegacyConnectorsConfigRoot(): string {
51
+ const xdgConfigHome = readEnvVar("XDG_CONFIG_HOME");
52
+ return xdgConfigHome
53
+ ? path.join(xdgConfigHome, LEGACY_CONFIG_ROOT_SEGMENT)
54
+ : path.join(resolveHomeDir(), ".config", LEGACY_CONFIG_ROOT_SEGMENT);
55
+ }
56
+
57
+ /**
58
+ * Read-time fallback: pick the config root that actually holds (or will hold)
59
+ * the connector registry.
60
+ *
61
+ * 1. Canonical `remnic/` root when its `.remnic-connectors` registry dir
62
+ * already exists (post-migration, or a fresh install that has already
63
+ * written its first connector).
64
+ * 2. Legacy `engram/` root when ONLY its `.engram-connectors` registry dir
65
+ * exists — an existing install we keep reading in place until the user
66
+ * runs `remnic migrate`.
67
+ * 3. Canonical `remnic/` root otherwise (fresh install with no registry on
68
+ * disk yet) so the first write lands at the new path.
69
+ *
70
+ * The probe looks for the REGISTRY SUBDIR (`.remnic-connectors` /
71
+ * `.engram-connectors`), not the bare config root. The config root
72
+ * `~/.config/remnic/` is created by unrelated daemon setup (e.g. the daemon's
73
+ * own `config.json`), so probing the bare root would falsely resolve to
74
+ * remnic and hide connector data that still lives under
75
+ * `~/.config/engram/.engram-connectors/` (cursor review round 1, #1620).
76
+ *
77
+ * This is a READ-time fallback. Writes go to whichever root this returns, so
78
+ * a legacy install keeps its data colocated for the active session; a fresh
79
+ * install writes straight to `remnic/`.
80
+ */
81
+ export function getActiveConnectorsConfigRoot(): string {
82
+ const canonical = getConnectorsConfigRoot();
83
+ if (fs.existsSync(path.join(canonical, REGISTRY_DIR_NAME))) return canonical;
84
+ const legacy = getLegacyConnectorsConfigRoot();
85
+ if (fs.existsSync(path.join(legacy, LEGACY_REGISTRY_DIR_NAME))) return legacy;
86
+ return canonical;
87
+ }
88
+
89
+ /**
90
+ * Path of the connector registry manifest file. The registry subdir name
91
+ * (`.remnic-connectors` vs the legacy `.engram-connectors`) is chosen from the
92
+ * active root so a pre-rename install reads its existing files without a
93
+ * rename-side migration step (#1518).
94
+ */
27
95
  export function getRegistryPath(): string {
28
- return path.join(getConnectorsConfigRoot(), REGISTRY_DIR_NAME, "registry.json");
96
+ const root = getActiveConnectorsConfigRoot();
97
+ const dirName = root === getConnectorsConfigRoot() ? REGISTRY_DIR_NAME : LEGACY_REGISTRY_DIR_NAME;
98
+ return path.join(root, dirName, "registry.json");
29
99
  }
30
100
 
31
- /** Directory holding one `<connector-id>.json` per installed connector. */
101
+ /**
102
+ * Directory holding one `<connector-id>.json` per installed connector. The
103
+ * registry subdir name follows the active root (see {@link getRegistryPath}).
104
+ */
32
105
  export function getConnectorsDir(): string {
33
- return path.join(getConnectorsConfigRoot(), REGISTRY_DIR_NAME, "connectors");
106
+ const root = getActiveConnectorsConfigRoot();
107
+ const dirName = root === getConnectorsConfigRoot() ? REGISTRY_DIR_NAME : LEGACY_REGISTRY_DIR_NAME;
108
+ return path.join(root, dirName, "connectors");
34
109
  }
35
110
 
36
111
  /**
37
112
  * Sticky-legacy evidence for `emitLegacyTools` (issue #1550): any persisted
38
- * connector entry under the legacy engram connectors dir means an existing
39
- * install whose clients may still address engram_* aliases. Missing or
40
- * unreadable dir means fresh install — no evidence, no aliases.
113
+ * connector entry under the LEGACY engram registry means an existing install
114
+ * whose clients may still address engram_* aliases. Missing or unreadable
115
+ * legacy dir means fresh install — no evidence, no aliases.
116
+ *
117
+ * Probes ONLY the legacy engram registry (`$XDG_CONFIG_HOME/engram/.engram-connectors`),
118
+ * never the active/canonical root. A fresh post-rename install that just
119
+ * wrote its first connector under `remnic/.remnic-connectors/` is NOT legacy
120
+ * evidence — counting it would flip `resolveEmitLegacyTools` to true and
121
+ * advertise deprecated `engram.*` aliases to users who never had them
122
+ * (#1620 review, codex P2). An unmigrated install still reports its entries
123
+ * here because its data lives under the engram tree.
41
124
  */
42
125
  export function hasLegacyConnectorEntries(): boolean {
43
126
  try {
44
- return fs
45
- .readdirSync(getConnectorsDir())
46
- .some((name) => name.endsWith(".json"));
127
+ const legacyDir = path.join(
128
+ getLegacyConnectorsConfigRoot(),
129
+ LEGACY_REGISTRY_DIR_NAME,
130
+ "connectors",
131
+ );
132
+ return fs.readdirSync(legacyDir).some((name) => name.endsWith(".json"));
47
133
  } catch {
48
134
  return false;
49
135
  }
@@ -17,6 +17,7 @@ export class ClaudeCodeMemoryExtensionPublisher implements MemoryExtensionPublis
17
17
  readonly hostId = "claude-code";
18
18
 
19
19
  static readonly capabilities: PublisherCapabilities = {
20
+ isStub: true,
20
21
  instructionsMd: false,
21
22
  skillsFolder: false,
22
23
  citationFormat: false,
@@ -53,6 +53,7 @@ export class CodexMemoryExtensionPublisher implements MemoryExtensionPublisher {
53
53
  readonly hostId = "codex";
54
54
 
55
55
  static readonly capabilities: PublisherCapabilities = {
56
+ isStub: false,
56
57
  instructionsMd: true,
57
58
  skillsFolder: false,
58
59
  citationFormat: true,
@@ -17,6 +17,7 @@ export class HermesMemoryExtensionPublisher implements MemoryExtensionPublisher
17
17
  readonly hostId = "hermes";
18
18
 
19
19
  static readonly capabilities: PublisherCapabilities = {
20
+ isStub: true,
20
21
  instructionsMd: false,
21
22
  skillsFolder: false,
22
23
  citationFormat: false,
@@ -73,8 +73,19 @@ export interface PublishResult {
73
73
  * Declarative capability flags that describe what a given publisher
74
74
  * can produce. Useful for feature-gating UI or doctor output without
75
75
  * instantiating the publisher.
76
+ *
77
+ * `isStub` is the explicit declaration required by #1518: when true, the
78
+ * docs-code parity gate (`scripts/check-docs-parity.mjs`) refuses any
79
+ * automation claim ("installs the plugin", "configures mcp",
80
+ * "automatically …") in the publisher's mapped install docs, because a stub
81
+ * publisher writes nothing to disk. A publisher that implements any real
82
+ * artefact writes MUST set this false. The inferred "all flags false ⇒ stub"
83
+ * detection in the parity script remains as a backstop, but the explicit
84
+ * flag is now the source of truth.
76
85
  */
77
86
  export interface PublisherCapabilities {
87
+ /** Whether the publisher is an intentional no-op stub (writes nothing). */
88
+ readonly isStub: boolean;
78
89
  /** Whether the publisher writes an instructions.md file. */
79
90
  readonly instructionsMd: boolean;
80
91
  /** Whether the publisher populates a skills folder. */
@@ -0,0 +1,269 @@
1
+ // Issue #1539 PR2 — unit tests for the recall pipeline spine module.
2
+ //
3
+ // These tests verify `unifiedDedupeAndRank` produces the correct results for
4
+ // each declared divergence dimension, WITHOUT changing any existing pipeline.
5
+ // PRs 3–6 will migrate each pipeline to call this function; at that point the
6
+ // characterization snapshots (tests/recall-pipeline-unified.test.ts) verify
7
+ // end-to-end byte-for-byte parity.
8
+
9
+ import assert from "node:assert/strict";
10
+ import test from "node:test";
11
+
12
+ import type { EvidencePackItem } from "./evidence-pack.js";
13
+ import {
14
+ unifiedDedupeAndRank,
15
+ type RankedEvidenceItem,
16
+ } from "./recall-pipeline-stages.js";
17
+
18
+ const NO_INTENTS: never[] = [];
19
+ const constantScorer = (_item: EvidencePackItem): number => 10;
20
+
21
+ function item(
22
+ turnIndex: number,
23
+ content: string,
24
+ overrides: Partial<EvidencePackItem> = {},
25
+ ): EvidencePackItem {
26
+ return {
27
+ id: `s1:${turnIndex}`,
28
+ sessionId: "s1",
29
+ turnIndex,
30
+ role: "user",
31
+ content,
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ test("unifiedDedupeAndRank: dedup collapses identical ids", () => {
37
+ const items = [
38
+ item(3, "Content A"),
39
+ item(3, "Content A"), // same id → deduped
40
+ ];
41
+ const result = unifiedDedupeAndRank(items, {
42
+ query: "q",
43
+ intents: NO_INTENTS,
44
+ scoreEvidence: constantScorer,
45
+ });
46
+ assert.equal(result.length, 1);
47
+ assert.equal(result[0]?.turnIndex, 3);
48
+ });
49
+
50
+ test("unifiedDedupeAndRank: dedup collapses identical normalized content under different ids", () => {
51
+ const items = [
52
+ item(10, "My monthly expenses are $2,400."),
53
+ item(11, "MY MONTHLY EXPENSES ARE $2,400."), // same normalized content
54
+ ];
55
+ const result = unifiedDedupeAndRank(items, {
56
+ query: "q",
57
+ intents: NO_INTENTS,
58
+ scoreEvidence: constantScorer,
59
+ });
60
+ assert.equal(result.length, 1, "expected content dedup to collapse the two items");
61
+ assert.equal(result[0]?.turnIndex, 10, "first-seen wins");
62
+ });
63
+
64
+ test("unifiedDedupeAndRank: DESC sort (default) orders turnIndex descending on score ties", () => {
65
+ const items = [
66
+ item(2, "Oldest"),
67
+ item(5, "Middle"),
68
+ item(9, "Newest"),
69
+ ];
70
+ const result = unifiedDedupeAndRank(items, {
71
+ query: "q",
72
+ intents: NO_INTENTS,
73
+ scoreEvidence: constantScorer, // all tied → turnIndex DESC
74
+ });
75
+ const turns = result.map((r) => r.turnIndex);
76
+ assert.deepEqual(turns, [9, 5, 2]);
77
+ });
78
+
79
+ test("unifiedDedupeAndRank: ASC sort orders turnIndex ascending on score ties", () => {
80
+ const items = [
81
+ item(30, "Latest"),
82
+ item(10, "Earliest"),
83
+ item(20, "Middle"),
84
+ ];
85
+ const result = unifiedDedupeAndRank(items, {
86
+ query: "q",
87
+ intents: NO_INTENTS,
88
+ scoreEvidence: constantScorer,
89
+ turnIndexSortDirection: "asc",
90
+ });
91
+ const turns = result.map((r) => r.turnIndex);
92
+ assert.deepEqual(turns, [10, 20, 30]);
93
+ });
94
+
95
+ test("unifiedDedupeAndRank: rank primary key is always DESC regardless of turnIndex direction", () => {
96
+ const items = [
97
+ item(1, "Low rank", { score: 0 }),
98
+ item(2, "High rank", { score: 0 }),
99
+ ];
100
+ const result = unifiedDedupeAndRank(items, {
101
+ query: "q",
102
+ intents: NO_INTENTS,
103
+ scoreEvidence: (i) => (i.turnIndex === 2 ? 100 : 1),
104
+ turnIndexSortDirection: "asc",
105
+ });
106
+ // rank DESC wins over turnIndex ASC: turn 2 (rank 100) comes first despite ASC.
107
+ assert.equal(result[0]?.turnIndex, 2);
108
+ assert.equal(result[1]?.turnIndex, 1);
109
+ });
110
+
111
+ test("unifiedDedupeAndRank: rankThreshold drops items below the declared threshold", () => {
112
+ const items = [
113
+ item(1, "Weak", { score: 0 }),
114
+ item(2, "Strong", { score: 0 }),
115
+ item(3, "Medium", { score: 0 }),
116
+ ];
117
+ const result = unifiedDedupeAndRank(items, {
118
+ query: "q",
119
+ intents: NO_INTENTS,
120
+ scoreEvidence: (i) => {
121
+ if (i.turnIndex === 1) return 3;
122
+ if (i.turnIndex === 2) return 10;
123
+ return 6;
124
+ },
125
+ rankThreshold: 6,
126
+ });
127
+ const turns = result.map((r) => r.turnIndex).sort((a, b) => (a ?? 0) - (b ?? 0));
128
+ assert.deepEqual(turns, [2, 3], "turn 1 (rank 3) is below threshold 6 and dropped");
129
+ });
130
+
131
+ test("unifiedDedupeAndRank: transformContent is applied to output but NOT to scorer input", () => {
132
+ const items = [item(5, "original content")];
133
+ let scorerSawTransformed = false;
134
+ const result = unifiedDedupeAndRank(items, {
135
+ query: "q",
136
+ intents: NO_INTENTS,
137
+ scoreEvidence: (i) => {
138
+ if (i.content.includes("APPENDED CUE")) scorerSawTransformed = true;
139
+ return 5;
140
+ },
141
+ transformContent: (content) => `${content}\nAPPENDED CUE`,
142
+ });
143
+ assert.equal(scorerSawTransformed, false, "scorer must see ORIGINAL content");
144
+ assert.ok(result[0]?.content.includes("APPENDED CUE"), "output must have transformed content");
145
+ });
146
+
147
+ test("unifiedDedupeAndRank: undefined turnIndex sorts to the bottom of DESC (-1 sentinel)", () => {
148
+ const items = [
149
+ item(5, "Has turn"),
150
+ { id: "s1:x", sessionId: "s1", role: "user", content: "No turn" }, // no turnIndex
151
+ ];
152
+ const result = unifiedDedupeAndRank(items, {
153
+ query: "q",
154
+ intents: NO_INTENTS,
155
+ scoreEvidence: constantScorer,
156
+ // default DESC
157
+ });
158
+ assert.equal(result[1]?.id, "s1:x", "undefined-turnIndex item sorts last in DESC");
159
+ });
160
+
161
+ test("unifiedDedupeAndRank: undefined turnIndex sorts to the bottom of ASC (MAX sentinel)", () => {
162
+ const items = [
163
+ item(5, "Has turn"),
164
+ { id: "s1:x", sessionId: "s1", role: "user", content: "No turn" },
165
+ ];
166
+ const result = unifiedDedupeAndRank(items, {
167
+ query: "q",
168
+ intents: NO_INTENTS,
169
+ scoreEvidence: constantScorer,
170
+ turnIndexSortDirection: "asc",
171
+ });
172
+ assert.equal(result[1]?.id, "s1:x", "undefined-turnIndex item sorts last in ASC");
173
+ });
174
+
175
+ test("unifiedDedupeAndRank: DESC tertiary tiebreaker is score DESC", () => {
176
+ // Same rank (all tied via constantScorer) AND same turnIndex → score breaks the tie.
177
+ const items = [
178
+ item(5, "Low score", { score: 10 }),
179
+ item(5, "High score", { score: 90 }), // same turn, deduped by content? No — different content
180
+ ];
181
+ // Wait — both have turn 5 so same id "s1:5" → second is deduped. Use different sessions.
182
+ const itemsDistinct: EvidencePackItem[] = [
183
+ { id: "s1:5", sessionId: "s1", turnIndex: 5, role: "user", content: "A", score: 10 },
184
+ { id: "s2:5", sessionId: "s2", turnIndex: 5, role: "user", content: "B", score: 90 },
185
+ ];
186
+ const result = unifiedDedupeAndRank(itemsDistinct, {
187
+ query: "q",
188
+ intents: NO_INTENTS,
189
+ scoreEvidence: constantScorer, // rank tied → turnIndex tied → score DESC
190
+ });
191
+ assert.equal(result[0]?.id, "s2:5", "higher score (90) ranks first on DESC tertiary");
192
+ assert.equal(result[1]?.id, "s1:5");
193
+ });
194
+
195
+ test("unifiedDedupeAndRank: ASC tertiary tiebreaker is content.localeCompare", () => {
196
+ const items: EvidencePackItem[] = [
197
+ { id: "s2:5", sessionId: "s2", turnIndex: 5, role: "user", content: "Banana" },
198
+ { id: "s1:5", sessionId: "s1", turnIndex: 5, role: "user", content: "Apple" },
199
+ ];
200
+ const result = unifiedDedupeAndRank(items, {
201
+ query: "q",
202
+ intents: NO_INTENTS,
203
+ scoreEvidence: constantScorer, // rank tied → turnIndex tied → content localeCompare
204
+ turnIndexSortDirection: "asc",
205
+ });
206
+ assert.equal(result[0]?.content, "Apple", "localeCompare ASC: Apple < Banana");
207
+ assert.equal(result[1]?.content, "Banana");
208
+ });
209
+
210
+ test("unifiedDedupeAndRank: RankedEvidenceItem carries the computed rank", () => {
211
+ const items = [item(1, "content")];
212
+ const result: RankedEvidenceItem[] = unifiedDedupeAndRank(items, {
213
+ query: "q",
214
+ intents: NO_INTENTS,
215
+ scoreEvidence: () => 42,
216
+ });
217
+ assert.equal(result[0]?.rank, 42);
218
+ });
219
+
220
+ test("unifiedDedupeAndRank: fallback id uses sessionId:turnIndex when id is absent", () => {
221
+ const items: EvidencePackItem[] = [
222
+ { sessionId: "s1", turnIndex: 7, role: "user", content: "No explicit id" },
223
+ { sessionId: "s1", turnIndex: 7, role: "user", content: "Same fallback id → deduped" },
224
+ ];
225
+ const result = unifiedDedupeAndRank(items, {
226
+ query: "q",
227
+ intents: NO_INTENTS,
228
+ scoreEvidence: constantScorer,
229
+ });
230
+ assert.equal(result.length, 1, "fallback id dedup must collapse the two items");
231
+ });
232
+
233
+ test("unifiedDedupeAndRank: dedupByContent false keeps distinct ids with identical content (event-order shape)", () => {
234
+ // Event-order's rankAndSelectEventOrderItems deduplicates by turn id only and
235
+ // keeps distinct turns even when two turns share the same cue-appended body.
236
+ // The spine must express that as a declared config field so PR 6's migration
237
+ // does not silently drop valid turns (cursor bugbot a4299851).
238
+ const items: EvidencePackItem[] = [
239
+ item(1, "What time is it?", { id: "s1:1" }),
240
+ item(5, "What time is it?", { id: "s1:5" }),
241
+ ];
242
+ const result = unifiedDedupeAndRank(items, {
243
+ query: "q",
244
+ intents: NO_INTENTS,
245
+ scoreEvidence: constantScorer,
246
+ dedupByContent: false,
247
+ });
248
+ assert.equal(result.length, 2, "distinct ids with identical content must both survive when dedupByContent is false");
249
+ });
250
+
251
+ test("unifiedDedupeAndRank: dedupByContent true (default) still collapses identical content under different ids", () => {
252
+ const items: EvidencePackItem[] = [
253
+ item(1, "Duplicate body", { id: "s1:1" }),
254
+ item(2, "Duplicate body", { id: "s1:2" }),
255
+ ];
256
+ const resultDefault = unifiedDedupeAndRank(items, {
257
+ query: "q",
258
+ intents: NO_INTENTS,
259
+ scoreEvidence: constantScorer,
260
+ });
261
+ assert.equal(resultDefault.length, 1, "default dedupByContent collapses identical content");
262
+ const resultExplicit = unifiedDedupeAndRank(items, {
263
+ query: "q",
264
+ intents: NO_INTENTS,
265
+ scoreEvidence: constantScorer,
266
+ dedupByContent: true,
267
+ });
268
+ assert.equal(resultExplicit.length, 1, "explicit dedupByContent: true collapses identical content");
269
+ });