@remnic/core 9.3.671 → 9.3.673

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 (98) hide show
  1. package/dist/access-audit.js +2 -2
  2. package/dist/access-cli.js +27 -25
  3. package/dist/access-cli.js.map +1 -1
  4. package/dist/access-http.js +12 -12
  5. package/dist/access-mcp.js +11 -11
  6. package/dist/access-schema.d.ts +36 -36
  7. package/dist/access-schema.js +3 -3
  8. package/dist/access-service.js +9 -9
  9. package/dist/active-recall.js +3 -1
  10. package/dist/active-recall.js.map +1 -1
  11. package/dist/chunk-3BQOQYRB.js +33 -0
  12. package/dist/chunk-3BQOQYRB.js.map +1 -0
  13. package/dist/{chunk-UOBLE67F.js → chunk-3IE22DJ2.js} +4 -4
  14. package/dist/chunk-52LZ42LI.js +25 -0
  15. package/dist/chunk-52LZ42LI.js.map +1 -0
  16. package/dist/{chunk-CRO4LCQ6.js → chunk-7OGJQP7T.js} +5 -5
  17. package/dist/{chunk-23EBQ27U.js → chunk-B55KFEGS.js} +2 -2
  18. package/dist/{chunk-BTLNC5YM.js → chunk-GNAMDNGT.js} +5 -13
  19. package/dist/chunk-GNAMDNGT.js.map +1 -0
  20. package/dist/{chunk-KQAFEZQX.js → chunk-IPLYGWQF.js} +5 -5
  21. package/dist/{chunk-MLVMBV2C.js → chunk-IUZWBCJX.js} +8 -40
  22. package/dist/chunk-IUZWBCJX.js.map +1 -0
  23. package/dist/{chunk-PYTATYUV.js → chunk-ODWI5XU2.js} +2 -2
  24. package/dist/{chunk-F7OWUP3G.js → chunk-OG7A6AZX.js} +4 -4
  25. package/dist/{chunk-XS2CWEHZ.js → chunk-Q6MIDQEL.js} +2 -2
  26. package/dist/{chunk-UDJLF3BO.js → chunk-QLRYXOAD.js} +2 -2
  27. package/dist/chunk-QO3AILZN.js +89 -0
  28. package/dist/chunk-QO3AILZN.js.map +1 -0
  29. package/dist/{chunk-ZI6A7X4V.js → chunk-R37A3BEW.js} +26 -26
  30. package/dist/{chunk-CPVV2UEL.js → chunk-SDLJ2W7S.js} +6 -6
  31. package/dist/{chunk-Z4GALEO3.js → chunk-SF45RQDX.js} +3 -3
  32. package/dist/{chunk-LXVOZ2O6.js → chunk-T2AOOHDA.js} +2 -2
  33. package/dist/{chunk-7K5Q6COX.js → chunk-TVVEYCNW.js} +4 -4
  34. package/dist/{chunk-32RD3GIW.js → chunk-XVVEKF5I.js} +19 -19
  35. package/dist/{chunk-TGN4M5MB.js → chunk-ZLINDOBG.js} +4 -4
  36. package/dist/cli.js +24 -22
  37. package/dist/coding/optional-coding-graph.d.ts +63 -0
  38. package/dist/coding/optional-coding-graph.js +119 -0
  39. package/dist/coding/optional-coding-graph.js.map +1 -0
  40. package/dist/coding-graph-types-Dd2tGrnm.d.ts +106 -0
  41. package/dist/config.d.ts +1 -1
  42. package/dist/config.js +3 -1
  43. package/dist/connectors/index.d.ts +6 -2
  44. package/dist/connectors/index.js +6 -2
  45. package/dist/conversation-index/backend.js +2 -2
  46. package/dist/emit-legacy-tools.d.ts +61 -0
  47. package/dist/emit-legacy-tools.js +12 -0
  48. package/dist/emit-legacy-tools.js.map +1 -0
  49. package/dist/index.d.ts +1 -0
  50. package/dist/index.js +54 -46
  51. package/dist/index.js.map +1 -1
  52. package/dist/lcm/engine.js +2 -2
  53. package/dist/lcm/index.js +2 -2
  54. package/dist/namespaces/migrate.js +5 -5
  55. package/dist/namespaces/search.js +4 -4
  56. package/dist/operator-toolkit.js +10 -8
  57. package/dist/orchestrator.js +16 -16
  58. package/dist/resume-bundles.js +4 -2
  59. package/dist/schemas.d.ts +42 -42
  60. package/dist/search/factory.js +3 -3
  61. package/dist/search/index.js +3 -3
  62. package/dist/shared-context/manager.d.ts +2 -2
  63. package/dist/transfer/autodetect.js +1 -1
  64. package/dist/transfer/backup.js +1 -1
  65. package/dist/transfer/capsule-export.js +2 -2
  66. package/package.json +19 -1
  67. package/src/coding/coding-graph-types.ts +180 -0
  68. package/src/coding/optional-coding-graph-cache.test.ts +86 -0
  69. package/src/coding/optional-coding-graph-cacheread.test.ts +78 -0
  70. package/src/coding/optional-coding-graph-concurrent.test.ts +48 -0
  71. package/src/coding/optional-coding-graph-incompatible.test.ts +98 -0
  72. package/src/coding/optional-coding-graph-probe.test.ts +34 -0
  73. package/src/coding/optional-coding-graph.test.ts +117 -0
  74. package/src/coding/optional-coding-graph.ts +370 -0
  75. package/src/config.test.ts +408 -6
  76. package/src/config.ts +12 -56
  77. package/src/connectors/index.ts +2 -15
  78. package/src/connectors/paths.ts +50 -0
  79. package/src/emit-legacy-tools.test.ts +297 -0
  80. package/src/emit-legacy-tools.ts +204 -0
  81. package/src/index.ts +22 -0
  82. package/dist/chunk-BTLNC5YM.js.map +0 -1
  83. package/dist/chunk-MLVMBV2C.js.map +0 -1
  84. /package/dist/{chunk-UOBLE67F.js.map → chunk-3IE22DJ2.js.map} +0 -0
  85. /package/dist/{chunk-CRO4LCQ6.js.map → chunk-7OGJQP7T.js.map} +0 -0
  86. /package/dist/{chunk-23EBQ27U.js.map → chunk-B55KFEGS.js.map} +0 -0
  87. /package/dist/{chunk-KQAFEZQX.js.map → chunk-IPLYGWQF.js.map} +0 -0
  88. /package/dist/{chunk-PYTATYUV.js.map → chunk-ODWI5XU2.js.map} +0 -0
  89. /package/dist/{chunk-F7OWUP3G.js.map → chunk-OG7A6AZX.js.map} +0 -0
  90. /package/dist/{chunk-XS2CWEHZ.js.map → chunk-Q6MIDQEL.js.map} +0 -0
  91. /package/dist/{chunk-UDJLF3BO.js.map → chunk-QLRYXOAD.js.map} +0 -0
  92. /package/dist/{chunk-ZI6A7X4V.js.map → chunk-R37A3BEW.js.map} +0 -0
  93. /package/dist/{chunk-CPVV2UEL.js.map → chunk-SDLJ2W7S.js.map} +0 -0
  94. /package/dist/{chunk-Z4GALEO3.js.map → chunk-SF45RQDX.js.map} +0 -0
  95. /package/dist/{chunk-LXVOZ2O6.js.map → chunk-T2AOOHDA.js.map} +0 -0
  96. /package/dist/{chunk-7K5Q6COX.js.map → chunk-TVVEYCNW.js.map} +0 -0
  97. /package/dist/{chunk-32RD3GIW.js.map → chunk-XVVEKF5I.js.map} +0 -0
  98. /package/dist/{chunk-TGN4M5MB.js.map → chunk-ZLINDOBG.js.map} +0 -0
@@ -1,14 +1,257 @@
1
1
  import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
2
5
  import test from "node:test";
3
6
 
4
7
  import { parseConfig } from "./config.js";
5
8
 
6
- test("parseConfig emitLegacyTools defaults to true and coerces config/env (issue #1427)", () => {
7
- // Default: legacy aliases on, for backward compatibility.
8
- assert.equal(parseConfig({}).emitLegacyTools, true);
9
- // `null` means "unset use default", consistent with the repo convention for
10
- // optional fields (e.g. taskModelChain: null → undefined). Not a hard error.
11
- assert.equal(parseConfig({ emitLegacyTools: null }).emitLegacyTools, true);
9
+ /**
10
+ * Run `body` with XDG_CONFIG_HOME pointing at a throwaway dir so the
11
+ * sticky-legacy `emitLegacyTools` default (#1550) never reads the real
12
+ * machine state. `withLegacyEntry` seeds one persisted connector file.
13
+ */
14
+ function withIsolatedConnectorsDir<T>(
15
+ withLegacyEntry: boolean,
16
+ body: () => T,
17
+ ): T {
18
+ const prev = process.env.XDG_CONFIG_HOME;
19
+ const root = mkdtempSync(path.join(tmpdir(), "remnic-config-test-"));
20
+ process.env.XDG_CONFIG_HOME = root;
21
+ try {
22
+ if (withLegacyEntry) {
23
+ const dir = path.join(root, "engram", ".engram-connectors", "connectors");
24
+ mkdirSync(dir, { recursive: true });
25
+ writeFileSync(path.join(dir, "codex-cli.json"), "{}\n");
26
+ }
27
+ return body();
28
+ } finally {
29
+ if (prev === undefined) delete process.env.XDG_CONFIG_HOME;
30
+ else process.env.XDG_CONFIG_HOME = prev;
31
+ rmSync(root, { recursive: true, force: true });
32
+ }
33
+ }
34
+
35
+ test("parseConfig emitLegacyTools sticky-legacy default (issue #1550)", () => {
36
+ // Fresh install (no legacy connector entries): canonical-only surface.
37
+ withIsolatedConnectorsDir(false, () => {
38
+ assert.equal(parseConfig({}).emitLegacyTools, false, "fresh install defaults false");
39
+ assert.equal(parseConfig({ emitLegacyTools: null }).emitLegacyTools, false);
40
+ });
41
+ // Existing install with a persisted legacy connector entry: aliases stay on.
42
+ withIsolatedConnectorsDir(true, () => {
43
+ assert.equal(parseConfig({}).emitLegacyTools, true, "legacy connector entry keeps aliases");
44
+ // Explicit opt-out still wins over the sticky evidence.
45
+ assert.equal(parseConfig({ emitLegacyTools: false }).emitLegacyTools, false);
46
+ assert.equal(parseConfig({ emitLegacyTools: "false" }).emitLegacyTools, false);
47
+ });
48
+ });
49
+
50
+ test("parseConfig emitLegacyTools raw-vs-effective: schema default does not block sticky legacy (#1550, PR #1593 review)", () => {
51
+ // Cursor Bugbot + chatgpt-codex-connector both flagged the same class: the
52
+ // OpenClaw SDK materializes JSON-schema defaults into `api.pluginConfig`
53
+ // BEFORE `parseConfig` runs, so a fresh install would arrive with
54
+ // `emitLegacyTools: false` already populated even though the operator never
55
+ // wrote the key. The original resolver treated any present value as
56
+ // operator-set, which short-circuited the sticky-legacy fallback to
57
+ // `hasLegacyConnectorEntries()` and broke upgrades with legacy connector
58
+ // entries on disk. Fix: parseConfig now takes an optional `rawOperatorConfig`
59
+ // argument (sourced from `loadPluginConfigFromFile`, the pre-defaults
60
+ // operator file). Resolvers check `rawOperatorConfig` first — if the key is
61
+ // absent there, the present value is treated as a schema default and the
62
+ // env / sticky-legacy fallback chain runs.
63
+ withIsolatedConnectorsDir(false, () => {
64
+ // Fresh install + schema-default false in the merged config: sticky
65
+ // legacy path should still run and resolve to false.
66
+ assert.equal(
67
+ parseConfig({ emitLegacyTools: false }, {}).emitLegacyTools,
68
+ false,
69
+ "fresh install with schema-default false still resolves false",
70
+ );
71
+ // Explicit operator opt-out (rawOperatorConfig has the key): false wins.
72
+ assert.equal(
73
+ parseConfig({ emitLegacyTools: false }, { emitLegacyTools: false })
74
+ .emitLegacyTools,
75
+ false,
76
+ "explicit operator opt-out via file is honored",
77
+ );
78
+ // When raw author wrote a real value but the merged configValue
79
+ // disagrees, configValue wins (matches the runtime-over-file spread in
80
+ // src/index.ts — chatgpt-codex-connector P2, PR #1593 round 4).
81
+ // coerceBooleanLikeOrThrow normalizes string "false" to boolean false.
82
+ assert.equal(
83
+ parseConfig({ emitLegacyTools: "false" }, { emitLegacyTools: "true" })
84
+ .emitLegacyTools,
85
+ false,
86
+ "merged value wins over raw value (runtime-over-file precedence)",
87
+ );
88
+ });
89
+ // Upgraded install with legacy connector JSON on disk: schema-default
90
+ // `false` in merged config MUST NOT mask the sticky-legacy fallback. This
91
+ // is the exact Cursor Bugbot scenario.
92
+ withIsolatedConnectorsDir(true, () => {
93
+ assert.equal(
94
+ parseConfig({ emitLegacyTools: false }, {}).emitLegacyTools,
95
+ true,
96
+ "upgraded install with legacy connector JSON keeps aliases on",
97
+ );
98
+ // Raw operator opt-out still wins over the sticky evidence.
99
+ assert.equal(
100
+ parseConfig({ emitLegacyTools: false }, { emitLegacyTools: false })
101
+ .emitLegacyTools,
102
+ false,
103
+ "raw operator opt-out overrides sticky-legacy",
104
+ );
105
+ // Raw operator opt-IN also wins.
106
+ assert.equal(
107
+ parseConfig({ emitLegacyTools: true }, {}).emitLegacyTools,
108
+ true,
109
+ "raw merged true is operator-set even with empty raw",
110
+ );
111
+ });
112
+ });
113
+
114
+ test("parseConfig namespaceCatalogEnabled raw-vs-effective: schema-default hardening (#1550 class hardening)", () => {
115
+ // Same-class hardening as emitLegacyTools: if a future schema revision flips
116
+ // namespaceCatalogEnabled's default to `false`, the resolver must still let
117
+ // the sticky / absent chain run unless the operator wrote the key. Today
118
+ // the schema default is `true`, so the bug doesn't manifest — but the helper
119
+ // now has the raw-vs-effective split, and this test pins the contract so a
120
+ // future schema flip can't reintroduce the bug class silently.
121
+ //
122
+ // Logic: when raw is missing the key, compare merged to the schema default
123
+ // (true). If merged equals the schema default, it's the materialized
124
+ // schema value — fall through to default. If merged differs (i.e. merged
125
+ // is `false`), it's runtime operator intent — honor it. Symmetric to
126
+ // resolveEmitLegacyTools, with the schema default inverted.
127
+ // Absent from both → schema default true (sticky chain returns true).
128
+ assert.equal(
129
+ parseConfig({}, {}).namespaceCatalogEnabled,
130
+ true,
131
+ "absent from both raw and merged -> true (schema default)",
132
+ );
133
+ // Merged `true` (schema default) with empty raw → fall through, return true.
134
+ assert.equal(
135
+ parseConfig({ namespaceCatalogEnabled: true }, {}).namespaceCatalogEnabled,
136
+ true,
137
+ "merged true (schema default) with empty raw falls through to default",
138
+ );
139
+ // Merged `false` (DIFFERENT from schema default) with empty raw → runtime
140
+ // opt-out intent — honor it, return false.
141
+ assert.equal(
142
+ parseConfig({ namespaceCatalogEnabled: false }, {}).namespaceCatalogEnabled,
143
+ false,
144
+ "merged false differs from schema default — runtime operator intent honored",
145
+ );
146
+ // Merged value wins over raw value when both are present — matches
147
+ // the runtime-over-file spread in src/index.ts.
148
+ assert.equal(
149
+ parseConfig({ namespaceCatalogEnabled: true }, { namespaceCatalogEnabled: false })
150
+ .namespaceCatalogEnabled,
151
+ true,
152
+ "merged true wins over raw false (runtime-over-file)",
153
+ );
154
+ assert.equal(
155
+ parseConfig({ namespaceCatalogEnabled: false }, { namespaceCatalogEnabled: true })
156
+ .namespaceCatalogEnabled,
157
+ false,
158
+ "merged false wins over raw true (runtime-over-file)",
159
+ );
160
+ });
161
+
162
+ test("parseConfig emitLegacyTools raw null is treated as absent (PR #1593 review round 2)", () => {
163
+ // Cursor Bugbot round 2: when raw has the key but its value is `null`
164
+ // (operator explicitly cleared it in openclaw.json), the old resolver
165
+ // threw via coerceBooleanLikeOrThrow. New behavior: treat null/undefined
166
+ // in raw as "absent" and fall through to merged / env / sticky-legacy.
167
+ withIsolatedConnectorsDir(false, () => {
168
+ // Fresh install: raw null + merged null + env absent + sticky false → false.
169
+ assert.equal(
170
+ parseConfig({ emitLegacyTools: null }, { emitLegacyTools: null }).emitLegacyTools,
171
+ false,
172
+ "fresh install with raw null resolves to false via sticky-legacy",
173
+ );
174
+ // Legacy install: raw null + sticky evidence → true.
175
+ });
176
+ withIsolatedConnectorsDir(true, () => {
177
+ assert.equal(
178
+ parseConfig({ emitLegacyTools: null }, { emitLegacyTools: null }).emitLegacyTools,
179
+ true,
180
+ "upgraded install with raw null resolves to true via sticky-legacy",
181
+ );
182
+ });
183
+ });
184
+
185
+ test("parseConfig emitLegacyTools runtime true overrides schema default (PR #1593 review round 2)", () => {
186
+ // Cursor Bugbot round 2: schema default is `false`. When raw is missing
187
+ // the key but merged carries `true` (runtime gateway set it), the old
188
+ // resolver dropped the runtime override as schema-default materialization.
189
+ // New behavior: if merged value differs from the schema default, treat
190
+ // it as runtime operator intent and honor it.
191
+ withIsolatedConnectorsDir(false, () => {
192
+ // Merged `true` with empty raw → runtime opt-in honored, even on a
193
+ // fresh install (no legacy connector entries).
194
+ assert.equal(
195
+ parseConfig({ emitLegacyTools: true }, {}).emitLegacyTools,
196
+ true,
197
+ "runtime true with empty raw treated as operator intent",
198
+ );
199
+ // Merged `false` (the schema default) with empty raw → sticky fallback.
200
+ assert.equal(
201
+ parseConfig({ emitLegacyTools: false }, {}).emitLegacyTools,
202
+ false,
203
+ "merged false (schema default) with empty raw falls through to sticky-legacy",
204
+ );
205
+ });
206
+ });
207
+
208
+ test("parseConfig defensive null rawOperatorConfig (PR #1593 review round 3)", () => {
209
+ // Cursor Bugbot + kilo-code-bot round 3: a JSON `null` on disk for the
210
+ // operator config block surfaces as `null` at the second argument (the
211
+ // loader's `as Record | undefined` cast previously hid this). Both
212
+ // resolvers now normalize `null` to `{}` so the `"key" in raw` check
213
+ // never throws and the schema-default-detection logic still runs.
214
+ withIsolatedConnectorsDir(false, () => {
215
+ // null raw + emitLegacyTools merged schema-default false → sticky-legacy
216
+ // fallback (no throw).
217
+ assert.equal(
218
+ parseConfig({ emitLegacyTools: false }, null as unknown as Record<string, unknown>).emitLegacyTools,
219
+ false,
220
+ "null raw with emitLegacyTools merged schema default falls through to sticky-legacy",
221
+ );
222
+ // null raw + emitLegacyTools merged true (runtime intent) → honored.
223
+ assert.equal(
224
+ parseConfig({ emitLegacyTools: true }, null as unknown as Record<string, unknown>).emitLegacyTools,
225
+ true,
226
+ "null raw with emitLegacyTools runtime true honored as operator intent",
227
+ );
228
+ // null raw + namespaceCatalogEnabled merged false → differs from schema
229
+ // default true → runtime intent → honored.
230
+ assert.equal(
231
+ parseConfig({ namespaceCatalogEnabled: false }, null as unknown as Record<string, unknown>).namespaceCatalogEnabled,
232
+ false,
233
+ "null raw with namespaceCatalogEnabled runtime false honored as operator intent",
234
+ );
235
+ // null raw + namespaceCatalogEnabled merged true (schema default) →
236
+ // equals schema default → fall through to return SCHEMA_DEFAULT.
237
+ assert.equal(
238
+ parseConfig({ namespaceCatalogEnabled: true }, null as unknown as Record<string, unknown>).namespaceCatalogEnabled,
239
+ true,
240
+ "null raw with namespaceCatalogEnabled schema default true preserved",
241
+ );
242
+ });
243
+ withIsolatedConnectorsDir(true, () => {
244
+ // null raw + emitLegacyTools merged false + legacy evidence →
245
+ // sticky-legacy returns true (upgraded install scenario).
246
+ assert.equal(
247
+ parseConfig({ emitLegacyTools: false }, null as unknown as Record<string, unknown>).emitLegacyTools,
248
+ true,
249
+ "null raw with legacy evidence keeps aliases on",
250
+ );
251
+ });
252
+ });
253
+
254
+ test("parseConfig emitLegacyTools coerces config/env (issue #1427)", () => {
12
255
  // Boolean + boolean-like string config values.
13
256
  assert.equal(parseConfig({ emitLegacyTools: false }).emitLegacyTools, false);
14
257
  assert.equal(parseConfig({ emitLegacyTools: "false" }).emitLegacyTools, false);
@@ -26,6 +269,12 @@ test("parseConfig emitLegacyTools defaults to true and coerces config/env (issue
26
269
  delete process.env.REMNIC_EMIT_LEGACY_TOOLS;
27
270
  process.env.ENGRAM_EMIT_LEGACY_TOOLS = "false";
28
271
  assert.equal(parseConfig({}).emitLegacyTools, false, "ENGRAM_ env fallback disables");
272
+ // Env "true" also wins over the fresh-install default (#1550).
273
+ delete process.env.ENGRAM_EMIT_LEGACY_TOOLS;
274
+ process.env.REMNIC_EMIT_LEGACY_TOOLS = "true";
275
+ withIsolatedConnectorsDir(false, () => {
276
+ assert.equal(parseConfig({}).emitLegacyTools, true, "env true wins over fresh-install default");
277
+ });
29
278
  } finally {
30
279
  if (prevRemnic === undefined) delete process.env.REMNIC_EMIT_LEGACY_TOOLS;
31
280
  else process.env.REMNIC_EMIT_LEGACY_TOOLS = prevRemnic;
@@ -1518,3 +1767,156 @@ test("parseConfig rejects invalid maintenance namespace fanout values", () => {
1518
1767
  /maintenance\.namespaceLockStaleMs must be a positive integer/,
1519
1768
  );
1520
1769
  });
1770
+
1771
+
1772
+ test("parseConfig runtime-over-file precedence (PR #1593 review round 4)", () => {
1773
+ // chatgpt-codex-connector P2: src/index.ts calls parseConfig with
1774
+ // { ...fileConfig, ...api.pluginConfig } where the spread means runtime
1775
+ // overrides file. The resolver must honor configValue (the merged
1776
+ // object) as authoritative when both raw and configValue are present.
1777
+ // raw is consulted only to detect "operator authored" vs "schema-default
1778
+ // materialization" — NOT to override configValue.
1779
+ //
1780
+ // Scenario: file says emitLegacyTools: false, runtime says
1781
+ // api.pluginConfig.emitLegacyTools: true. The merged configValue is
1782
+ // true; the resolver must honor the runtime override, not the file.
1783
+ withIsolatedConnectorsDir(false, () => {
1784
+ assert.equal(
1785
+ parseConfig(
1786
+ { emitLegacyTools: true }, // merged: runtime over file
1787
+ { emitLegacyTools: false }, // file wrote false
1788
+ ).emitLegacyTools,
1789
+ true,
1790
+ "runtime true overrides file false (rawOperatorConfig has the key)",
1791
+ );
1792
+ // Symmetric: file says true, runtime says false → merged false wins.
1793
+ assert.equal(
1794
+ parseConfig(
1795
+ { emitLegacyTools: false },
1796
+ { emitLegacyTools: true },
1797
+ ).emitLegacyTools,
1798
+ false,
1799
+ "runtime false overrides file true",
1800
+ );
1801
+ // Same precedence for namespaceCatalogEnabled.
1802
+ assert.equal(
1803
+ parseConfig(
1804
+ { namespaceCatalogEnabled: false }, // runtime override to false
1805
+ { namespaceCatalogEnabled: true }, // file set true (schema default)
1806
+ ).namespaceCatalogEnabled,
1807
+ false,
1808
+ "runtime false overrides file true for namespaceCatalogEnabled",
1809
+ );
1810
+ });
1811
+ // Sticky-legacy still works: merged false (schema default), raw empty
1812
+ // → fall through to env / sticky-legacy. With legacy connector JSON on
1813
+ // disk → returns true (the upgraded install scenario).
1814
+ withIsolatedConnectorsDir(true, () => {
1815
+ assert.equal(
1816
+ parseConfig(
1817
+ { emitLegacyTools: false },
1818
+ {}, // no file
1819
+ ).emitLegacyTools,
1820
+ true,
1821
+ "merged false with empty raw falls through to sticky-legacy (upgraded install)",
1822
+ );
1823
+ });
1824
+ });
1825
+
1826
+
1827
+ test("parseConfig null raw + runtime override honored (PR #1593 review round 5)", () => {
1828
+ // Cursor Bugbot + chatgpt-codex-connector P2 (round 5, flagged against
1829
+ // round-3 commit ee58f92f/34316d5e): when raw has the key with a
1830
+ // null/undefined value, the previous resolver skipped the merged
1831
+ // configValue entirely. The round-4 rewrite (6c3ca83e) fixed this via
1832
+ // the `configValue !== SCHEMA_DEFAULT` check — the resolver honors
1833
+ // configValue whenever it differs from the schema default, regardless
1834
+ // of whether raw authored the key.
1835
+ //
1836
+ // This test pins the contract so a future refactor cannot reintroduce
1837
+ // the bug.
1838
+ withIsolatedConnectorsDir(false, () => {
1839
+ // emitLegacyTools: file has null, runtime sets true → honored.
1840
+ assert.equal(
1841
+ parseConfig({ emitLegacyTools: true }, { emitLegacyTools: null }).emitLegacyTools,
1842
+ true,
1843
+ "null raw with runtime true overrides schema default false",
1844
+ );
1845
+ // emitLegacyTools: file has undefined, runtime sets true → honored.
1846
+ assert.equal(
1847
+ parseConfig({ emitLegacyTools: true }, { emitLegacyTools: undefined }).emitLegacyTools,
1848
+ true,
1849
+ "undefined raw with runtime true overrides schema default false",
1850
+ );
1851
+ // emitLegacyTools: file absent (key not in raw), runtime sets true → honored.
1852
+ assert.equal(
1853
+ parseConfig({ emitLegacyTools: true }, {}).emitLegacyTools,
1854
+ true,
1855
+ "absent raw with runtime true overrides schema default false",
1856
+ );
1857
+ // namespaceCatalogEnabled: file has null, runtime sets false → honored
1858
+ // (differs from schema default true).
1859
+ assert.equal(
1860
+ parseConfig({ namespaceCatalogEnabled: false }, { namespaceCatalogEnabled: null }).namespaceCatalogEnabled,
1861
+ false,
1862
+ "null raw with runtime false overrides schema default true",
1863
+ );
1864
+ // Sticky-legacy still works: merged false (schema default), raw empty →
1865
+ // fall through to sticky-legacy.
1866
+ assert.equal(
1867
+ parseConfig({ emitLegacyTools: false }, {}).emitLegacyTools,
1868
+ false,
1869
+ "schema-default false with empty raw falls through to sticky-legacy",
1870
+ );
1871
+ });
1872
+ });
1873
+
1874
+
1875
+ test("parseConfig schema-default-detection round-4 contract (round 8: runtimeSet gate reverted)", () => {
1876
+ // chatgpt-codex-connector P1 round 8 (PR #1593, src/index.ts:1348):
1877
+ // the round-7 runtimeSet gate was reverted because OpenClaw's loader
1878
+ // runs `applyDefaults: true` before exposing `api.pluginConfig`, so the
1879
+ // set of keys present there cannot reliably distinguish operator-authored
1880
+ // values from schema-default materialization. The resolver now relies
1881
+ // solely on the `configValue !== SCHEMA_DEFAULT` comparison (round-4
1882
+ // contract). This test pins that contract for both gates.
1883
+ //
1884
+ // The third arg `runtimeSet` is kept in the parseConfig signature for
1885
+ // API stability but no longer changes resolver behavior.
1886
+ withIsolatedConnectorsDir(true, () => {
1887
+ // Schema-default false with empty raw: the resolver falls through to
1888
+ // sticky-legacy (the round-4 contract). On an upgraded install, that
1889
+ // means true.
1890
+ assert.equal(
1891
+ parseConfig({ emitLegacyTools: false }, {}, new Set()).emitLegacyTools,
1892
+ true,
1893
+ "schema-default false with empty raw falls through to sticky-legacy (round-4 contract)",
1894
+ );
1895
+ // For namespaceCatalogEnabled: schema default is true. Runtime false
1896
+ // differs from default → honored as runtime intent regardless of
1897
+ // runtimeSet.
1898
+ assert.equal(
1899
+ parseConfig(
1900
+ { namespaceCatalogEnabled: false },
1901
+ {},
1902
+ new Set(),
1903
+ ).namespaceCatalogEnabled,
1904
+ false,
1905
+ "merged false (≠ schema default true) is runtime intent regardless of runtimeSet",
1906
+ );
1907
+ });
1908
+ withIsolatedConnectorsDir(false, () => {
1909
+ // Fresh install: same precedence.
1910
+ assert.equal(
1911
+ parseConfig({ emitLegacyTools: false }, {}, new Set()).emitLegacyTools,
1912
+ false,
1913
+ "fresh install, schema-default false with empty raw returns false",
1914
+ );
1915
+ // Runtime value differs from schema default: honored.
1916
+ assert.equal(
1917
+ parseConfig({ emitLegacyTools: true }, {}, new Set()).emitLegacyTools,
1918
+ true,
1919
+ "runtime true (≠ schema default false) honored",
1920
+ );
1921
+ });
1922
+ });
package/src/config.ts CHANGED
@@ -39,6 +39,11 @@ import { expandTildePath } from "./utils/path.js";
39
39
  // lives in connectors/coerce.ts (a tiny, dependency-free module) so neither
40
40
  // config.ts → connectors/index.ts nor the reverse circular import arises.
41
41
  import { coerceBool, coerceInstallExtension, coerceNumber } from "./connectors/coerce.js";
42
+ import { hasLegacyConnectorEntries } from "./connectors/paths.js";
43
+ import {
44
+ resolveEmitLegacyTools,
45
+ resolveNamespaceCatalogEnabled,
46
+ } from "./emit-legacy-tools.js";
42
47
  import { parseWearablesConfig } from "./wearables/config.js";
43
48
  import { parseCodingKnowledgeConfig } from "./coding/coding-knowledge-config.js";
44
49
  const DEFAULT_MEMORY_DIR = path.join(
@@ -613,59 +618,6 @@ function coerceBooleanLike(value: unknown): boolean | undefined {
613
618
  return undefined;
614
619
  }
615
620
 
616
- /**
617
- * Resolve the `emitLegacyTools` opt-out (issue #1427): config field wins, then
618
- * the REMNIC_/ENGRAM_ env var, then default true. A *present-but-malformed*
619
- * value fails fast rather than silently re-enabling legacy aliases — this knob
620
- * controls the advertised MCP `tools/list` surface, so a typo like
621
- * `emitLegacyTools=fales` must not be misread as `true` (gotcha #51).
622
- */
623
- function resolveEmitLegacyTools(configValue: unknown): boolean {
624
- const ACCEPTED = "true/false/1/0/yes/no/on/off";
625
- if (configValue !== undefined && configValue !== null) {
626
- const coerced = coerceBooleanLike(configValue);
627
- if (coerced === undefined) {
628
- throw new Error(
629
- `emitLegacyTools must be a boolean-like value (${ACCEPTED}); got ${JSON.stringify(configValue)}`,
630
- );
631
- }
632
- return coerced;
633
- }
634
- const envRaw =
635
- readEnvVar("REMNIC_EMIT_LEGACY_TOOLS") ?? readEnvVar("ENGRAM_EMIT_LEGACY_TOOLS");
636
- if (envRaw !== undefined) {
637
- const coerced = coerceBooleanLike(envRaw);
638
- if (coerced === undefined) {
639
- throw new Error(
640
- `REMNIC_EMIT_LEGACY_TOOLS must be a boolean-like value (${ACCEPTED}); got "${envRaw}"`,
641
- );
642
- }
643
- return coerced;
644
- }
645
- return true;
646
- }
647
-
648
- /**
649
- * Resolve the `namespaceCatalogEnabled` opt-out (issue #1499). A boolean-like
650
- * value ("false"/"0"/"no"/"off" etc.) is honored; a PRESENT but unrecognized
651
- * value ("flase", 2) is REJECTED rather than silently defaulting to enabled
652
- * (CLAUDE.md rule #51 — reject invalid input instead of silently defaulting),
653
- * mirroring `resolveEmitLegacyTools`. Defaults to enabled only when absent.
654
- */
655
- function resolveNamespaceCatalogEnabled(configValue: unknown): boolean {
656
- const ACCEPTED = "true/false/1/0/yes/no/on/off";
657
- if (configValue !== undefined && configValue !== null) {
658
- const coerced = coerceBooleanLike(configValue);
659
- if (coerced === undefined) {
660
- throw new Error(
661
- `namespaceCatalogEnabled must be a boolean-like value (${ACCEPTED}); got ${JSON.stringify(configValue)}`,
662
- );
663
- }
664
- return coerced;
665
- }
666
- return true;
667
- }
668
-
669
621
  function readNestedConfig(
670
622
  cfg: Record<string, unknown>,
671
623
  blockName: string,
@@ -1056,7 +1008,11 @@ function resolveMemoryOsPreset(value: unknown): MemoryOsPresetName | undefined {
1056
1008
  return MEMORY_OS_PRESET_ALIASES[normalized];
1057
1009
  }
1058
1010
 
1059
- export function parseConfig(raw: unknown): PluginConfig {
1011
+ export function parseConfig(
1012
+ raw: unknown,
1013
+ rawOperatorConfig?: Record<string, unknown> | null,
1014
+ runtimeSet?: ReadonlySet<string>,
1015
+ ): PluginConfig {
1060
1016
  const baseCfg =
1061
1017
  raw && typeof raw === "object" && !Array.isArray(raw)
1062
1018
  ? (raw as Record<string, unknown>)
@@ -3031,7 +2987,7 @@ export function parseConfig(raw: unknown): PluginConfig {
3031
2987
  // a PRESENT but unrecognized value ("flase", 2) is REJECTED rather than
3032
2988
  // silently defaulting to enabled (CLAUDE.md rule #51); default to enabled
3033
2989
  // only when the value is absent.
3034
- namespaceCatalogEnabled: resolveNamespaceCatalogEnabled(cfg.namespaceCatalogEnabled),
2990
+ namespaceCatalogEnabled: resolveNamespaceCatalogEnabled(cfg.namespaceCatalogEnabled, rawOperatorConfig, runtimeSet),
3035
2991
  // NOTE: namespace identifiers are intentionally NOT sanitized here — the
3036
2992
  // codebase rejects unsafe namespaces at the point of use (see
3037
2993
  // codex-materialize-runner and NamespaceStorageRouter / resolveNamespaceDir),
@@ -4100,7 +4056,7 @@ export function parseConfig(raw: unknown): PluginConfig {
4100
4056
  // Legacy MCP tool aliases opt-out (issue #1427). Config field wins; then
4101
4057
  // the REMNIC_/ENGRAM_ env var (gotcha #9); default true for back-compat.
4102
4058
  // Malformed values fail fast rather than silently defaulting (gotcha #51).
4103
- emitLegacyTools: resolveEmitLegacyTools(cfg.emitLegacyTools),
4059
+ emitLegacyTools: resolveEmitLegacyTools(cfg.emitLegacyTools, rawOperatorConfig, runtimeSet),
4104
4060
  // Codex citation parity (issue #379)
4105
4061
  citationsEnabled: cfg.citationsEnabled === true,
4106
4062
  citationsAutoDetect: cfg.citationsAutoDetect !== false,
@@ -16,6 +16,8 @@ import { launchProcessSync } from "../runtime/child-process.js";
16
16
  import { mergeEnv, readEnvVar, resolveHomeDir } from "../runtime/env.js";
17
17
  import { expandTildePath } from "../utils/path.js";
18
18
  import { coerceInstallExtension } from "./coerce.js";
19
+ import { getConnectorsDir, getRegistryPath } from "./paths.js";
20
+ export { getConnectorsDir, getRegistryPath } from "./paths.js";
19
21
 
20
22
  // Native memory artifact materialization for Codex CLI (#378). Surfaced here
21
23
  // so downstream callers can `import { materializeForNamespace } from "@remnic/core/connectors"`.
@@ -556,7 +558,6 @@ const BUILTIN_CONNECTORS: ConnectorManifest[] = [
556
558
 
557
559
  // ── Registry management ───────────────────────────────────────────────────
558
560
 
559
- const REGISTRY_DIR_NAME = ".engram-connectors";
560
561
  const CONNECTOR_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
561
562
 
562
563
  function isValidConnectorId(connectorId: unknown): connectorId is string {
@@ -572,13 +573,6 @@ function isConnectorManifest(value: unknown): value is ConnectorManifest {
572
573
  );
573
574
  }
574
575
 
575
- export function getRegistryPath(): string {
576
- const xdgConfigHome = readEnvVar("XDG_CONFIG_HOME");
577
- const configDir = xdgConfigHome
578
- ? path.join(xdgConfigHome, "engram")
579
- : path.join(resolveHomeDir(), ".config", "engram");
580
- return path.join(configDir, REGISTRY_DIR_NAME, "registry.json");
581
- }
582
576
 
583
577
  export function loadRegistry(): ConnectorRegistry {
584
578
  const regPath = getRegistryPath();
@@ -3058,13 +3052,6 @@ export function removeCodexMemoryExtension(
3058
3052
 
3059
3053
  // ── Helpers ───────────────────────────────────────────────────────────────────
3060
3054
 
3061
- function getConnectorsDir(): string {
3062
- const xdgConfigHome = readEnvVar("XDG_CONFIG_HOME");
3063
- const configDir = xdgConfigHome
3064
- ? path.join(xdgConfigHome, "engram")
3065
- : path.join(resolveHomeDir(), ".config", "engram");
3066
- return path.join(configDir, REGISTRY_DIR_NAME, "connectors");
3067
- }
3068
3055
 
3069
3056
  // ── WeClone proxy config helpers ───────────────────────────────────────────
3070
3057
  //
@@ -0,0 +1,50 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { readEnvVar, resolveHomeDir } from "../runtime/env.js";
5
+
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.
10
+ */
11
+ export const REGISTRY_DIR_NAME = ".engram-connectors";
12
+
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.
18
+ */
19
+ export function getConnectorsConfigRoot(): string {
20
+ const xdgConfigHome = readEnvVar("XDG_CONFIG_HOME");
21
+ return xdgConfigHome
22
+ ? path.join(xdgConfigHome, "engram")
23
+ : path.join(resolveHomeDir(), ".config", "engram");
24
+ }
25
+
26
+ /** Path of the connector registry manifest file. */
27
+ export function getRegistryPath(): string {
28
+ return path.join(getConnectorsConfigRoot(), REGISTRY_DIR_NAME, "registry.json");
29
+ }
30
+
31
+ /** Directory holding one `<connector-id>.json` per installed connector. */
32
+ export function getConnectorsDir(): string {
33
+ return path.join(getConnectorsConfigRoot(), REGISTRY_DIR_NAME, "connectors");
34
+ }
35
+
36
+ /**
37
+ * 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.
41
+ */
42
+ export function hasLegacyConnectorEntries(): boolean {
43
+ try {
44
+ return fs
45
+ .readdirSync(getConnectorsDir())
46
+ .some((name) => name.endsWith(".json"));
47
+ } catch {
48
+ return false;
49
+ }
50
+ }