@remnic/core 9.3.622 → 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.
Files changed (141) hide show
  1. package/dist/access-cli.js +24 -24
  2. package/dist/access-http.js +9 -9
  3. package/dist/access-mcp.js +8 -8
  4. package/dist/access-service.js +7 -7
  5. package/dist/briefing.js +4 -4
  6. package/dist/buffer-surprise.js +3 -3
  7. package/dist/calibration.js +2 -2
  8. package/dist/causal-consolidation.js +8 -8
  9. package/dist/{chunk-UK727RHF.js → chunk-2L54V4ZO.js} +3 -3
  10. package/dist/{chunk-YPNGPHNZ.js → chunk-2UFQYU5F.js} +2 -2
  11. package/dist/{chunk-XAZOWLW4.js → chunk-3VONWEQB.js} +3 -3
  12. package/dist/{chunk-BF7ZRHH2.js → chunk-66SLUXKM.js} +2 -2
  13. package/dist/{chunk-AVHPSLQ2.js → chunk-AYHXQR53.js} +2 -2
  14. package/dist/{chunk-LANHQ7EN.js → chunk-BNW5NJJH.js} +2 -2
  15. package/dist/{chunk-6GMPIJAZ.js → chunk-C3IW2F5Z.js} +2 -2
  16. package/dist/{chunk-VNR3K2R3.js → chunk-C4PZTWTG.js} +14 -14
  17. package/dist/{chunk-3GM7ZY6H.js → chunk-FMGWXIES.js} +4 -4
  18. package/dist/{chunk-LMZ7XQBB.js → chunk-GLWW3EJQ.js} +3 -3
  19. package/dist/{chunk-2GRRN7SZ.js → chunk-GYTVOLNX.js} +2 -2
  20. package/dist/{chunk-IMA6GU4Y.js → chunk-H3PHZLMF.js} +3 -3
  21. package/dist/chunk-H3PHZLMF.js.map +1 -0
  22. package/dist/{chunk-XQUIHXNI.js → chunk-I6UCUHLK.js} +4 -4
  23. package/dist/{chunk-2I2MDQIB.js → chunk-I74SUMNI.js} +2 -2
  24. package/dist/chunk-I74SUMNI.js.map +1 -0
  25. package/dist/{chunk-4H5ZJHEN.js → chunk-J6A3CX5N.js} +8 -3
  26. package/dist/{chunk-4H5ZJHEN.js.map → chunk-J6A3CX5N.js.map} +1 -1
  27. package/dist/{chunk-DEVUWMME.js → chunk-KGIGRNR6.js} +2 -2
  28. package/dist/{chunk-EOLCAPOU.js → chunk-KQFQ3IS5.js} +5 -5
  29. package/dist/{chunk-QSVPYQPG.js → chunk-LDXUBPMO.js} +2 -2
  30. package/dist/chunk-LDXUBPMO.js.map +1 -0
  31. package/dist/{chunk-JFEKNTX7.js → chunk-LN4YGHTM.js} +6 -2
  32. package/dist/chunk-LN4YGHTM.js.map +1 -0
  33. package/dist/{chunk-WB3LYXC5.js → chunk-MON3LMO7.js} +3 -3
  34. package/dist/{chunk-GA3PMY73.js → chunk-O4UNM6OR.js} +2 -2
  35. package/dist/{chunk-4G2RQTAE.js → chunk-OZXVGYGZ.js} +2 -2
  36. package/dist/{chunk-WCYKT2DE.js → chunk-P4BC54KI.js} +23 -14
  37. package/dist/chunk-P4BC54KI.js.map +1 -0
  38. package/dist/{chunk-DXBCNDVD.js → chunk-PJGB7XRR.js} +5 -5
  39. package/dist/chunk-PJGB7XRR.js.map +1 -0
  40. package/dist/{chunk-ZNCDQZIS.js → chunk-QFQQFX2H.js} +3 -3
  41. package/dist/{chunk-ZNCDQZIS.js.map → chunk-QFQQFX2H.js.map} +1 -1
  42. package/dist/{chunk-CCNZM5UM.js → chunk-R3OQGYOU.js} +2 -2
  43. package/dist/{chunk-UZB5KHKX.js → chunk-RGMVMVMF.js} +2 -2
  44. package/dist/chunk-RGMVMVMF.js.map +1 -0
  45. package/dist/{chunk-EDP57PFC.js → chunk-RKW6QR7W.js} +22 -18
  46. package/dist/chunk-RKW6QR7W.js.map +1 -0
  47. package/dist/{chunk-4MHHUPNH.js → chunk-UGEBPVNI.js} +3 -3
  48. package/dist/{chunk-4WMCPJWX.js → chunk-UQ7RN5HK.js} +22 -13
  49. package/dist/chunk-UQ7RN5HK.js.map +1 -0
  50. package/dist/{chunk-ZUNNG6PC.js → chunk-W3BKVM64.js} +2 -2
  51. package/dist/{chunk-K5O2QY6T.js → chunk-YTWNKQ2G.js} +2 -2
  52. package/dist/chunk-YTWNKQ2G.js.map +1 -0
  53. package/dist/{chunk-2SGJY2UY.js → chunk-Z3CCEP6F.js} +3 -3
  54. package/dist/{chunk-4NS2ELXF.js → chunk-ZJSZNTEI.js} +4 -4
  55. package/dist/{chunk-UCGCSZP2.js → chunk-ZZPIJPPD.js} +2 -2
  56. package/dist/chunking.js +1 -1
  57. package/dist/cli.js +19 -19
  58. package/dist/compounding/engine.js +4 -4
  59. package/dist/connectors/codex-materialize-runner.js +5 -5
  60. package/dist/connectors/codex-materialize.js +1 -1
  61. package/dist/connectors/index.js +5 -5
  62. package/dist/contradiction/index.js +2 -2
  63. package/dist/{contradiction-scan-GD7KUFWS.js → contradiction-scan-AZTGFMPY.js} +3 -3
  64. package/dist/entity-retrieval.js +4 -4
  65. package/dist/explicit-capture.js +1 -1
  66. package/dist/extraction-judge.js +3 -3
  67. package/dist/extraction.js +3 -3
  68. package/dist/fallback-llm.js +2 -2
  69. package/dist/identity-continuity.js +1 -1
  70. package/dist/index.js +39 -36
  71. package/dist/index.js.map +1 -1
  72. package/dist/json-extract.js +1 -1
  73. package/dist/maintenance/memory-governance.js +4 -4
  74. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
  75. package/dist/maintenance/rebuild-memory-projection.js +5 -5
  76. package/dist/namespaces/migrate.js +5 -5
  77. package/dist/namespaces/storage.js +4 -4
  78. package/dist/operator-toolkit.js +7 -7
  79. package/dist/orchestrator.js +21 -21
  80. package/dist/peers/index.js +1 -1
  81. package/dist/recall-planner-llm.js +2 -2
  82. package/dist/schemas.d.ts +22 -22
  83. package/dist/semantic-chunking.js +2 -2
  84. package/dist/semantic-consolidation.js +6 -6
  85. package/dist/semantic-rule-promotion.js +4 -4
  86. package/dist/semantic-rule-verifier.js +4 -4
  87. package/dist/source-attribution.js +1 -1
  88. package/dist/storage.js +3 -3
  89. package/dist/summarizer.js +3 -3
  90. package/dist/temporal-supersession.js +1 -1
  91. package/dist/transfer/types.d.ts +12 -12
  92. package/dist/verified-recall.js +4 -4
  93. package/package.json +1 -1
  94. package/src/chunking.ts +38 -23
  95. package/src/coding/review-context.ts +7 -1
  96. package/src/connectors/codex-materialize.ts +6 -1
  97. package/src/explicit-capture.ts +7 -2
  98. package/src/identity-continuity.ts +7 -1
  99. package/src/json-extract.ts +4 -1
  100. package/src/orchestrator.ts +5 -1
  101. package/src/peers/profile-reasoner.ts +4 -1
  102. package/src/semantic-chunking.ts +32 -16
  103. package/src/semantic-consolidation.ts +4 -1
  104. package/src/source-attribution.test.ts +21 -0
  105. package/src/source-attribution.ts +17 -2
  106. package/src/storage.ts +11 -2
  107. package/src/temporal-supersession.ts +4 -1
  108. package/dist/chunk-2I2MDQIB.js.map +0 -1
  109. package/dist/chunk-4WMCPJWX.js.map +0 -1
  110. package/dist/chunk-DXBCNDVD.js.map +0 -1
  111. package/dist/chunk-EDP57PFC.js.map +0 -1
  112. package/dist/chunk-IMA6GU4Y.js.map +0 -1
  113. package/dist/chunk-JFEKNTX7.js.map +0 -1
  114. package/dist/chunk-K5O2QY6T.js.map +0 -1
  115. package/dist/chunk-QSVPYQPG.js.map +0 -1
  116. package/dist/chunk-UZB5KHKX.js.map +0 -1
  117. package/dist/chunk-WCYKT2DE.js.map +0 -1
  118. /package/dist/{chunk-UK727RHF.js.map → chunk-2L54V4ZO.js.map} +0 -0
  119. /package/dist/{chunk-YPNGPHNZ.js.map → chunk-2UFQYU5F.js.map} +0 -0
  120. /package/dist/{chunk-XAZOWLW4.js.map → chunk-3VONWEQB.js.map} +0 -0
  121. /package/dist/{chunk-BF7ZRHH2.js.map → chunk-66SLUXKM.js.map} +0 -0
  122. /package/dist/{chunk-AVHPSLQ2.js.map → chunk-AYHXQR53.js.map} +0 -0
  123. /package/dist/{chunk-LANHQ7EN.js.map → chunk-BNW5NJJH.js.map} +0 -0
  124. /package/dist/{chunk-6GMPIJAZ.js.map → chunk-C3IW2F5Z.js.map} +0 -0
  125. /package/dist/{chunk-VNR3K2R3.js.map → chunk-C4PZTWTG.js.map} +0 -0
  126. /package/dist/{chunk-3GM7ZY6H.js.map → chunk-FMGWXIES.js.map} +0 -0
  127. /package/dist/{chunk-LMZ7XQBB.js.map → chunk-GLWW3EJQ.js.map} +0 -0
  128. /package/dist/{chunk-2GRRN7SZ.js.map → chunk-GYTVOLNX.js.map} +0 -0
  129. /package/dist/{chunk-XQUIHXNI.js.map → chunk-I6UCUHLK.js.map} +0 -0
  130. /package/dist/{chunk-DEVUWMME.js.map → chunk-KGIGRNR6.js.map} +0 -0
  131. /package/dist/{chunk-EOLCAPOU.js.map → chunk-KQFQ3IS5.js.map} +0 -0
  132. /package/dist/{chunk-WB3LYXC5.js.map → chunk-MON3LMO7.js.map} +0 -0
  133. /package/dist/{chunk-GA3PMY73.js.map → chunk-O4UNM6OR.js.map} +0 -0
  134. /package/dist/{chunk-4G2RQTAE.js.map → chunk-OZXVGYGZ.js.map} +0 -0
  135. /package/dist/{chunk-CCNZM5UM.js.map → chunk-R3OQGYOU.js.map} +0 -0
  136. /package/dist/{chunk-4MHHUPNH.js.map → chunk-UGEBPVNI.js.map} +0 -0
  137. /package/dist/{chunk-ZUNNG6PC.js.map → chunk-W3BKVM64.js.map} +0 -0
  138. /package/dist/{chunk-2SGJY2UY.js.map → chunk-Z3CCEP6F.js.map} +0 -0
  139. /package/dist/{chunk-4NS2ELXF.js.map → chunk-ZJSZNTEI.js.map} +0 -0
  140. /package/dist/{chunk-UCGCSZP2.js.map → chunk-ZZPIJPPD.js.map} +0 -0
  141. /package/dist/{contradiction-scan-GD7KUFWS.js.map → contradiction-scan-AZTGFMPY.js.map} +0 -0
@@ -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/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"]}