@rohirik/openltm-core 2.12.0 → 2.12.3

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/README.md CHANGED
@@ -22,6 +22,7 @@ idempotent — safe to run multiple times.
22
22
 
23
23
  - Adds the `ltm` MCP server entry (`bunx @rohirik/openltm-core mcp-serve`)
24
24
  - Wires three lifecycle hooks: `SessionStart`, `PreCompact`, `PostEditCheck`
25
+ - `SessionStart` now emits a portable Prior Knowledge prefill directly from `openltm-core`; the other two are safe no-ops unless the full Claude plugin checkout is installed
25
26
 
26
27
  ### OpenCode (`opencode.json`)
27
28
 
@@ -54,6 +55,8 @@ config directories.
54
55
 
55
56
  ## Programmatic API
56
57
 
58
+ Installers are registry-driven under `INSTALL_TARGETS`, so adding a new host now means defining one target entry (detect + install) instead of editing the CLI orchestrator in multiple places.
59
+
57
60
  ```typescript
58
61
  import { installClaude, installOpenCode, installPi, detectAgents } from "@rohirik/openltm-core/cli";
59
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohirik/openltm-core",
3
- "version": "2.12.0",
3
+ "version": "2.12.3",
4
4
  "description": "Shared LTM storage engine — path-agnostic SQLite core used by Claude Code, OpenCode, and Pi adapters",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -32,7 +32,7 @@
32
32
  "dependencies": {
33
33
  "@clack/prompts": "^1.3.0",
34
34
  "@iarna/toml": "^2.2.5",
35
- "@modelcontextprotocol/sdk": "^1.27.1",
35
+ "@modelcontextprotocol/sdk": "^1.29.0",
36
36
  "bun-types": "^1.0.0",
37
37
  "sqlite-vec": "0.1.9",
38
38
  "zod": "^4.3.6"
@@ -0,0 +1,47 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { readFileSync, unlinkSync } from "fs";
4
+ import { join } from "path";
5
+
6
+ const dbPath = `/tmp/test-openltm-hook-${process.pid}-${Date.now()}.db`;
7
+ const SCHEMA_PATH = join(import.meta.dir, "..", "..", "schema.sql");
8
+
9
+ beforeAll(async () => {
10
+ const { runPendingMigrations, _setDbForTesting } = await import("../../index.js");
11
+ const db = new Database(dbPath, { create: true });
12
+ db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
13
+ db.exec(readFileSync(SCHEMA_PATH, "utf-8"));
14
+ await runPendingMigrations(db);
15
+ _setDbForTesting(db);
16
+ }, 30_000);
17
+
18
+ afterAll(() => {
19
+ try { unlinkSync(dbPath); } catch {}
20
+ try { unlinkSync(`${dbPath}-shm`); } catch {}
21
+ try { unlinkSync(`${dbPath}-wal`); } catch {}
22
+ });
23
+
24
+ describe("cli hook dispatcher", () => {
25
+ it("builds a SessionStart prefill block from hook stdin JSON", async () => {
26
+ const { learn } = await import("../../index.js");
27
+ const { buildHookOutput } = await import("../../cli/hook.js");
28
+
29
+ learn({
30
+ content: "SessionStart should restore this memory",
31
+ category: "pattern",
32
+ importance: 3,
33
+ project_scope: "hook-project",
34
+ skipExport: true,
35
+ });
36
+
37
+ const output = await buildHookOutput("SessionStart", JSON.stringify({ cwd: "/tmp/hook-project" }));
38
+ expect(output).toContain("Prior Knowledge");
39
+ expect(output).toContain("restore this memory");
40
+ });
41
+
42
+ it("keeps non-SessionStart hook events as safe no-ops", async () => {
43
+ const { buildHookOutput } = await import("../../cli/hook.js");
44
+ expect(await buildHookOutput("PreCompact", JSON.stringify({ cwd: "/tmp/hook-project" }))).toBe("");
45
+ expect(await buildHookOutput("PostEditCheck", JSON.stringify({ cwd: "/tmp/hook-project" }))).toBe("");
46
+ });
47
+ });
@@ -0,0 +1,56 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { readFileSync, unlinkSync } from "fs";
4
+ import { join } from "path";
5
+
6
+ const dbPath = `/tmp/test-openltm-prefill-${process.pid}-${Date.now()}.db`;
7
+ const SCHEMA_PATH = join(import.meta.dir, "..", "schema.sql");
8
+
9
+ beforeAll(async () => {
10
+ const { runPendingMigrations, _setDbForTesting } = await import("../index.js");
11
+ const db = new Database(dbPath, { create: true });
12
+ db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
13
+ db.exec(readFileSync(SCHEMA_PATH, "utf-8"));
14
+ await runPendingMigrations(db);
15
+ _setDbForTesting(db);
16
+ }, 30_000);
17
+
18
+ afterAll(() => {
19
+ try { unlinkSync(dbPath); } catch {}
20
+ try { unlinkSync(`${dbPath}-shm`); } catch {}
21
+ try { unlinkSync(`${dbPath}-wal`); } catch {}
22
+ });
23
+
24
+ describe("prefill helpers", () => {
25
+ it("returns empty output when no memories exist", async () => {
26
+ const { buildPrefillContext } = await import("../index.js");
27
+ expect(buildPrefillContext({ project: "empty-prefill-project", maxMemories: 4 })).toBe("");
28
+ });
29
+
30
+ it("builds a compact shared prefill block from globals + project memories", async () => {
31
+ const { buildPrefillContext, deriveProjectFromCwd, learn } = await import("../index.js");
32
+
33
+ learn({
34
+ content: "Global guardrail — prefer Bun commands in this repo",
35
+ category: "workflow",
36
+ importance: 4,
37
+ skipExport: true,
38
+ });
39
+ learn({
40
+ content: "Project decision — adapter hooks should use the shared prefill builder",
41
+ category: "architecture",
42
+ importance: 3,
43
+ project_scope: "prefill-project",
44
+ skipExport: true,
45
+ });
46
+
47
+ expect(deriveProjectFromCwd("/tmp/prefill-project")).toBe("prefill-project");
48
+
49
+ const block = buildPrefillContext({ project: "prefill-project", maxMemories: 6, maxLines: 18 });
50
+ expect(block).toContain("## Prior Knowledge (LTM)");
51
+ expect(block).toContain("Global:");
52
+ expect(block).toContain("Project (prefill-project):");
53
+ expect(block).toContain("prefer Bun commands");
54
+ expect(block).toContain("shared prefill builder");
55
+ });
56
+ });
package/src/cli/bin.ts CHANGED
@@ -9,8 +9,8 @@
9
9
  * bunx @rohirik/openltm-core --pi # Pi only
10
10
  * bunx @rohirik/openltm-core --dry-run --claude # preview without writing
11
11
  *
12
- * bunx @rohirik/openltm-core hook --name <hookName> # lifecycle hook stub
13
- * bunx @rohirik/openltm-core mcp-serve # MCP server (future)
12
+ * bunx @rohirik/openltm-core hook --name <hookName> # lifecycle hook entrypoint
13
+ * bunx @rohirik/openltm-core mcp-serve # MCP server
14
14
  */
15
15
  import { runInstallCli } from "./install.js";
16
16
  import { runHook } from "./hook.js";
@@ -32,7 +32,7 @@ function printHelp(): void {
32
32
  " Sub-commands:",
33
33
  " memory <cmd> Read/write memories from the shell (learn, recall,",
34
34
  " forget, relate, context) — run 'memory --help'",
35
- " hook --name <event> Lifecycle hook stub (for Claude Code hook wiring)",
35
+ " hook --name <event> Lifecycle hook entrypoint (SessionStart prefill + safe no-ops)",
36
36
  " mcp-serve Start the LTM MCP server (stdio)",
37
37
  "",
38
38
  " If no target flags are given, agents are auto-detected.",
@@ -63,7 +63,7 @@ async function main(): Promise<void> {
63
63
  process.stderr.write(" ltm hook: missing --name argument\n");
64
64
  process.exit(1);
65
65
  }
66
- runHook(hookName);
66
+ await runHook(hookName);
67
67
  return;
68
68
  }
69
69
 
package/src/cli/detect.ts CHANGED
@@ -7,37 +7,9 @@
7
7
  *
8
8
  * Pure function — accepts a homedir argument so tests can supply a tmp dir.
9
9
  */
10
- import { existsSync } from "fs";
11
- import { join } from "path";
12
10
  import os from "os";
13
11
  import type { DetectResult } from "./types.js";
14
-
15
- /**
16
- * Probe for an OpenCode config directory using the standard path resolution
17
- * order: $XDG_CONFIG_HOME/opencode → ~/.config/opencode → (darwin only)
18
- * ~/Library/Application Support/opencode.
19
- */
20
- function probeOpenCode(homedir: string): boolean {
21
- const xdg = process.env["XDG_CONFIG_HOME"];
22
- if (xdg && existsSync(join(xdg, "opencode"))) return true;
23
- if (existsSync(join(homedir, ".config", "opencode"))) return true;
24
- if (process.platform === "darwin") {
25
- if (existsSync(join(homedir, "Library", "Application Support", "opencode"))) return true;
26
- }
27
- return false;
28
- }
29
-
30
- /**
31
- * Probe for a Pi config using the three known locations:
32
- * ~/.pi/ | ~/pi.toml | ~/.pi/config.toml
33
- */
34
- function probePi(homedir: string): boolean {
35
- return (
36
- existsSync(join(homedir, ".pi")) ||
37
- existsSync(join(homedir, "pi.toml")) ||
38
- existsSync(join(homedir, ".pi", "config.toml"))
39
- );
40
- }
12
+ import { INSTALL_TARGETS } from "./targets.js";
41
13
 
42
14
  /**
43
15
  * detectAgents — inspect the filesystem to determine which AI coding agents
@@ -47,9 +19,8 @@ function probePi(homedir: string): boolean {
47
19
  * @returns DetectResult with boolean flags for each supported agent.
48
20
  */
49
21
  export function detectAgents(homedir: string = os.homedir()): DetectResult {
50
- return {
51
- claude: existsSync(join(homedir, ".claude")),
52
- opencode: probeOpenCode(homedir),
53
- pi: probePi(homedir),
54
- };
22
+ return INSTALL_TARGETS.reduce<DetectResult>((acc, target) => {
23
+ acc[target.id] = target.detect(homedir);
24
+ return acc;
25
+ }, { claude: false, opencode: false, pi: false });
55
26
  }
package/src/cli/hook.ts CHANGED
@@ -1,25 +1,63 @@
1
1
  /**
2
- * cli/hook.ts — Hook dispatcher stub.
2
+ * cli/hook.ts — Lightweight Claude-compatible hook dispatcher.
3
3
  *
4
- * Full hook logic lives in the Claude Code plugin (hooks/src/).
5
- * When invoked via `bunx @rohirik/openltm-core hook --name <hookName>`, this
6
- * stub exits cleanly (exit 0) so Claude Code does not treat the absent hook
7
- * runner as an error.
8
- *
9
- * A future phase can wire actual hook logic here once openltm-core ships the
10
- * compiled hook handlers.
4
+ * For bunx installs we support a portable SessionStart prefill directly from
5
+ * openltm-core so users still get an "already pre-filled" experience without
6
+ * the full Claude plugin checkout. Other hook events are safe no-ops.
11
7
  */
8
+ import { buildPrefillContext, deriveProjectFromCwd } from "../prefill.js";
9
+
10
+ function parseHookCwd(raw: string): string {
11
+ if (!raw.trim()) return "";
12
+ try {
13
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
14
+ const cwd = parsed["cwd"]
15
+ ?? parsed["working_directory"]
16
+ ?? (parsed["session"] as Record<string, unknown> | undefined)?.["cwd"];
17
+ return typeof cwd === "string" ? cwd : "";
18
+ } catch {
19
+ return "";
20
+ }
21
+ }
22
+
23
+ export async function buildHookOutput(name: string, rawInput: string): Promise<string> {
24
+ switch (name) {
25
+ case "SessionStart": {
26
+ const cwd = parseHookCwd(rawInput);
27
+ if (!cwd) return "";
28
+ const project = deriveProjectFromCwd(cwd);
29
+ if (!project) return "";
30
+ return buildPrefillContext({ project, maxMemories: 10, maxLines: 18 });
31
+ }
32
+ case "PreCompact":
33
+ case "PostEditCheck":
34
+ return "";
35
+ default:
36
+ return "";
37
+ }
38
+ }
39
+
40
+ async function readStdin(): Promise<string> {
41
+ let result = "";
42
+ try {
43
+ for await (const chunk of Bun.stdin.stream()) {
44
+ result += new TextDecoder().decode(chunk);
45
+ }
46
+ } catch {
47
+ // stdin may be absent in some hook invocations
48
+ }
49
+ return result;
50
+ }
12
51
 
13
52
  /**
14
- * runHook — stub dispatcher for LTM lifecycle hooks.
53
+ * runHook — dispatch a lifecycle hook.
15
54
  *
16
- * @param name - Hook event name (e.g. "SessionStart", "PreCompact").
55
+ * SessionStart emits a compact Prior Knowledge block when memories exist.
56
+ * Other events intentionally no-op until a richer host-independent contract is
57
+ * extracted from the full Claude plugin hook suite.
17
58
  */
18
- export function runHook(name: string): void {
19
- // Stub: full hook logic requires the Claude Code plugin.
20
- // Exit 0 so Claude Code does not break when the hook is registered.
21
- process.stderr.write(
22
- `LTM: hook '${name}' via bunx not yet supported — install the Claude Code plugin for full hook support\n`,
23
- );
24
- process.exit(0);
59
+ export async function runHook(name: string): Promise<void> {
60
+ const raw = await readStdin();
61
+ const output = await buildHookOutput(name, raw);
62
+ if (output) process.stdout.write(output);
25
63
  }
package/src/cli/index.ts CHANGED
@@ -20,3 +20,5 @@ export { detectAgents } from "./detect.js";
20
20
  export { installClaude } from "./claude.js";
21
21
  export { installOpenCode } from "./opencode.js";
22
22
  export { installPi } from "./pi.js";
23
+ export { INSTALL_TARGETS, getInstallTarget } from "./targets.js";
24
+ export type { InstallTargetDefinition } from "./targets.js";
@@ -11,11 +11,9 @@
11
11
  */
12
12
  import * as clack from "@clack/prompts";
13
13
  import { detectAgents } from "./detect.js";
14
- import { installClaude } from "./claude.js";
15
- import { installOpenCode } from "./opencode.js";
16
- import { installPi } from "./pi.js";
17
14
  import type { CliInstallOptions, CliInstallResult, InstallStep, InstallResult } from "./types.js";
18
15
  import { InstallTarget } from "./types.js";
16
+ import { INSTALL_TARGETS } from "./targets.js";
19
17
 
20
18
  // ── Known targets set (legacy validation) ────────────────────────────────────
21
19
 
@@ -124,53 +122,19 @@ export async function runInstallCli(opts: CliRunOpts): Promise<CliRunResult> {
124
122
 
125
123
  const results: InstallResult[] = [];
126
124
 
127
- // Claude
128
- if (targets.claude) {
125
+ for (const target of INSTALL_TARGETS) {
126
+ if (!targets[target.id]) continue;
129
127
  const s = silent ? null : clack.spinner();
130
- if (s) s.start("Installing into Claude Code…");
131
- const r = await installClaude({ homedir, dryRun });
128
+ if (s) s.start(`Installing into ${target.label}…`);
129
+ const r = await target.install({ homedir, dryRun });
132
130
  results.push(r);
133
131
  if (s) {
134
132
  if (r.status === "installed") {
135
- s.stop(`Claude Code: installed${dryRun ? " (dry-run)" : ""}`);
133
+ s.stop(`${target.label}: installed${dryRun ? " (dry-run)" : ""}`);
136
134
  } else if (r.status === "skipped") {
137
- s.stop("Claude Code: already configured (skipped)");
135
+ s.stop(`${target.label}: already configured (skipped)`);
138
136
  } else {
139
- s.stop(`Claude Code: error — ${r.detail ?? "unknown error"}`);
140
- }
141
- }
142
- }
143
-
144
- // OpenCode
145
- if (targets.opencode) {
146
- const s = silent ? null : clack.spinner();
147
- if (s) s.start("Installing into OpenCode…");
148
- const r = await installOpenCode({ homedir, dryRun });
149
- results.push(r);
150
- if (s) {
151
- if (r.status === "installed") {
152
- s.stop(`OpenCode: installed${dryRun ? " (dry-run)" : ""}`);
153
- } else if (r.status === "skipped") {
154
- s.stop("OpenCode: already configured (skipped)");
155
- } else {
156
- s.stop(`OpenCode: error — ${r.detail ?? "unknown error"}`);
157
- }
158
- }
159
- }
160
-
161
- // Pi
162
- if (targets.pi) {
163
- const s = silent ? null : clack.spinner();
164
- if (s) s.start("Installing into Pi…");
165
- const r = await installPi({ dryRun });
166
- results.push(r);
167
- if (s) {
168
- if (r.status === "installed") {
169
- s.stop(`Pi: installed${dryRun ? " (dry-run)" : ""}`);
170
- } else if (r.status === "skipped") {
171
- s.stop("Pi: already configured (skipped)");
172
- } else {
173
- s.stop(`Pi: error — ${r.detail ?? "unknown error"}`);
137
+ s.stop(`${target.label}: error — ${r.detail ?? "unknown error"}`);
174
138
  }
175
139
  }
176
140
  }
@@ -0,0 +1,62 @@
1
+ import type { InstallResult } from "./types.js";
2
+ import { installClaude } from "./claude.js";
3
+ import { installOpenCode } from "./opencode.js";
4
+ import { installPi } from "./pi.js";
5
+ import { existsSync } from "fs";
6
+ import { join } from "path";
7
+
8
+ export interface InstallTargetDefinition {
9
+ id: "claude" | "opencode" | "pi";
10
+ label: string;
11
+ detect: (homedir: string) => boolean;
12
+ install: (opts: { homedir?: string; dryRun?: boolean }) => Promise<InstallResult>;
13
+ }
14
+
15
+ function detectClaude(homedir: string): boolean {
16
+ return existsSync(join(homedir, ".claude"));
17
+ }
18
+
19
+ function detectOpenCode(homedir: string): boolean {
20
+ const xdg = process.env["XDG_CONFIG_HOME"];
21
+ if (xdg && existsSync(join(xdg, "opencode"))) return true;
22
+ if (existsSync(join(homedir, ".config", "opencode"))) return true;
23
+ if (process.platform === "darwin") {
24
+ if (existsSync(join(homedir, "Library", "Application Support", "opencode"))) return true;
25
+ }
26
+ return false;
27
+ }
28
+
29
+ function detectPi(homedir: string): boolean {
30
+ return (
31
+ existsSync(join(homedir, ".pi")) ||
32
+ existsSync(join(homedir, "pi.toml")) ||
33
+ existsSync(join(homedir, ".pi", "config.toml"))
34
+ );
35
+ }
36
+
37
+ export const INSTALL_TARGETS: readonly InstallTargetDefinition[] = [
38
+ {
39
+ id: "claude",
40
+ label: "Claude Code",
41
+ detect: detectClaude,
42
+ install: ({ homedir, dryRun }) => installClaude({ homedir, dryRun }),
43
+ },
44
+ {
45
+ id: "opencode",
46
+ label: "OpenCode",
47
+ detect: detectOpenCode,
48
+ install: ({ homedir, dryRun }) => installOpenCode({ homedir, dryRun }),
49
+ },
50
+ {
51
+ id: "pi",
52
+ label: "Pi",
53
+ detect: detectPi,
54
+ install: ({ dryRun }) => installPi({ dryRun }),
55
+ },
56
+ ] as const;
57
+
58
+ export function getInstallTarget(id: InstallTargetDefinition["id"]): InstallTargetDefinition {
59
+ const target = INSTALL_TARGETS.find((entry) => entry.id === id);
60
+ if (!target) throw new Error(`Unknown install target: ${id}`);
61
+ return target;
62
+ }
package/src/index.ts CHANGED
@@ -43,6 +43,10 @@ export { categorise } from "./recall/categorise.js";
43
43
  export { buildExplainer, computeTemperature } from "./recall/explainer.js";
44
44
  export type { MemoryTemperature, RecallExplainer, ExplainerInput } from "./recall/explainer.js";
45
45
 
46
+ // Session prefill helpers
47
+ export { buildPrefillContext, deriveProjectFromCwd, selectPrefillMemories } from "./prefill.js";
48
+ export type { PrefillOptions, PrefillSelection } from "./prefill.js";
49
+
46
50
  // Embedding providers
47
51
  export * from "./providers/index.js";
48
52
  export { WriteQueue, writeQueue } from "./lib/writeQueue.js";
package/src/prefill.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * prefill.ts — Shared session-start context builder for host adapters.
3
+ *
4
+ * Keeps the "already pre-filled" experience consistent across Claude Code,
5
+ * OpenCode, Pi, and any future host that can call into openltm-core.
6
+ */
7
+ import { getContextMerge, type Memory } from "./db.js";
8
+ import { readConfigSync } from "./config.js";
9
+
10
+ export interface PrefillOptions {
11
+ project: string;
12
+ maxMemories?: number;
13
+ maxGlobalMemories?: number;
14
+ maxLines?: number;
15
+ header?: string;
16
+ }
17
+
18
+ export interface PrefillSelection {
19
+ globals: Memory[];
20
+ scoped: Memory[];
21
+ }
22
+
23
+ const DEFAULT_HEADER = "## Prior Knowledge (LTM)";
24
+
25
+ function oneLine(text: string): string {
26
+ return text.replace(/\s+/g, " ").trim();
27
+ }
28
+
29
+ function clampPositiveInt(value: number | undefined, fallback: number): number {
30
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return fallback;
31
+ return Math.floor(value);
32
+ }
33
+
34
+ function formatMemory(memory: Pick<Memory, "id" | "content" | "category" | "importance">): string {
35
+ return `- [${memory.id}] (${memory.category}/${memory.importance}) ${oneLine(memory.content)}`;
36
+ }
37
+
38
+ function trimLines(lines: string[], maxLines: number): string[] {
39
+ if (lines.length <= maxLines) return lines;
40
+ const trimmed = lines.slice(0, Math.max(0, maxLines - 1));
41
+ trimmed.push("… (truncated)");
42
+ return trimmed;
43
+ }
44
+
45
+ export function deriveProjectFromCwd(cwd: string): string {
46
+ return cwd.replace(/\/$/, "").split("/").pop() ?? "";
47
+ }
48
+
49
+ export function selectPrefillMemories(project: string, opts: Omit<PrefillOptions, "project"> = {}): PrefillSelection {
50
+ const cfg = readConfigSync();
51
+ const configuredTopN = cfg.ltm?.injectTopN;
52
+ const maxMemories = clampPositiveInt(opts.maxMemories, configuredTopN ?? 8);
53
+ const desiredGlobals = clampPositiveInt(opts.maxGlobalMemories, Math.min(3, Math.max(1, Math.ceil(maxMemories / 3))));
54
+
55
+ const merged = getContextMerge(project);
56
+
57
+ const globals = merged.globals.slice(0, Math.min(desiredGlobals, maxMemories));
58
+ const remaining = Math.max(0, maxMemories - globals.length);
59
+ const scoped = merged.scoped.slice(0, remaining);
60
+
61
+ if (scoped.length < remaining && globals.length < maxMemories) {
62
+ const refillGlobals = merged.globals.slice(globals.length, Math.min(merged.globals.length, maxMemories - scoped.length));
63
+ globals.push(...refillGlobals);
64
+ }
65
+
66
+ return { globals, scoped };
67
+ }
68
+
69
+ export function buildPrefillContext(opts: PrefillOptions): string {
70
+ const maxLines = clampPositiveInt(opts.maxLines, 18);
71
+ const { globals, scoped } = selectPrefillMemories(opts.project, opts);
72
+ if (globals.length === 0 && scoped.length === 0) return "";
73
+
74
+ const lines: string[] = [opts.header ?? DEFAULT_HEADER, ""];
75
+
76
+ if (globals.length > 0) {
77
+ lines.push("Global:");
78
+ for (const memory of globals) lines.push(formatMemory(memory));
79
+ lines.push("");
80
+ }
81
+
82
+ if (scoped.length > 0) {
83
+ lines.push(`Project (${opts.project}):`);
84
+ for (const memory of scoped) lines.push(formatMemory(memory));
85
+ lines.push("");
86
+ }
87
+
88
+ return trimLines(lines, maxLines).join("\n").trimEnd() + "\n";
89
+ }
@@ -27,6 +27,10 @@ export interface ExplainerInput {
27
27
  semanticScore?: number | null;
28
28
  }
29
29
 
30
+ function roundScore(value: number): number {
31
+ return Math.round(value * 1_000_000) / 1_000_000;
32
+ }
33
+
30
34
  /** Compute memory temperature from access pattern. */
31
35
  export function computeTemperature(
32
36
  recallCount: number,
@@ -49,7 +53,7 @@ export function buildExplainer(input: ExplainerInput): RecallExplainer {
49
53
  const daysSince = input.last_recalled_at
50
54
  ? (Date.now() - new Date(input.last_recalled_at).getTime()) / 86_400_000
51
55
  : 90;
52
- const recencyBoost = Math.max(0, 1 - daysSince / 90);
56
+ const recencyBoost = roundScore(Math.max(0, 1 - daysSince / 90));
53
57
 
54
58
  const ftsRank = input.ftsRank ?? null;
55
59
  const semanticScore = input.semanticScore ?? null;
@@ -70,7 +74,7 @@ export function buildExplainer(input: ExplainerInput): RecallExplainer {
70
74
  semanticScore,
71
75
  importanceBoost,
72
76
  recencyBoost,
73
- totalScore,
77
+ totalScore: roundScore(totalScore),
74
78
  temperature: computeTemperature(input.recall_count, input.last_recalled_at),
75
79
  };
76
80
  }