@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.
- package/dist/access-boundary.js +8 -8
- package/dist/access-cli.js +16 -16
- package/dist/access-http.js +11 -11
- package/dist/access-mcp.js +10 -10
- package/dist/access-operations.js +9 -9
- package/dist/access-service.js +7 -7
- package/dist/active-recall.js +3 -3
- package/dist/{chunk-ZPTISBQU.js → chunk-2SNKUSQC.js} +4 -4
- package/dist/{chunk-MCQDSY4G.js → chunk-3K37VPM3.js} +2 -2
- package/dist/{chunk-6GJS4BFH.js → chunk-A7UZR6RA.js} +2 -2
- package/dist/{chunk-U7D7NP4J.js → chunk-ED35D32I.js} +2 -2
- package/dist/{chunk-2KAYTPPT.js → chunk-JPETDXED.js} +5 -5
- package/dist/{chunk-B43NZNMG.js → chunk-JZBFL7RI.js} +7 -7
- package/dist/chunk-K6MV3TRQ.js +56 -0
- package/dist/chunk-K6MV3TRQ.js.map +1 -0
- package/dist/{chunk-QO3AILZN.js → chunk-OO42R444.js} +2 -2
- package/dist/{chunk-NN7QYW5W.js → chunk-OWFY6NGQ.js} +2 -2
- package/dist/{chunk-S2OU5DZY.js → chunk-QSQW54U5.js} +4 -4
- package/dist/{chunk-QVMXQGT7.js → chunk-RHXFYIHA.js} +23 -23
- package/dist/{chunk-CTOQEZSN.js → chunk-SAEZIIID.js} +2 -2
- package/dist/{chunk-DCWIQFNA.js → chunk-T422SYM6.js} +5 -5
- package/dist/{chunk-FE6DQUNJ.js → chunk-UHUZXWDX.js} +3 -3
- package/dist/{chunk-473JIN2U.js → chunk-UXLZOVCN.js} +3 -3
- package/dist/{chunk-2SJCWLQD.js → chunk-W63OY3J7.js} +2 -2
- package/dist/{chunk-FUCUR2OZ.js → chunk-XU7363OX.js} +2 -2
- package/dist/{chunk-KFBOZYME.js → chunk-Z2M6YTAJ.js} +3 -3
- package/dist/cli.js +24 -24
- package/dist/config.js +3 -3
- package/dist/connectors/index.d.ts +10 -2
- package/dist/connectors/index.js +2 -2
- package/dist/conversation-index/backend.js +2 -2
- package/dist/emit-legacy-tools.js +2 -2
- package/dist/index.d.ts +11 -0
- package/dist/index.js +36 -33
- package/dist/index.js.map +1 -1
- package/dist/lcm/engine.js +2 -2
- package/dist/lcm/index.js +4 -4
- package/dist/namespaces/migrate.js +7 -7
- package/dist/namespaces/search.js +6 -6
- package/dist/operator-toolkit.js +11 -11
- package/dist/orchestrator.js +10 -10
- package/dist/recall-pipeline-stages.d.ts +167 -0
- package/dist/recall-pipeline-stages.js +61 -0
- package/dist/recall-pipeline-stages.js.map +1 -0
- package/dist/resume-bundles.js +4 -4
- package/dist/search/factory.js +5 -5
- package/dist/search/index.js +9 -9
- package/package.json +2 -2
- package/src/connectors/paths.test.ts +192 -0
- package/src/connectors/paths.ts +106 -20
- package/src/memory-extension/claude-code-publisher.ts +1 -0
- package/src/memory-extension/codex-publisher.ts +1 -0
- package/src/memory-extension/hermes-publisher.ts +1 -0
- package/src/memory-extension/types.ts +11 -0
- package/src/recall-pipeline-stages.test.ts +269 -0
- package/src/recall-pipeline-stages.ts +294 -0
- package/dist/chunk-3BQOQYRB.js +0 -33
- package/dist/chunk-3BQOQYRB.js.map +0 -1
- /package/dist/{chunk-ZPTISBQU.js.map → chunk-2SNKUSQC.js.map} +0 -0
- /package/dist/{chunk-MCQDSY4G.js.map → chunk-3K37VPM3.js.map} +0 -0
- /package/dist/{chunk-6GJS4BFH.js.map → chunk-A7UZR6RA.js.map} +0 -0
- /package/dist/{chunk-U7D7NP4J.js.map → chunk-ED35D32I.js.map} +0 -0
- /package/dist/{chunk-2KAYTPPT.js.map → chunk-JPETDXED.js.map} +0 -0
- /package/dist/{chunk-B43NZNMG.js.map → chunk-JZBFL7RI.js.map} +0 -0
- /package/dist/{chunk-QO3AILZN.js.map → chunk-OO42R444.js.map} +0 -0
- /package/dist/{chunk-NN7QYW5W.js.map → chunk-OWFY6NGQ.js.map} +0 -0
- /package/dist/{chunk-S2OU5DZY.js.map → chunk-QSQW54U5.js.map} +0 -0
- /package/dist/{chunk-QVMXQGT7.js.map → chunk-RHXFYIHA.js.map} +0 -0
- /package/dist/{chunk-CTOQEZSN.js.map → chunk-SAEZIIID.js.map} +0 -0
- /package/dist/{chunk-DCWIQFNA.js.map → chunk-T422SYM6.js.map} +0 -0
- /package/dist/{chunk-FE6DQUNJ.js.map → chunk-UHUZXWDX.js.map} +0 -0
- /package/dist/{chunk-473JIN2U.js.map → chunk-UXLZOVCN.js.map} +0 -0
- /package/dist/{chunk-2SJCWLQD.js.map → chunk-W63OY3J7.js.map} +0 -0
- /package/dist/{chunk-FUCUR2OZ.js.map → chunk-XU7363OX.js.map} +0 -0
- /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
|
+
});
|
package/src/connectors/paths.ts
CHANGED
|
@@ -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
|
|
8
|
-
* root for
|
|
9
|
-
*
|
|
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 = ".
|
|
11
|
+
export const REGISTRY_DIR_NAME = ".remnic-connectors";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
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,
|
|
23
|
-
: path.join(resolveHomeDir(), ".config",
|
|
40
|
+
? path.join(xdgConfigHome, CONFIG_ROOT_SEGMENT)
|
|
41
|
+
: path.join(resolveHomeDir(), ".config", CONFIG_ROOT_SEGMENT);
|
|
24
42
|
}
|
|
25
43
|
|
|
26
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
});
|