@remnic/core 9.3.621 → 9.3.623
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-cli.js +28 -28
- package/dist/access-http.js +11 -11
- package/dist/access-mcp.js +10 -10
- package/dist/access-service.js +9 -9
- package/dist/briefing.js +6 -6
- package/dist/buffer-surprise.js +3 -3
- package/dist/calibration.js +2 -2
- package/dist/causal-consolidation.js +10 -10
- package/dist/{chunk-GLPBYIXN.js → chunk-2L54V4ZO.js} +3 -3
- package/dist/{chunk-PP2JH3GP.js → chunk-2UFQYU5F.js} +2 -2
- package/dist/{chunk-XAZOWLW4.js → chunk-3VONWEQB.js} +3 -3
- package/dist/{chunk-BF7ZRHH2.js → chunk-66SLUXKM.js} +2 -2
- package/dist/{chunk-3HPAPHUK.js → chunk-6KYMPV2O.js} +12 -11
- package/dist/chunk-6KYMPV2O.js.map +1 -0
- package/dist/{chunk-S53OYO3F.js → chunk-7VFZTJ7K.js} +2 -2
- package/dist/{chunk-4RR6ROTB.js → chunk-AGNBY3VG.js} +2 -2
- package/dist/{chunk-YEEAADCI.js → chunk-AYHXQR53.js} +2 -2
- package/dist/{chunk-IEUU7O4F.js → chunk-BNW5NJJH.js} +2 -2
- package/dist/{chunk-6GMPIJAZ.js → chunk-C3IW2F5Z.js} +2 -2
- package/dist/{chunk-4EWRLK3C.js → chunk-C4PZTWTG.js} +16 -16
- package/dist/{chunk-QVO4YOB7.js → chunk-D2B22JDF.js} +2 -2
- package/dist/{chunk-HA5SI4GK.js → chunk-FMGWXIES.js} +4 -4
- package/dist/{chunk-B6SU7YSE.js → chunk-GLWW3EJQ.js} +5 -5
- package/dist/{chunk-5BTCT236.js → chunk-GYTVOLNX.js} +2 -2
- package/dist/{chunk-IMA6GU4Y.js → chunk-H3PHZLMF.js} +3 -3
- package/dist/chunk-H3PHZLMF.js.map +1 -0
- package/dist/{chunk-TIPYPLLQ.js → chunk-I6UCUHLK.js} +4 -4
- package/dist/{chunk-2I2MDQIB.js → chunk-I74SUMNI.js} +2 -2
- package/dist/chunk-I74SUMNI.js.map +1 -0
- package/dist/{chunk-4H5ZJHEN.js → chunk-J6A3CX5N.js} +8 -3
- package/dist/{chunk-4H5ZJHEN.js.map → chunk-J6A3CX5N.js.map} +1 -1
- package/dist/{chunk-DEVUWMME.js → chunk-KGIGRNR6.js} +2 -2
- package/dist/{chunk-F4QTFIB4.js → chunk-KQFQ3IS5.js} +6 -6
- package/dist/{chunk-QSVPYQPG.js → chunk-LDXUBPMO.js} +2 -2
- package/dist/chunk-LDXUBPMO.js.map +1 -0
- package/dist/{chunk-JFEKNTX7.js → chunk-LN4YGHTM.js} +6 -2
- package/dist/chunk-LN4YGHTM.js.map +1 -0
- package/dist/{chunk-7XYTQGCC.js → chunk-MAV46GWQ.js} +2 -2
- package/dist/{chunk-KILOTVIF.js → chunk-MB5RSUW6.js} +2 -2
- package/dist/{chunk-WB3LYXC5.js → chunk-MON3LMO7.js} +3 -3
- package/dist/{chunk-APRRL26Q.js → chunk-O4UNM6OR.js} +2 -2
- package/dist/{chunk-AZDOWD2L.js → chunk-OZXVGYGZ.js} +2 -2
- package/dist/{chunk-WCYKT2DE.js → chunk-P4BC54KI.js} +23 -14
- package/dist/chunk-P4BC54KI.js.map +1 -0
- package/dist/{chunk-7MLB4NCL.js → chunk-PJGB7XRR.js} +6 -6
- package/dist/chunk-PJGB7XRR.js.map +1 -0
- package/dist/{chunk-DEPRLVLK.js → chunk-QFQQFX2H.js} +3 -3
- package/dist/{chunk-DEPRLVLK.js.map → chunk-QFQQFX2H.js.map} +1 -1
- package/dist/{chunk-QPD426WT.js → chunk-R3OQGYOU.js} +2 -2
- package/dist/{chunk-UZB5KHKX.js → chunk-RGMVMVMF.js} +2 -2
- package/dist/chunk-RGMVMVMF.js.map +1 -0
- package/dist/{chunk-O3U5BPUP.js → chunk-RKW6QR7W.js} +23 -19
- package/dist/chunk-RKW6QR7W.js.map +1 -0
- package/dist/{chunk-C6C7XVKG.js → chunk-UGEBPVNI.js} +3 -3
- package/dist/{chunk-4WMCPJWX.js → chunk-UQ7RN5HK.js} +22 -13
- package/dist/chunk-UQ7RN5HK.js.map +1 -0
- package/dist/{chunk-XQNPGNKK.js → chunk-W3BKVM64.js} +2 -2
- package/dist/{chunk-K5O2QY6T.js → chunk-YTWNKQ2G.js} +2 -2
- package/dist/chunk-YTWNKQ2G.js.map +1 -0
- package/dist/{chunk-2SGJY2UY.js → chunk-Z3CCEP6F.js} +3 -3
- package/dist/{chunk-THTIZJZA.js → chunk-ZJSZNTEI.js} +4 -4
- package/dist/{chunk-CIOMS6DI.js → chunk-ZZPIJPPD.js} +2 -2
- package/dist/chunking.js +1 -1
- package/dist/cli.js +23 -23
- package/dist/compounding/engine.js +6 -6
- package/dist/connectors/codex-materialize-runner.js +7 -7
- package/dist/connectors/codex-materialize.js +1 -1
- package/dist/connectors/index.js +7 -7
- package/dist/contradiction/index.js +2 -2
- package/dist/{contradiction-scan-GD7KUFWS.js → contradiction-scan-AZTGFMPY.js} +3 -3
- package/dist/entity-retrieval.js +6 -6
- package/dist/explicit-capture.js +1 -1
- package/dist/extraction-judge.js +3 -3
- package/dist/extraction.js +3 -3
- package/dist/fallback-llm.js +2 -2
- package/dist/identity-continuity.js +1 -1
- package/dist/index.js +45 -42
- package/dist/index.js.map +1 -1
- package/dist/json-extract.js +1 -1
- package/dist/lcm/engine.js +3 -3
- package/dist/lcm/index.js +3 -3
- package/dist/lcm/schema.js +2 -2
- package/dist/maintenance/memory-governance.js +6 -6
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +6 -6
- package/dist/maintenance/rebuild-memory-projection.js +7 -7
- package/dist/memory-projection-store.js +2 -2
- package/dist/namespaces/migrate.js +7 -7
- package/dist/namespaces/storage.js +6 -6
- package/dist/operator-toolkit.js +9 -9
- package/dist/orchestrator.js +25 -25
- package/dist/peers/index.js +1 -1
- package/dist/recall-planner-llm.js +2 -2
- package/dist/runtime/better-sqlite.d.ts +2 -1
- package/dist/runtime/better-sqlite.js +3 -1
- package/dist/schemas.d.ts +22 -22
- package/dist/semantic-chunking.js +2 -2
- package/dist/semantic-consolidation.js +8 -8
- package/dist/semantic-rule-promotion.js +6 -6
- package/dist/semantic-rule-verifier.js +6 -6
- package/dist/source-attribution.js +1 -1
- package/dist/storage.js +5 -5
- package/dist/summarizer.js +3 -3
- package/dist/temporal-supersession.js +1 -1
- package/dist/transfer/export-sqlite.js +2 -2
- package/dist/transfer/import-sqlite.js +2 -2
- package/dist/transfer/types.d.ts +12 -12
- package/dist/verified-recall.js +6 -6
- package/package.json +1 -1
- package/src/chunking.ts +38 -23
- package/src/coding/review-context.ts +7 -1
- package/src/connectors/codex-materialize.ts +6 -1
- package/src/explicit-capture.ts +7 -2
- package/src/identity-continuity.ts +7 -1
- package/src/json-extract.ts +4 -1
- package/src/orchestrator.ts +5 -1
- package/src/peers/profile-reasoner.ts +4 -1
- package/src/runtime/better-sqlite.test.ts +29 -0
- package/src/runtime/better-sqlite.ts +30 -8
- package/src/semantic-chunking.ts +32 -16
- package/src/semantic-consolidation.ts +4 -1
- package/src/source-attribution.test.ts +21 -0
- package/src/source-attribution.ts +17 -2
- package/src/storage.ts +11 -2
- package/src/temporal-supersession.ts +4 -1
- package/dist/chunk-2I2MDQIB.js.map +0 -1
- package/dist/chunk-3HPAPHUK.js.map +0 -1
- package/dist/chunk-4WMCPJWX.js.map +0 -1
- package/dist/chunk-7MLB4NCL.js.map +0 -1
- package/dist/chunk-IMA6GU4Y.js.map +0 -1
- package/dist/chunk-JFEKNTX7.js.map +0 -1
- package/dist/chunk-K5O2QY6T.js.map +0 -1
- package/dist/chunk-O3U5BPUP.js.map +0 -1
- package/dist/chunk-QSVPYQPG.js.map +0 -1
- package/dist/chunk-UZB5KHKX.js.map +0 -1
- package/dist/chunk-WCYKT2DE.js.map +0 -1
- /package/dist/{chunk-GLPBYIXN.js.map → chunk-2L54V4ZO.js.map} +0 -0
- /package/dist/{chunk-PP2JH3GP.js.map → chunk-2UFQYU5F.js.map} +0 -0
- /package/dist/{chunk-XAZOWLW4.js.map → chunk-3VONWEQB.js.map} +0 -0
- /package/dist/{chunk-BF7ZRHH2.js.map → chunk-66SLUXKM.js.map} +0 -0
- /package/dist/{chunk-S53OYO3F.js.map → chunk-7VFZTJ7K.js.map} +0 -0
- /package/dist/{chunk-4RR6ROTB.js.map → chunk-AGNBY3VG.js.map} +0 -0
- /package/dist/{chunk-YEEAADCI.js.map → chunk-AYHXQR53.js.map} +0 -0
- /package/dist/{chunk-IEUU7O4F.js.map → chunk-BNW5NJJH.js.map} +0 -0
- /package/dist/{chunk-6GMPIJAZ.js.map → chunk-C3IW2F5Z.js.map} +0 -0
- /package/dist/{chunk-4EWRLK3C.js.map → chunk-C4PZTWTG.js.map} +0 -0
- /package/dist/{chunk-QVO4YOB7.js.map → chunk-D2B22JDF.js.map} +0 -0
- /package/dist/{chunk-HA5SI4GK.js.map → chunk-FMGWXIES.js.map} +0 -0
- /package/dist/{chunk-B6SU7YSE.js.map → chunk-GLWW3EJQ.js.map} +0 -0
- /package/dist/{chunk-5BTCT236.js.map → chunk-GYTVOLNX.js.map} +0 -0
- /package/dist/{chunk-TIPYPLLQ.js.map → chunk-I6UCUHLK.js.map} +0 -0
- /package/dist/{chunk-DEVUWMME.js.map → chunk-KGIGRNR6.js.map} +0 -0
- /package/dist/{chunk-F4QTFIB4.js.map → chunk-KQFQ3IS5.js.map} +0 -0
- /package/dist/{chunk-7XYTQGCC.js.map → chunk-MAV46GWQ.js.map} +0 -0
- /package/dist/{chunk-KILOTVIF.js.map → chunk-MB5RSUW6.js.map} +0 -0
- /package/dist/{chunk-WB3LYXC5.js.map → chunk-MON3LMO7.js.map} +0 -0
- /package/dist/{chunk-APRRL26Q.js.map → chunk-O4UNM6OR.js.map} +0 -0
- /package/dist/{chunk-AZDOWD2L.js.map → chunk-OZXVGYGZ.js.map} +0 -0
- /package/dist/{chunk-QPD426WT.js.map → chunk-R3OQGYOU.js.map} +0 -0
- /package/dist/{chunk-C6C7XVKG.js.map → chunk-UGEBPVNI.js.map} +0 -0
- /package/dist/{chunk-XQNPGNKK.js.map → chunk-W3BKVM64.js.map} +0 -0
- /package/dist/{chunk-2SGJY2UY.js.map → chunk-Z3CCEP6F.js.map} +0 -0
- /package/dist/{chunk-THTIZJZA.js.map → chunk-ZJSZNTEI.js.map} +0 -0
- /package/dist/{chunk-CIOMS6DI.js.map → chunk-ZZPIJPPD.js.map} +0 -0
- /package/dist/{contradiction-scan-GD7KUFWS.js.map → contradiction-scan-AZTGFMPY.js.map} +0 -0
|
@@ -41,8 +41,19 @@ function requireBetterSqlite3Ctor(require: RuntimeRequire): BetterSqlite3Ctor {
|
|
|
41
41
|
return ctor;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Raw, unredacted message — used ONLY for internal classification (detecting a
|
|
45
|
+
// native-binding mismatch). Never returned to a user-facing surface, because it
|
|
46
|
+
// can contain absolute paths. Native-binding markers (better_sqlite3.node,
|
|
47
|
+
// NODE_MODULE_VERSION, "was compiled against a different Node.js version") live
|
|
48
|
+
// in error.message, so message text is sufficient and we never read .stack.
|
|
49
|
+
function rawErrorMessage(error: unknown): string {
|
|
50
|
+
return error instanceof Error ? error.message : String(error ?? "");
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
export function isLikelyBetterSqlite3NativeBindingError(error: unknown): boolean {
|
|
45
|
-
|
|
54
|
+
// Classify on the RAW message so redaction can't strip detection markers
|
|
55
|
+
// (e.g. the path containing "better_sqlite3.node").
|
|
56
|
+
const detail = rawErrorMessage(error);
|
|
46
57
|
return (
|
|
47
58
|
detail.includes("Could not locate the bindings file") ||
|
|
48
59
|
detail.includes("better_sqlite3.node") ||
|
|
@@ -53,7 +64,7 @@ export function isLikelyBetterSqlite3NativeBindingError(error: unknown): boolean
|
|
|
53
64
|
}
|
|
54
65
|
|
|
55
66
|
function unavailableError(error: unknown): Error {
|
|
56
|
-
const detail =
|
|
67
|
+
const detail = displayErrorDetail(error);
|
|
57
68
|
const nativeBindingHint = isLikelyBetterSqlite3NativeBindingError(error)
|
|
58
69
|
? " This usually means the better-sqlite3 native binding was not compiled for this Node.js/platform combination. " +
|
|
59
70
|
"Run `node scripts/ensure-better-sqlite3.mjs` from the Remnic install directory, or run " +
|
|
@@ -67,10 +78,21 @@ function unavailableError(error: unknown): Error {
|
|
|
67
78
|
);
|
|
68
79
|
}
|
|
69
80
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
// Sanitized, user-facing error detail. This string becomes the message of the
|
|
82
|
+
// Error thrown by unavailableError(), which propagates to user-facing surfaces
|
|
83
|
+
// (HTTP error bodies, MCP tool errors — access-http.ts / access-mcp.ts return
|
|
84
|
+
// err.message). We must not leak server internals (CodeQL js/stack-trace-exposure):
|
|
85
|
+
// - error.stack is never read.
|
|
86
|
+
// We deliberately surface only the error's class name and Node error code —
|
|
87
|
+
// never the raw message. Node module-load failures embed absolute server paths
|
|
88
|
+
// directly in error.message (the "Require stack:" block, and unquoted native
|
|
89
|
+
// loader paths that may even contain spaces), which no regex can redact
|
|
90
|
+
// reliably. The error code (MODULE_NOT_FOUND, ERR_DLOPEN_FAILED, …) is a stable,
|
|
91
|
+
// path-free identifier that, together with the native-binding hint, is enough
|
|
92
|
+
// for a user to act on. The full original error stays on the `cause` chain and
|
|
93
|
+
// is logged with its stack elsewhere.
|
|
94
|
+
export function displayErrorDetail(error: unknown): string {
|
|
95
|
+
if (!(error instanceof Error)) return "";
|
|
96
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
97
|
+
return typeof code === "string" && code.length > 0 ? `${error.name} (${code})` : error.name;
|
|
76
98
|
}
|
package/src/semantic-chunking.ts
CHANGED
|
@@ -185,25 +185,41 @@ export function findLocalMinima(
|
|
|
185
185
|
* Preserves punctuation with the preceding sentence.
|
|
186
186
|
*/
|
|
187
187
|
function splitSentences(text: string): string[] {
|
|
188
|
+
// Linear character scan instead of a regex. Every regex form of this split is
|
|
189
|
+
// either polynomial (CodeQL js/polynomial-redos) or — once bounded/anchored to
|
|
190
|
+
// satisfy CodeQL — mishandles long runs or interior punctuation (a global
|
|
191
|
+
// match drops a skipped prefix; a sticky match stops at the first non-boundary
|
|
192
|
+
// `.`, e.g. "v1.2.3" / "example.com", returning the whole document as one
|
|
193
|
+
// sentence and bypassing chunking). The scan is O(n), drops nothing, and
|
|
194
|
+
// handles interior punctuation correctly; normal prose splits identically to
|
|
195
|
+
// the previous /[^.!?]*[.!?]+(?:\s+|$)/g form.
|
|
188
196
|
const sentences: string[] = [];
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
197
|
+
let start = 0;
|
|
198
|
+
for (let i = 0; i < text.length; i++) {
|
|
199
|
+
const ch = text[i];
|
|
200
|
+
if (ch !== "." && ch !== "!" && ch !== "?") continue;
|
|
201
|
+
let end = i;
|
|
202
|
+
while (end + 1 < text.length) {
|
|
203
|
+
const n = text[end + 1];
|
|
204
|
+
if (n !== "." && n !== "!" && n !== "?") break;
|
|
205
|
+
end++;
|
|
206
|
+
}
|
|
207
|
+
const after = text[end + 1];
|
|
208
|
+
// A real boundary only if the terminator run ends the string or is followed
|
|
209
|
+
// by whitespace. Interior punctuation (no following whitespace) is left in
|
|
210
|
+
// place and the scan continues.
|
|
211
|
+
if (after === undefined || /\s/.test(after)) {
|
|
212
|
+
const sentence = text.slice(start, end + 1).trim();
|
|
213
|
+
if (sentence.length > 0) sentences.push(sentence);
|
|
214
|
+
start = end + 1;
|
|
203
215
|
}
|
|
216
|
+
i = end;
|
|
204
217
|
}
|
|
205
|
-
|
|
206
|
-
|
|
218
|
+
if (start < text.length) {
|
|
219
|
+
const remaining = text.slice(start).trim();
|
|
220
|
+
if (remaining.length > 0) sentences.push(remaining);
|
|
221
|
+
}
|
|
222
|
+
return sentences;
|
|
207
223
|
}
|
|
208
224
|
|
|
209
225
|
// ---------------------------------------------------------------------------
|
|
@@ -289,7 +289,10 @@ export function parseOperatorAwareConsolidationResponse(
|
|
|
289
289
|
if (trimmed.length === 0) return fallback;
|
|
290
290
|
|
|
291
291
|
// Strip a fenced code block if present.
|
|
292
|
-
|
|
292
|
+
// Dropped the \s* groups around the lazy body (they overlapped it and
|
|
293
|
+
// backtracked polynomially — CodeQL js/polynomial-redos). Input is already
|
|
294
|
+
// trimmed and fenced[1] is trimmed below, so matches are identical.
|
|
295
|
+
const fenced = /^```(?:json)?([\s\S]*?)```$/u.exec(trimmed);
|
|
293
296
|
const payload = fenced ? fenced[1].trim() : trimmed;
|
|
294
297
|
|
|
295
298
|
// Find a balanced brace-delimited JSON object that has an `operator`
|
|
@@ -1431,3 +1431,24 @@ test("hasCitationForTemplate: multi-separator template detects citation when int
|
|
|
1431
1431
|
"multi-colon citation where intermediate value contains the separator should be detected",
|
|
1432
1432
|
);
|
|
1433
1433
|
});
|
|
1434
|
+
|
|
1435
|
+
test("citation matcher resists polynomial ReDoS on hostile unterminated input (CodeQL js/polynomial-redos)", () => {
|
|
1436
|
+
// A "[Source:" with a long run of whitespace and no closing "]" is the
|
|
1437
|
+
// backtracking trigger for the old /\[Source:\s*([^\]\n]+?)\]/ pattern.
|
|
1438
|
+
const hostile = `prefix [Source:${" ".repeat(100000)}no closing bracket`;
|
|
1439
|
+
const start = process.hrtime.bigint();
|
|
1440
|
+
const detected = hasCitation(hostile);
|
|
1441
|
+
const parsed = parseAllCitations(hostile);
|
|
1442
|
+
const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
|
|
1443
|
+
|
|
1444
|
+
// No complete citation token (no closing "]"), so nothing should be detected.
|
|
1445
|
+
assert.equal(detected, false);
|
|
1446
|
+
assert.deepEqual(parsed, []);
|
|
1447
|
+
// Linear scan: completes near-instantly. Generous bound guards against the
|
|
1448
|
+
// quadratic blowup the old regex exhibited (seconds-to-minutes on this input).
|
|
1449
|
+
assert.ok(elapsedMs < 1000, `citation scan took ${elapsedMs.toFixed(1)}ms (expected < 1000ms)`);
|
|
1450
|
+
|
|
1451
|
+
// A well-formed citation with surrounding whitespace is still parsed correctly.
|
|
1452
|
+
const valid = parseAllCitations("body [Source: doc-1 , 2026-06-08 ]");
|
|
1453
|
+
assert.equal(valid.length, 1);
|
|
1454
|
+
});
|
|
@@ -71,7 +71,22 @@ export interface ParsedCitation {
|
|
|
71
71
|
* the text. Kept as a getter factory so callers do not share regex state.
|
|
72
72
|
*/
|
|
73
73
|
function defaultCitationMatcher(): RegExp {
|
|
74
|
-
|
|
74
|
+
// Bounded repetition {1,1024} instead of + so the match cannot backtrack
|
|
75
|
+
// polynomially over hostile memory text (CodeQL js/polynomial-redos). A real
|
|
76
|
+
// citation is far shorter than 1024 chars, so this is behavior-preserving for
|
|
77
|
+
// any genuine [Source: …] block; only pathological/oversized input is excluded.
|
|
78
|
+
return /\[Source:([^\]\n]{1,1024})\]/gi;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Linear trailing-whitespace trim. Replaces text.replace(/\s+$/u, ""), whose
|
|
82
|
+
// anchored quantifier backtracks polynomially on long inputs (CodeQL
|
|
83
|
+
// js/polynomial-redos). Matches the exact \s (with u flag) semantics one char
|
|
84
|
+
// at a time, so the trailing-content preservation logic in attachCitation is
|
|
85
|
+
// unaffected.
|
|
86
|
+
function trimTrailingWhitespace(text: string): string {
|
|
87
|
+
let end = text.length;
|
|
88
|
+
while (end > 0 && /\s/u.test(text[end - 1]!)) end--;
|
|
89
|
+
return text.slice(0, end);
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
/**
|
|
@@ -444,7 +459,7 @@ export function attachCitation(
|
|
|
444
459
|
): string {
|
|
445
460
|
if (typeof text !== "string") return text as unknown as string;
|
|
446
461
|
if (hasCitationForTemplate(text, template)) return text;
|
|
447
|
-
const trimmedEnd = text
|
|
462
|
+
const trimmedEnd = trimTrailingWhitespace(text);
|
|
448
463
|
if (trimmedEnd.length === 0) return text;
|
|
449
464
|
const citation = formatCitation(ctx, template);
|
|
450
465
|
// Preserve any trailing newline that callers rely on for markdown rendering.
|
package/src/storage.ts
CHANGED
|
@@ -1905,7 +1905,12 @@ export function parseEntityFile(
|
|
|
1905
1905
|
break;
|
|
1906
1906
|
case "connected to": {
|
|
1907
1907
|
// Format: [[target-entity]] — relationship label
|
|
1908
|
-
|
|
1908
|
+
// Drop the \s* after the dash and let (.+) capture the rest (trimmed
|
|
1909
|
+
// below). This removes the \s*/(.+) overlap that backtracks polynomially
|
|
1910
|
+
// (CodeQL js/polynomial-redos) while staying exactly equivalent to the
|
|
1911
|
+
// original /…\s*[—–-]\s*(.+)$/ — including whitespace-only labels, which
|
|
1912
|
+
// still match and trim to "" (unlike a \S-anchored capture).
|
|
1913
|
+
const relMatch = bullet.match(/^\[\[([^\]]+)\]\]\s*[—–-](.+)$/);
|
|
1909
1914
|
if (relMatch) {
|
|
1910
1915
|
relationships.push({ target: relMatch[1].trim(), label: relMatch[2].trim() });
|
|
1911
1916
|
}
|
|
@@ -1913,7 +1918,11 @@ export function parseEntityFile(
|
|
|
1913
1918
|
}
|
|
1914
1919
|
case "activity": {
|
|
1915
1920
|
// Format: YYYY-MM-DD: note
|
|
1916
|
-
|
|
1921
|
+
// Drop the \s* after the colon and let (.+) capture the rest (trimmed
|
|
1922
|
+
// below): removes the \s*/(.+) overlap (CodeQL js/polynomial-redos) and
|
|
1923
|
+
// stays exactly equivalent to the original, including whitespace-only
|
|
1924
|
+
// notes which still match and trim to "".
|
|
1925
|
+
const actMatch = bullet.match(/^(\d{4}-\d{2}-\d{2}):(.+)$/);
|
|
1917
1926
|
if (actMatch) {
|
|
1918
1927
|
activity.push({ date: actMatch[1], note: actMatch[2].trim() });
|
|
1919
1928
|
}
|
|
@@ -37,7 +37,10 @@ export function normalizeSupersessionKey(raw: string): string {
|
|
|
37
37
|
.trim()
|
|
38
38
|
.toLowerCase()
|
|
39
39
|
.replace(/[\s\-]+/g, "-")
|
|
40
|
-
|
|
40
|
+
// The previous line already collapsed runs to single hyphens, so ^-|-$ is
|
|
41
|
+
// equivalent to ^-+|-+$ here and drops the anchored quantifier flagged by
|
|
42
|
+
// CodeQL js/polynomial-redos.
|
|
43
|
+
.replace(/^-|-$/g, "");
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
/**
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/peers/profile-reasoner.ts"],"sourcesContent":["/**\n * Peer profile reasoner — issue #679 PR 2/5.\n *\n * Pure async function that, for each peer:\n *\n * 1. Reads recent interaction-log entries via `readPeerInteractionLog`.\n * 2. Calls an injected LLM client (same chat shape as\n * `FallbackLlmClient.chatCompletion`) to derive 0..N profile-field\n * proposals with provenance `{observedAt, signal, sourceSessionId,\n * note}`.\n * 3. Merges the proposals into the peer's existing `PeerProfile`,\n * appending provenance entries (never replacing existing\n * provenance — the reasoner is additive by design so the operator\n * retains the full audit trail).\n * 4. Writes via `writePeerProfile`.\n *\n * Gating is handled in two layers:\n *\n * - The orchestrator wires the call behind the\n * `peerProfileReasonerEnabled` config flag (default `false` —\n * opt-in per Gotcha #30/#48). The reasoner ALSO short-circuits\n * when `options.enabled !== true`, so direct callers can't\n * accidentally bypass the flag.\n * - Per-peer, the `peerProfileReasonerMinInteractions` threshold\n * skips peers whose log has fewer entries since the last reasoner\n * run than required.\n *\n * The reasoner is intentionally storage-agnostic — it accepts an LLM\n * client by interface (`PeerProfileReasonerLlm`) so tests can mock the\n * call and the orchestrator can inject either the gateway client or\n * a fast local model. No direct OpenAI imports here.\n */\n\nimport {\n appendInteractionLog,\n listPeers,\n readPeerInteractionLog,\n readPeerProfile,\n writePeerProfile,\n} from \"./storage.js\";\nimport type {\n Peer,\n PeerInteractionLogEntry,\n PeerProfile,\n PeerProfileFieldProvenance,\n} from \"./types.js\";\nimport { PEER_ID_PATTERN } from \"./types.js\";\n\n// ──────────────────────────────────────────────────────────────────────\n// Types\n// ──────────────────────────────────────────────────────────────────────\n\n/**\n * Minimal chat-completion contract the reasoner depends on. Matches\n * `FallbackLlmClient.chatCompletion` so the orchestrator can pass it\n * through directly. Tests inject a mock that returns canned strings.\n *\n * Returning `null` means the LLM is unavailable / failed — the\n * reasoner treats that as \"no proposals for this peer\" rather than an\n * error so a flaky LLM never aborts the whole pass.\n */\nexport interface PeerProfileReasonerLlm {\n chatCompletion(\n messages: Array<{ role: \"system\" | \"user\" | \"assistant\"; content: string }>,\n options?: { temperature?: number; maxTokens?: number; timeoutMs?: number },\n ): Promise<{ content: string } | null>;\n}\n\n/**\n * One LLM-proposed profile-field update.\n *\n * `value` is the new markdown string to set under `field`. The\n * provenance entry the LLM emits travels alongside it; the reasoner\n * does NOT trust the LLM's `observedAt` — it always overwrites with\n * the run's `now` timestamp so provenance can never claim future or\n * past observation timestamps the operator didn't witness.\n */\nexport interface PeerProfileReasonerProposal {\n /** Stable section key, e.g. \"communication_style\". */\n readonly field: string;\n /** Markdown value to set under that key. */\n readonly value: string;\n /**\n * Short label for the signal that justified the inference,\n * e.g. \"explicit_preference\", \"tool_pattern\", \"topic_recurrence\".\n */\n readonly signal: string;\n /** Optional free-form note explaining the inference. */\n readonly note?: string;\n /**\n * Originating session id, when the LLM can attribute the inference\n * to a specific log line. Reasoner clamps this to a value that\n * actually appeared in the log window so the LLM can't hallucinate.\n */\n readonly sourceSessionId?: string;\n}\n\nexport interface PeerProfileReasonerOptions {\n /** Memory directory containing the peers/ subtree. */\n readonly memoryDir: string;\n /**\n * Master gate. When `false` (the default the orchestrator passes\n * when the config flag is off), the reasoner is a no-op and\n * returns an empty result. Direct callers must explicitly pass\n * `true` so the gate can never be defaulted ON by accident\n * (Gotcha #48 — least-privileged default).\n */\n readonly enabled: boolean;\n /** Injected LLM client. Required when `enabled === true`. */\n readonly llm?: PeerProfileReasonerLlm;\n /** Model name to log for telemetry; not used to dispatch. */\n readonly model?: string;\n /**\n * Minimum new interaction-log entries since last reasoner run\n * before this peer is processed. Peers below the threshold are\n * skipped with `reason: \"below_min_interactions\"`.\n */\n readonly minInteractions: number;\n /**\n * Hard cap on profile fields the reasoner will accept across all\n * peers in a single run. Tracked in insertion order: once the cap\n * is reached, subsequent proposals are dropped with\n * `dropped_due_to_cap` in the per-peer result. Use to bound LLM\n * cost and reviewer load per pass.\n */\n readonly maxFieldsPerRun: number;\n /**\n * Optional restriction to specific peer ids. When omitted, the\n * reasoner enumerates the entire peer registry via `listPeers`.\n */\n readonly peerIds?: ReadonlyArray<string>;\n /**\n * Maximum number of recent log entries to feed the LLM per peer.\n * Defaults to 50. Bounded so a runaway log can't blow the prompt.\n */\n readonly maxLogEntriesPerPeer?: number;\n /**\n * Reasoner run timestamp. Defaults to `new Date()` at call time.\n * Tests inject a deterministic clock; the orchestrator passes\n * `new Date()` so provenance entries reflect actual wall time.\n */\n readonly now?: Date;\n /**\n * Optional logger; defaults to a no-op so the reasoner stays\n * silent in unit tests. The orchestrator wires its `log` here so\n * runs surface in the gateway log under the\n * `[peer-profile-reasoner]` prefix.\n */\n readonly log?: {\n debug?: (msg: string) => void;\n info?: (msg: string) => void;\n warn?: (msg: string) => void;\n };\n /**\n * Whether to append a `peer_profile_reasoner_run` entry to the\n * peer's interaction log when the reasoner emits at least one\n * field for that peer. Defaults to `true`. Disable in tests that\n * want to assert the log was untouched.\n */\n readonly appendRunMarkerToLog?: boolean;\n /**\n * Optional abort signal. The reasoner checks between peers and\n * returns the partial result if cancelled mid-run.\n */\n readonly signal?: AbortSignal;\n}\n\nexport interface PeerProfileReasonerPeerResult {\n readonly peerId: string;\n readonly status:\n | \"processed\"\n | \"skipped_below_min_interactions\"\n | \"skipped_no_log\"\n | \"skipped_disabled\"\n | \"skipped_no_llm\"\n | \"skipped_llm_unavailable\"\n | \"skipped_invalid_proposal\"\n | \"skipped_cap_reached\"\n | \"skipped_aborted\"\n | \"error\";\n /** Number of fields actually applied to the peer's profile. */\n readonly fieldsApplied: number;\n /** Number of proposals dropped because the per-run cap was hit. */\n readonly droppedDueToCap: number;\n /** Set of field keys applied; useful for tests and telemetry. */\n readonly fields: ReadonlyArray<string>;\n /** Error message, when `status === \"error\"`. */\n readonly error?: string;\n}\n\nexport interface PeerProfileReasonerResult {\n readonly peersConsidered: number;\n readonly peersProcessed: number;\n readonly fieldsApplied: number;\n readonly perPeer: ReadonlyArray<PeerProfileReasonerPeerResult>;\n}\n\n// ──────────────────────────────────────────────────────────────────────\n// Prompt + parser (pure, exported for tests)\n// ──────────────────────────────────────────────────────────────────────\n\n/**\n * Build the user-facing reasoner prompt. The system message carries\n * the strict-JSON instruction; this function emits the user message\n * with the peer context and the recent log slice.\n *\n * The prompt is intentionally schema-prescriptive — sibling modules\n * (`semantic-consolidation.ts`, `extraction-judge.ts`) demonstrated\n * that letting the LLM improvise field names produces unstable\n * profiles across runs.\n */\nexport function buildPeerProfileReasonerPrompt(input: {\n peer: Peer;\n existingProfile: PeerProfile | null;\n log: ReadonlyArray<PeerInteractionLogEntry>;\n maxFields: number;\n}): string {\n const existingFields = input.existingProfile\n ? Object.keys(input.existingProfile.fields)\n : [];\n const logBlock = input.log\n .map((e) => {\n const session = e.sessionId ? ` session=${e.sessionId}` : \"\";\n return `- [${e.timestamp}] (${e.kind})${session} ${e.summary}`;\n })\n .join(\"\\n\");\n return [\n `You are an async peer-profile reasoner. Your job is to read recent interaction-log entries for one peer and propose 0..${input.maxFields} profile-field updates.`,\n \"\",\n `Peer:`,\n ` id: ${input.peer.id}`,\n ` kind: ${input.peer.kind}`,\n ` displayName: ${input.peer.displayName}`,\n \"\",\n `Existing profile field keys (preserve names when proposing updates that refine an existing field): ${existingFields.length > 0 ? existingFields.join(\", \") : \"(none yet)\"}`,\n \"\",\n `Recent interaction log (oldest first):`,\n logBlock.length > 0 ? logBlock : \"(no entries)\",\n \"\",\n `Output a single JSON object: {\"proposals\": [{\"field\": \"<stable_key>\", \"value\": \"<markdown>\", \"signal\": \"<short_label>\", \"note\": \"<optional>\", \"sourceSessionId\": \"<optional>\"}]}.`,\n \"\",\n `Rules:`,\n `1. Only propose fields supported by evidence in the log. Do not invent.`,\n `2. Keys are short snake_case (e.g. \"communication_style\", \"tool_patterns\").`,\n `3. value is markdown. signal is a short label like \"explicit_preference\" or \"topic_recurrence\".`,\n `4. Omit fields you can't justify. Empty proposals array is valid.`,\n `5. Output JSON ONLY — no prose before or after.`,\n ].join(\"\\n\");\n}\n\n/**\n * Parse the LLM response. Tolerates a fenced code block wrapper.\n * Returns an empty array on any malformed payload — the contract is\n * that flaky LLM output silently produces zero proposals rather than\n * surfacing an error to the caller.\n *\n * Exported so unit tests can verify parser behavior without spinning\n * up the full reasoner.\n */\nexport function parsePeerProfileReasonerResponse(\n raw: string,\n): PeerProfileReasonerProposal[] {\n if (typeof raw !== \"string\" || raw.trim() === \"\") return [];\n const trimmed = raw.trim();\n const fenced = /^```(?:json)?\\s*([\\s\\S]*?)```\\s*$/u.exec(trimmed);\n const payload = fenced ? fenced[1].trim() : trimmed;\n let parsed: unknown;\n try {\n parsed = JSON.parse(payload);\n } catch {\n return [];\n }\n // Gotcha #18: JSON.parse('null') succeeds. Reject non-objects.\n if (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) {\n return [];\n }\n const obj = parsed as { proposals?: unknown };\n if (!Array.isArray(obj.proposals)) return [];\n const out: PeerProfileReasonerProposal[] = [];\n // Gotcha — drop prototype-pollution keys at the field-name layer.\n const RESERVED_KEYS = new Set([\"__proto__\", \"constructor\", \"prototype\"]);\n for (const item of obj.proposals) {\n if (typeof item !== \"object\" || item === null || Array.isArray(item)) continue;\n const r = item as Record<string, unknown>;\n if (typeof r.field !== \"string\" || r.field.trim() === \"\") continue;\n if (RESERVED_KEYS.has(r.field)) continue;\n if (typeof r.value !== \"string\" || r.value.trim() === \"\") continue;\n if (typeof r.signal !== \"string\" || r.signal.trim() === \"\") continue;\n const proposal: PeerProfileReasonerProposal = {\n field: r.field,\n value: r.value,\n signal: r.signal,\n ...(typeof r.note === \"string\" && r.note.length > 0 ? { note: r.note } : {}),\n ...(typeof r.sourceSessionId === \"string\" && r.sourceSessionId.length > 0\n ? { sourceSessionId: r.sourceSessionId }\n : {}),\n };\n out.push(proposal);\n }\n return out;\n}\n\n// ──────────────────────────────────────────────────────────────────────\n// Reasoner core\n// ──────────────────────────────────────────────────────────────────────\n\nconst SYSTEM_MESSAGE =\n 'You are a peer-profile reasoner. Output ONLY a JSON object of the form {\"proposals\":[{\"field\":\"...\",\"value\":\"...\",\"signal\":\"...\",\"note\":\"...\",\"sourceSessionId\":\"...\"}]}. No prose, no fenced code block, no commentary.';\n\nconst RUN_MARKER_KIND = \"peer_profile_reasoner_run\";\n\n/**\n * Find the most recent reasoner-run marker timestamp in the log.\n * Used to count \"interactions since last run\" so the threshold\n * gate doesn't keep firing on the same dormant log forever.\n */\nfunction lastRunTimestamp(\n log: ReadonlyArray<PeerInteractionLogEntry>,\n): string | undefined {\n let latest: string | undefined;\n for (const entry of log) {\n if (entry.kind !== RUN_MARKER_KIND) continue;\n if (latest === undefined || entry.timestamp > latest) {\n latest = entry.timestamp;\n }\n }\n return latest;\n}\n\nfunction noopLogger(): NonNullable<PeerProfileReasonerOptions[\"log\"]> {\n return { debug: () => {}, info: () => {}, warn: () => {} };\n}\n\n/**\n * Run the reasoner across all (or the requested subset of) peers.\n *\n * Always returns a `PeerProfileReasonerResult` — never throws to the\n * caller — so the orchestrator can wire it as a best-effort\n * post-consolidation hook (Gotcha #13).\n */\nexport async function runPeerProfileReasoner(\n options: PeerProfileReasonerOptions,\n): Promise<PeerProfileReasonerResult> {\n const log = {\n debug: options.log?.debug ?? noopLogger().debug!,\n info: options.log?.info ?? noopLogger().info!,\n warn: options.log?.warn ?? noopLogger().warn!,\n };\n const result: {\n peersConsidered: number;\n peersProcessed: number;\n fieldsApplied: number;\n perPeer: PeerProfileReasonerPeerResult[];\n } = {\n peersConsidered: 0,\n peersProcessed: 0,\n fieldsApplied: 0,\n perPeer: [],\n };\n // Disabled flag is the master gate. Defaults to false in callers'\n // config; we additionally require strict `=== true` here so a\n // stray \"true\" string doesn't silently flip the flag (Gotcha #36).\n if (options.enabled !== true) {\n log.debug(\"[peer-profile-reasoner] disabled — no-op\");\n return result;\n }\n if (!options.llm) {\n log.warn(\"[peer-profile-reasoner] no LLM client supplied — skipping run\");\n return result;\n }\n const minInteractions = Number.isFinite(options.minInteractions)\n ? Math.max(0, Math.floor(options.minInteractions))\n : 0;\n const maxFields = Number.isFinite(options.maxFieldsPerRun)\n ? Math.max(0, Math.floor(options.maxFieldsPerRun))\n : 0;\n if (maxFields === 0) {\n log.debug(\"[peer-profile-reasoner] maxFieldsPerRun=0 — no-op\");\n return result;\n }\n const maxLogPerPeer = Number.isFinite(options.maxLogEntriesPerPeer ?? NaN)\n ? Math.max(1, Math.floor(options.maxLogEntriesPerPeer as number))\n : 50;\n const now = options.now ?? new Date();\n const nowIso = now.toISOString();\n\n let peers: Peer[];\n try {\n if (options.peerIds && options.peerIds.length > 0) {\n // Filter the explicit list against on-disk peers so we never\n // act on an id the operator typed but didn't register.\n const all = await listPeers(options.memoryDir);\n const wanted = new Set(\n options.peerIds.filter(\n (id) => typeof id === \"string\" && PEER_ID_PATTERN.test(id),\n ),\n );\n peers = all.filter((p) => wanted.has(p.id));\n } else {\n peers = await listPeers(options.memoryDir);\n }\n } catch (err) {\n log.warn(\n `[peer-profile-reasoner] listPeers failed: ${err instanceof Error ? err.message : String(err)}`,\n );\n return result;\n }\n result.peersConsidered = peers.length;\n let fieldsAppliedTotal = 0;\n\n for (const peer of peers) {\n if (options.signal?.aborted) {\n result.perPeer.push({\n peerId: peer.id,\n status: \"skipped_aborted\",\n fieldsApplied: 0,\n droppedDueToCap: 0,\n fields: [],\n });\n continue;\n }\n try {\n // Codex P2 review on PR #736: the min-interactions threshold\n // must reflect the FULL log of new activity, not the\n // `maxLogPerPeer`-truncated slice. Otherwise a peer with a\n // genuinely active conversation history can be permanently\n // marked `skipped_below_min_interactions` whenever\n // `peerProfileReasonerMinInteractions > maxLogEntriesPerPeer`,\n // because the slice will never include enough new entries.\n // Read the full log first to compute the gate, then truncate\n // for prompt construction below.\n const fullLog = await readPeerInteractionLog(\n options.memoryDir,\n peer.id,\n );\n if (fullLog.length === 0) {\n result.perPeer.push({\n peerId: peer.id,\n status: \"skipped_no_log\",\n fieldsApplied: 0,\n droppedDueToCap: 0,\n fields: [],\n });\n continue;\n }\n // Count interactions since the last reasoner-run marker, so\n // dormant peers don't trigger another LLM call until enough\n // new signal accumulates. Run markers themselves don't count.\n const lastRun = lastRunTimestamp(fullLog);\n const sinceLastRunFull = lastRun\n ? fullLog.filter(\n (e) => e.timestamp > lastRun && e.kind !== RUN_MARKER_KIND,\n )\n : fullLog.filter((e) => e.kind !== RUN_MARKER_KIND);\n if (sinceLastRunFull.length < minInteractions) {\n result.perPeer.push({\n peerId: peer.id,\n status: \"skipped_below_min_interactions\",\n fieldsApplied: 0,\n droppedDueToCap: 0,\n fields: [],\n });\n continue;\n }\n // Truncate ONLY for prompt construction so the LLM context\n // stays bounded. Use the most recent `maxLogPerPeer` entries\n // from the full since-last-run set so the prompt prefers fresh\n // signal over older entries.\n const sinceLastRun =\n sinceLastRunFull.length > maxLogPerPeer\n ? sinceLastRunFull.slice(sinceLastRunFull.length - maxLogPerPeer)\n : sinceLastRunFull;\n\n const existingProfile = await readPeerProfile(options.memoryDir, peer.id);\n\n const remainingBudget = maxFields - fieldsAppliedTotal;\n if (remainingBudget <= 0) {\n result.perPeer.push({\n peerId: peer.id,\n status: \"skipped_cap_reached\",\n fieldsApplied: 0,\n droppedDueToCap: 0,\n fields: [],\n });\n continue;\n }\n\n const prompt = buildPeerProfileReasonerPrompt({\n peer,\n existingProfile,\n log: sinceLastRun,\n maxFields: remainingBudget,\n });\n const messages = [\n { role: \"system\" as const, content: SYSTEM_MESSAGE },\n { role: \"user\" as const, content: prompt },\n ];\n let response: { content: string } | null;\n try {\n response = await options.llm.chatCompletion(messages, {\n temperature: 0.2,\n maxTokens: 1500,\n });\n } catch (err) {\n log.warn(\n `[peer-profile-reasoner] LLM call failed for \"${peer.id}\": ${err instanceof Error ? err.message : String(err)}`,\n );\n result.perPeer.push({\n peerId: peer.id,\n status: \"skipped_llm_unavailable\",\n fieldsApplied: 0,\n droppedDueToCap: 0,\n fields: [],\n });\n continue;\n }\n if (!response || typeof response.content !== \"string\") {\n result.perPeer.push({\n peerId: peer.id,\n status: \"skipped_llm_unavailable\",\n fieldsApplied: 0,\n droppedDueToCap: 0,\n fields: [],\n });\n continue;\n }\n\n const proposals = parsePeerProfileReasonerResponse(response.content);\n if (proposals.length === 0) {\n result.perPeer.push({\n peerId: peer.id,\n status: \"processed\",\n fieldsApplied: 0,\n droppedDueToCap: 0,\n fields: [],\n });\n continue;\n }\n\n // Build the merged profile. We never replace existing\n // provenance entries — provenance is append-only so the\n // operator retains a full audit trail.\n const sessionIdsInWindow = new Set(\n sinceLastRun\n .map((e) => e.sessionId)\n .filter((s): s is string => typeof s === \"string\" && s.length > 0),\n );\n const baseFields: Record<string, string> = existingProfile\n ? { ...existingProfile.fields }\n : {};\n const baseProvenance: Record<string, PeerProfileFieldProvenance[]> = {};\n if (existingProfile) {\n for (const [k, list] of Object.entries(existingProfile.provenance)) {\n baseProvenance[k] = [...list];\n }\n }\n\n // Codex P1 review on PR #736: the global `fieldsAppliedTotal`\n // counter must NOT be incremented until the profile write\n // actually succeeds. Otherwise a transient I/O error here\n // poisons the per-run cap for every subsequent peer — they get\n // marked `skipped_cap_reached` for fields that were never\n // persisted. Track the candidate count locally and only\n // commit it to the run-wide budget after `writePeerProfile`\n // returns successfully (Gotcha #25 — don't destroy old state\n // before confirming new state succeeds).\n const appliedFieldsForPeer: string[] = [];\n let droppedDueToCap = 0;\n let invalidProposalSeen = false;\n for (const proposal of proposals) {\n // Use a candidate-budget projection so we never propose more\n // than the run-wide cap allows even before we know the write\n // will succeed.\n const candidateBudget =\n maxFields - fieldsAppliedTotal - appliedFieldsForPeer.length;\n if (candidateBudget <= 0) {\n droppedDueToCap += 1;\n continue;\n }\n // Final defensive guard against prototype keys (parser\n // already drops them, but be redundant for safety).\n if (\n proposal.field === \"__proto__\" ||\n proposal.field === \"constructor\" ||\n proposal.field === \"prototype\"\n ) {\n invalidProposalSeen = true;\n continue;\n }\n // Sanity-check field key matches a conservative pattern so a\n // hostile LLM can't sneak path-traversal-shaped keys through\n // for downstream consumers.\n if (!/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(proposal.field)) {\n invalidProposalSeen = true;\n continue;\n }\n baseFields[proposal.field] = proposal.value;\n const sourceSessionId =\n proposal.sourceSessionId &&\n sessionIdsInWindow.has(proposal.sourceSessionId)\n ? proposal.sourceSessionId\n : undefined;\n const provEntry: PeerProfileFieldProvenance = {\n observedAt: nowIso,\n signal: proposal.signal,\n ...(sourceSessionId ? { sourceSessionId } : {}),\n ...(proposal.note && proposal.note.length > 0\n ? { note: proposal.note }\n : {}),\n };\n const list = baseProvenance[proposal.field] ?? [];\n list.push(provEntry);\n baseProvenance[proposal.field] = list;\n appliedFieldsForPeer.push(proposal.field);\n // NOTE: fieldsAppliedTotal is NOT incremented here — see the\n // P1 comment above. We commit the budget after the write\n // succeeds.\n }\n\n if (appliedFieldsForPeer.length === 0) {\n result.perPeer.push({\n peerId: peer.id,\n status: invalidProposalSeen\n ? \"skipped_invalid_proposal\"\n : droppedDueToCap > 0\n ? \"skipped_cap_reached\"\n : \"processed\",\n fieldsApplied: 0,\n droppedDueToCap,\n fields: [],\n });\n continue;\n }\n\n const merged: PeerProfile = {\n peerId: peer.id,\n updatedAt: nowIso,\n fields: baseFields,\n provenance: baseProvenance,\n };\n await writePeerProfile(options.memoryDir, merged);\n // Write succeeded — NOW commit the budget. A throw above\n // bubbles to the outer catch, where the peer is recorded as\n // `error` and the global cap remains intact for subsequent\n // peers (Codex P1 fix on PR #736).\n fieldsAppliedTotal += appliedFieldsForPeer.length;\n\n // Append a run marker so the next reasoner pass can compute\n // \"interactions since last run\" without a dedicated state\n // file. The marker is best-effort — a write failure here\n // logs but does not roll back the profile (the operator\n // would prefer a slightly noisy threshold over a lost\n // profile update).\n const wantsMarker = options.appendRunMarkerToLog ?? true;\n if (wantsMarker) {\n try {\n await appendInteractionLog(options.memoryDir, peer.id, {\n timestamp: nowIso,\n kind: RUN_MARKER_KIND,\n summary: `applied ${appliedFieldsForPeer.length} field(s) via ${options.model ?? \"unknown-model\"}`,\n });\n } catch (err) {\n log.warn(\n `[peer-profile-reasoner] run-marker append failed for \"${peer.id}\": ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n }\n\n result.perPeer.push({\n peerId: peer.id,\n status: \"processed\",\n fieldsApplied: appliedFieldsForPeer.length,\n droppedDueToCap,\n fields: appliedFieldsForPeer,\n });\n result.peersProcessed += 1;\n result.fieldsApplied = fieldsAppliedTotal;\n } catch (err) {\n log.warn(\n `[peer-profile-reasoner] error processing peer \"${peer.id}\": ${err instanceof Error ? err.message : String(err)}`,\n );\n result.perPeer.push({\n peerId: peer.id,\n status: \"error\",\n fieldsApplied: 0,\n droppedDueToCap: 0,\n fields: [],\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n return result;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/runtime/better-sqlite.ts"],"sourcesContent":["import { createRequire } from \"node:module\";\nimport type BetterSqlite3 from \"better-sqlite3\";\n\nexport type BetterSqlite3Database = BetterSqlite3.Database;\ntype BetterSqlite3Ctor = typeof BetterSqlite3;\ntype RuntimeRequire = ReturnType<typeof createRequire>;\n\nlet cachedCtor: BetterSqlite3Ctor | null = null;\n\nfunction loadBetterSqlite3(): BetterSqlite3Ctor {\n if (cachedCtor) return cachedCtor;\n\n const require = createRequire(import.meta.url);\n\n try {\n cachedCtor = requireBetterSqlite3Ctor(require);\n return cachedCtor;\n } catch (error) {\n throw unavailableError(error);\n }\n}\n\nexport function openBetterSqlite3(\n file: string,\n options?: ConstructorParameters<BetterSqlite3Ctor>[1],\n): BetterSqlite3Database {\n const Database = loadBetterSqlite3();\n return new Database(file, options);\n}\n\nfunction requireBetterSqlite3Ctor(require: RuntimeRequire): BetterSqlite3Ctor {\n const loaded = require(\"better-sqlite3\") as\n | BetterSqlite3Ctor\n | { default?: BetterSqlite3Ctor };\n const ctor = typeof loaded === \"function\" ? loaded : loaded.default;\n\n if (typeof ctor !== \"function\") {\n throw new Error(\"module did not export a constructor\");\n }\n\n return ctor;\n}\n\nexport function isLikelyBetterSqlite3NativeBindingError(error: unknown): boolean {\n const detail = errorDetail(error);\n return (\n detail.includes(\"Could not locate the bindings file\") ||\n detail.includes(\"better_sqlite3.node\") ||\n (detail.includes(\"node-v\") && detail.includes(\"better-sqlite3\")) ||\n (detail.includes(\"NODE_MODULE_VERSION\") && detail.includes(\"better-sqlite3\")) ||\n detail.includes(\"was compiled against a different Node.js version\")\n );\n}\n\nfunction unavailableError(error: unknown): Error {\n const detail = errorDetail(error);\n const nativeBindingHint = isLikelyBetterSqlite3NativeBindingError(error)\n ? \" This usually means the better-sqlite3 native binding was not compiled for this Node.js/platform combination. \" +\n \"Run `node scripts/ensure-better-sqlite3.mjs` from the Remnic install directory, or run \" +\n \"`npx node-gyp rebuild --directory=node_modules/better-sqlite3` if the verification script is unavailable.\"\n : \"\";\n return new Error(\n \"better-sqlite3 is unavailable. Remnic attempted to load the native SQLite binding and could not.\" +\n nativeBindingHint +\n (detail ? ` Original error: ${detail}` : \"\"),\n { cause: error instanceof Error ? error : undefined },\n );\n}\n\nfunction errorDetail(error: unknown): string {\n if (error instanceof Error) {\n const stack = error.stack && error.stack !== error.message ? `\\n${error.stack}` : \"\";\n return `${error.message}${stack}`;\n }\n return String(error ?? \"\");\n}\n"],"mappings":";AAAA,SAAS,qBAAqB;AAO9B,IAAI,aAAuC;AAE3C,SAAS,oBAAuC;AAC9C,MAAI,WAAY,QAAO;AAEvB,QAAMA,WAAU,cAAc,YAAY,GAAG;AAE7C,MAAI;AACF,iBAAa,yBAAyBA,QAAO;AAC7C,WAAO;AAAA,EACT,SAAS,OAAO;AACd,UAAM,iBAAiB,KAAK;AAAA,EAC9B;AACF;AAEO,SAAS,kBACd,MACA,SACuB;AACvB,QAAM,WAAW,kBAAkB;AACnC,SAAO,IAAI,SAAS,MAAM,OAAO;AACnC;AAEA,SAAS,yBAAyBA,UAA4C;AAC5E,QAAM,SAASA,SAAQ,gBAAgB;AAGvC,QAAM,OAAO,OAAO,WAAW,aAAa,SAAS,OAAO;AAE5D,MAAI,OAAO,SAAS,YAAY;AAC9B,UAAM,IAAI,MAAM,qCAAqC;AAAA,EACvD;AAEA,SAAO;AACT;AAEO,SAAS,wCAAwC,OAAyB;AAC/E,QAAM,SAAS,YAAY,KAAK;AAChC,SACE,OAAO,SAAS,oCAAoC,KACpD,OAAO,SAAS,qBAAqB,KACpC,OAAO,SAAS,QAAQ,KAAK,OAAO,SAAS,gBAAgB,KAC7D,OAAO,SAAS,qBAAqB,KAAK,OAAO,SAAS,gBAAgB,KAC3E,OAAO,SAAS,kDAAkD;AAEtE;AAEA,SAAS,iBAAiB,OAAuB;AAC/C,QAAM,SAAS,YAAY,KAAK;AAChC,QAAM,oBAAoB,wCAAwC,KAAK,IACnE,mTAGA;AACJ,SAAO,IAAI;AAAA,IACT,qGACE,qBACC,SAAS,oBAAoB,MAAM,KAAK;AAAA,IAC3C,EAAE,OAAO,iBAAiB,QAAQ,QAAQ,OAAU;AAAA,EACtD;AACF;AAEA,SAAS,YAAY,OAAwB;AAC3C,MAAI,iBAAiB,OAAO;AAC1B,UAAM,QAAQ,MAAM,SAAS,MAAM,UAAU,MAAM,UAAU;AAAA,EAAK,MAAM,KAAK,KAAK;AAClF,WAAO,GAAG,MAAM,OAAO,GAAG,KAAK;AAAA,EACjC;AACA,SAAO,OAAO,SAAS,EAAE;AAC3B;","names":["require"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/chunking.ts"],"sourcesContent":["/**\n * Automatic Chunking with Overlap (Phase 2A)\n *\n * Sentence-boundary chunking for long memories.\n * Preserves coherent thoughts by never splitting mid-sentence.\n */\n\nexport interface ChunkingConfig {\n /** Target tokens per chunk (default 200) */\n targetTokens: number;\n /** Minimum tokens to trigger chunking (default 150) */\n minTokens: number;\n /** Number of sentences to overlap between chunks (default 2) */\n overlapSentences: number;\n}\n\nexport interface Chunk {\n /** Chunk content */\n content: string;\n /** 0-based index */\n index: number;\n /** Approximate token count */\n tokenCount: number;\n}\n\nexport interface ChunkResult {\n /** Whether content was chunked */\n chunked: boolean;\n /** Array of chunks (length 1 if not chunked) */\n chunks: Chunk[];\n}\n\n/** Default chunking configuration */\nexport const DEFAULT_CHUNKING_CONFIG: ChunkingConfig = {\n targetTokens: 200,\n minTokens: 150,\n overlapSentences: 2,\n};\n\n/**\n * Estimate token count for text.\n * Rough approximation: ~4 characters per token for English.\n */\nfunction estimateTokens(text: string): number {\n return Math.ceil(text.length / 4);\n}\n\n/**\n * Split text into sentences.\n * Handles common abbreviations and edge cases.\n */\nfunction splitSentences(text: string): string[] {\n // Split on sentence-ending punctuation followed by whitespace or end of string\n // Preserve the punctuation with the sentence\n const sentences: string[] = [];\n\n // Regex to match sentence boundaries\n // Match: period/exclamation/question followed by space or end, but not abbreviations\n const sentenceRegex = /[^.!?]*[.!?]+(?:\\s+|$)/g;\n\n let match: RegExpExecArray | null;\n let lastIndex = 0;\n\n while ((match = sentenceRegex.exec(text)) !== null) {\n sentences.push(match[0].trim());\n lastIndex = sentenceRegex.lastIndex;\n }\n\n // Handle remaining text without sentence-ending punctuation\n if (lastIndex < text.length) {\n const remaining = text.slice(lastIndex).trim();\n if (remaining) {\n sentences.push(remaining);\n }\n }\n\n // Filter out empty sentences\n return sentences.filter((s) => s.length > 0);\n}\n\n/**\n * Chunk content into overlapping segments at sentence boundaries.\n *\n * @param content - The text content to chunk\n * @param config - Chunking configuration\n * @returns ChunkResult with chunks array\n */\nexport function chunkContent(\n content: string,\n config: ChunkingConfig = DEFAULT_CHUNKING_CONFIG,\n): ChunkResult {\n const totalTokens = estimateTokens(content);\n\n // Don't chunk if below minimum threshold\n if (totalTokens < config.minTokens) {\n return {\n chunked: false,\n chunks: [{\n content,\n index: 0,\n tokenCount: totalTokens,\n }],\n };\n }\n\n const sentences = splitSentences(content);\n\n // If we couldn't split into multiple sentences, don't chunk\n if (sentences.length <= 1) {\n return {\n chunked: false,\n chunks: [{\n content,\n index: 0,\n tokenCount: totalTokens,\n }],\n };\n }\n\n const chunks: Chunk[] = [];\n let currentChunkSentences: string[] = [];\n let currentTokens = 0;\n let chunkIndex = 0;\n\n for (let i = 0; i < sentences.length; i++) {\n const sentence = sentences[i];\n const sentenceTokens = estimateTokens(sentence);\n\n // Add sentence to current chunk\n currentChunkSentences.push(sentence);\n currentTokens += sentenceTokens;\n\n // Check if we've reached target size (with some flexibility)\n // Allow going over by up to 50% to avoid tiny final chunks\n const atTarget = currentTokens >= config.targetTokens;\n const isLastSentence = i === sentences.length - 1;\n\n if (atTarget || isLastSentence) {\n // Create chunk from accumulated sentences\n const chunkContent = currentChunkSentences.join(\" \");\n chunks.push({\n content: chunkContent,\n index: chunkIndex,\n tokenCount: estimateTokens(chunkContent),\n });\n chunkIndex++;\n\n // Start new chunk with overlap (if not at end)\n if (!isLastSentence) {\n // Keep last N sentences for overlap.\n // Guard: slice(-0) === slice(0), which returns the ENTIRE array\n // (CLAUDE.md gotcha #27). When overlapSentences is 0, clear fully.\n const overlapCount = Math.min(config.overlapSentences, currentChunkSentences.length);\n if (overlapCount <= 0) {\n currentChunkSentences = [];\n currentTokens = 0;\n } else {\n currentChunkSentences = currentChunkSentences.slice(-overlapCount);\n currentTokens = currentChunkSentences.reduce((sum, s) => sum + estimateTokens(s), 0);\n }\n }\n }\n }\n\n // Only consider it \"chunked\" if we got multiple chunks\n return {\n chunked: chunks.length > 1,\n chunks,\n };\n}\n\n/**\n * Get parent content by reassembling chunks.\n * Useful for displaying full context when a chunk is retrieved.\n *\n * @param chunks - Array of chunk contents in order\n * @returns Reassembled parent content (with overlap removed)\n */\nexport function reassembleChunks(chunks: string[]): string {\n if (chunks.length === 0) return \"\";\n if (chunks.length === 1) return chunks[0];\n\n // For overlapping chunks, we need to deduplicate\n // Simple approach: use full first chunk, then non-overlapping parts of subsequent chunks\n // This is imperfect but handles most cases\n const result: string[] = [chunks[0]];\n\n for (let i = 1; i < chunks.length; i++) {\n const prevChunk = chunks[i - 1];\n const currChunk = chunks[i];\n\n // Find overlap by looking for common suffix/prefix\n // Try to find where the previous chunk ends in the current chunk\n const prevSentences = splitSentences(prevChunk);\n const currSentences = splitSentences(currChunk);\n\n // Find how many sentences from prev are at the start of curr\n let overlapCount = 0;\n for (let j = 0; j < Math.min(prevSentences.length, currSentences.length); j++) {\n // Check if last N sentences of prev match first N sentences of curr\n const prevEnd = prevSentences.slice(-(j + 1));\n const currStart = currSentences.slice(0, j + 1);\n\n if (prevEnd.join(\" \") === currStart.join(\" \")) {\n overlapCount = j + 1;\n }\n }\n\n // Add non-overlapping portion\n if (overlapCount > 0 && overlapCount < currSentences.length) {\n result.push(currSentences.slice(overlapCount).join(\" \"));\n } else if (overlapCount === 0) {\n // No detected overlap, add full chunk\n result.push(currChunk);\n }\n // If overlapCount === currSentences.length, skip (fully contained)\n }\n\n return result.join(\" \");\n}\n"],"mappings":"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","names":["chunkContent"]}
|