clawmem 0.8.5 → 0.10.0

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/src/clawmem.ts CHANGED
@@ -64,6 +64,12 @@ import { precompactExtract } from "./hooks/precompact-extract.ts";
64
64
  import { postcompactInject } from "./hooks/postcompact-inject.ts";
65
65
  import { pretoolInject } from "./hooks/pretool-inject.ts";
66
66
  import { curatorNudge } from "./hooks/curator-nudge.ts";
67
+ import {
68
+ readSessionFocus,
69
+ writeSessionFocus,
70
+ clearSessionFocus,
71
+ focusFilePath,
72
+ } from "./session-focus.ts";
67
73
 
68
74
  enableProductionMode();
69
75
 
@@ -1298,8 +1304,28 @@ function cmdPath() {
1298
1304
  console.log(getDefaultDbPath());
1299
1305
  }
1300
1306
 
1307
+ /**
1308
+ * Read a single OpenClaw config key via `openclaw config get <key>`. Returns
1309
+ * the trimmed string value, or undefined when the key is unset / the CLI is
1310
+ * unavailable / the key is missing. Callers should treat undefined as
1311
+ * "no opinion" rather than "definitely unset".
1312
+ */
1313
+ function readOpenClawConfigValue(key: string): string | undefined {
1314
+ try {
1315
+ const r = Bun.spawnSync(["openclaw", "config", "get", key], { stdout: "pipe", stderr: "pipe" });
1316
+ if (r.exitCode !== 0) return undefined;
1317
+ const out = new TextDecoder().decode(r.stdout).trim();
1318
+ if (!out) return undefined;
1319
+ // `openclaw config get` may print JSON ("clawmem"\n) or raw (clawmem). Strip quotes.
1320
+ return out.replace(/^"(.*)"$/, "$1");
1321
+ } catch {
1322
+ return undefined;
1323
+ }
1324
+ }
1325
+
1301
1326
  async function cmdSetupOpenClaw(args: string[]) {
1302
1327
  const remove = args.includes("--remove");
1328
+ const linkMode = args.includes("--link");
1303
1329
  const pluginDir = pathResolve(import.meta.dir, "openclaw");
1304
1330
  const extensionsDir = pathResolve(process.env.HOME || "~", ".openclaw", "extensions");
1305
1331
  const linkPath = pathResolve(extensionsDir, "clawmem");
@@ -1333,10 +1359,21 @@ async function cmdSetupOpenClaw(args: string[]) {
1333
1359
  }
1334
1360
 
1335
1361
  if (hasOpenClawCli) {
1336
- Bun.spawnSync(["openclaw", "config", "set", "plugins.slots.contextEngine", "legacy"], { stdout: "inherit", stderr: "inherit" });
1337
- console.log(`${c.green}Reset context engine slot to legacy${c.reset}`);
1362
+ // Reset the memory slot if ClawMem owned it (post-§14.3-migration installs).
1363
+ const memSlot = readOpenClawConfigValue("plugins.slots.memory");
1364
+ if (memSlot === "clawmem") {
1365
+ Bun.spawnSync(["openclaw", "config", "unset", "plugins.slots.memory"], { stdout: "inherit", stderr: "inherit" });
1366
+ console.log(`${c.green}Cleared memory slot (was clawmem)${c.reset}`);
1367
+ }
1368
+ // Reset the legacy context-engine slot if any pre-§14.3-migration install
1369
+ // left it pointing at clawmem.
1370
+ const ceSlot = readOpenClawConfigValue("plugins.slots.contextEngine");
1371
+ if (ceSlot === "clawmem") {
1372
+ Bun.spawnSync(["openclaw", "config", "set", "plugins.slots.contextEngine", "legacy"], { stdout: "inherit", stderr: "inherit" });
1373
+ console.log(`${c.green}Reset context engine slot to legacy (was clawmem)${c.reset}`);
1374
+ }
1338
1375
  } else if (removed) {
1339
- console.log(`${c.dim}openclaw CLI not found — manually run: openclaw config set plugins.slots.contextEngine legacy${c.reset}`);
1376
+ console.log(`${c.dim}openclaw CLI not found — manually clear: openclaw config unset plugins.slots.memory && openclaw config set plugins.slots.contextEngine legacy${c.reset}`);
1340
1377
  }
1341
1378
  return;
1342
1379
  }
@@ -1348,42 +1385,86 @@ async function cmdSetupOpenClaw(args: string[]) {
1348
1385
  if (!existsSync(pathResolve(pluginDir, "openclaw.plugin.json"))) {
1349
1386
  die(`Plugin manifest not found at ${pluginDir}/openclaw.plugin.json`);
1350
1387
  }
1388
+ if (!existsSync(pathResolve(pluginDir, "package.json"))) {
1389
+ die(`Plugin package.json not found at ${pluginDir}/package.json — required for OpenClaw v2026.4.11+ discovery`);
1390
+ }
1351
1391
 
1352
1392
  // Create extensions directory
1353
1393
  if (!existsSync(extensionsDir)) {
1354
1394
  mkdirSync(extensionsDir, { recursive: true });
1355
1395
  }
1356
1396
 
1357
- // Remove stale symlink/directory if present
1397
+ // Remove any stale install (symlink or directory) before re-installing.
1398
+ // OpenClaw v2026.4.11+ discovery (discoverInDirectory in ids-*.js) uses
1399
+ // readdirSync({ withFileTypes: true }) where symlinks report
1400
+ // isDirectory() === false and get silently skipped, so copy mode is the
1401
+ // default. The --link flag keeps symlink behavior for older OpenClaw
1402
+ // versions or local development workflows where editing the live source
1403
+ // should take effect without re-running setup.
1358
1404
  try {
1359
1405
  const { lstatSync, unlinkSync, rmSync } = await import("fs");
1360
1406
  const stat = lstatSync(linkPath);
1361
1407
  if (stat.isSymbolicLink()) {
1362
- const { readlinkSync } = await import("fs");
1363
- const target = readlinkSync(linkPath);
1364
- if (target === pluginDir) {
1365
- console.log(`${c.dim}Symlink already correct at ${linkPath}${c.reset}`);
1366
- } else {
1367
- unlinkSync(linkPath);
1368
- console.log(`${c.dim}Replaced stale symlink (was → ${target})${c.reset}`);
1369
- }
1408
+ unlinkSync(linkPath);
1409
+ console.log(`${c.dim}Replaced stale symlink at ${linkPath}${c.reset}`);
1370
1410
  } else if (stat.isDirectory()) {
1371
1411
  rmSync(linkPath, { recursive: true });
1372
1412
  console.log(`${c.dim}Replaced existing directory at ${linkPath}${c.reset}`);
1373
1413
  } else {
1374
- // Regular file or other non-symlink, non-directory — conflict
1375
1414
  die(`${linkPath} exists but is not a symlink or directory. Remove it manually and re-run setup.`);
1376
1415
  }
1377
1416
  } catch (e: any) {
1378
1417
  if (e.code !== "ENOENT") throw e;
1379
1418
  }
1380
1419
 
1381
- // Create symlink
1382
- if (!existsSync(linkPath)) {
1420
+ if (linkMode) {
1383
1421
  const { symlinkSync } = await import("fs");
1384
1422
  symlinkSync(pluginDir, linkPath);
1423
+ console.log(`${c.green}Installed plugin: ${linkPath} → ${pluginDir} (symlink)${c.reset}`);
1424
+ console.log(`${c.yellow} Warning: symlink mode. OpenClaw v2026.4.11+ discovery skips${c.reset}`);
1425
+ console.log(`${c.yellow} symlinks silently. Re-run without --link on current releases.${c.reset}`);
1426
+ } else {
1427
+ const { cpSync } = await import("fs");
1428
+ cpSync(pluginDir, linkPath, { recursive: true, dereference: true });
1429
+ console.log(`${c.green}Installed plugin: ${linkPath} (copied from ${pluginDir})${c.reset}`);
1430
+ }
1431
+
1432
+ // ----- §14.3 upgrade migration -----
1433
+ // ClawMem v0.10.0 changed `kind: "context-engine"` to `kind: "memory"`.
1434
+ // Existing installs with `plugins.slots.contextEngine = "clawmem"` will hit
1435
+ // a hard runtime error after upgrading because OpenClaw's
1436
+ // `resolveContextEngine()` throws on unknown engine ids. Detect and rewrite
1437
+ // the stale config to "legacy" so OpenClaw's built-in LegacyContextEngine
1438
+ // takes over compaction. Also detect any pre-existing `plugins.slots.memory`
1439
+ // assignment so we don't clobber a user's choice during upgrade.
1440
+ let migrationApplied = false;
1441
+ if (hasOpenClawCli) {
1442
+ const staleContextEngine = readOpenClawConfigValue("plugins.slots.contextEngine");
1443
+ if (staleContextEngine === "clawmem") {
1444
+ console.log();
1445
+ console.log(`${c.bold}${c.cyan}Upgrade migration detected:${c.reset}`);
1446
+ console.log(` Found legacy ClawMem context-engine slot config from v0.9.x or earlier.`);
1447
+ console.log(` Rewriting plugins.slots.contextEngine: clawmem → legacy`);
1448
+ console.log(` ${c.dim}(ClawMem now registers as a memory plugin. OpenClaw's built-in${c.reset}`);
1449
+ console.log(` ${c.dim} LegacyContextEngine will handle compaction unless you install a${c.reset}`);
1450
+ console.log(` ${c.dim} third-party context-engine plugin like hermes-lcm.)${c.reset}`);
1451
+ const migrate = Bun.spawnSync(
1452
+ ["openclaw", "config", "set", "plugins.slots.contextEngine", "legacy"],
1453
+ { stdout: "inherit", stderr: "inherit" },
1454
+ );
1455
+ if (migrate.exitCode === 0) {
1456
+ migrationApplied = true;
1457
+ } else {
1458
+ console.log(`${c.yellow} Warning: failed to rewrite stale config — please run manually:${c.reset}`);
1459
+ console.log(` ${c.cyan}openclaw config set plugins.slots.contextEngine legacy${c.reset}`);
1460
+ }
1461
+ }
1462
+ } else {
1463
+ console.log();
1464
+ console.log(`${c.dim}Upgrade migration skipped — openclaw CLI not on PATH. If upgrading${c.reset}`);
1465
+ console.log(`${c.dim}from v0.9.x or earlier, manually run:${c.reset}`);
1466
+ console.log(` ${c.cyan}openclaw config set plugins.slots.contextEngine legacy${c.reset}`);
1385
1467
  }
1386
- console.log(`${c.green}Installed plugin: ${linkPath} → ${pluginDir}${c.reset}`);
1387
1468
 
1388
1469
  // Version warning
1389
1470
  console.log();
@@ -1391,17 +1472,21 @@ async function cmdSetupOpenClaw(args: string[]) {
1391
1472
  console.log(`have a bug where plugins.slots.contextEngine is silently dropped`);
1392
1473
  console.log(`during config normalization (openclaw/openclaw#64192).`);
1393
1474
 
1394
- // Remaining steps gateway must restart BEFORE setting the context engine slot,
1395
- // otherwise OpenClaw hasn't discovered the plugin yet and the slot assignment
1396
- // fails or is ignored (the exact bug reported in issue #5).
1475
+ // Remaining steps. CLI discovery finds the plugin immediately because the
1476
+ // plugin dir now ships a package.json with openclaw.extensions declared, so
1477
+ // `openclaw plugins enable clawmem` can run before any gateway restart.
1478
+ // The enable command switches the exclusive memory slot to clawmem and
1479
+ // disables memory-core/memory-lancedb automatically. Then the gateway
1480
+ // restart applies the new slot assignment.
1397
1481
  console.log();
1398
1482
  console.log(`${c.bold}Next steps:${c.reset}`);
1399
1483
  console.log();
1400
- console.log(` 1. Restart OpenClaw gateway to discover the plugin:`);
1401
- console.log(` ${c.cyan}openclaw gateway restart${c.reset}`);
1484
+ console.log(` 1. Enable ClawMem as the active memory plugin:`);
1485
+ console.log(` ${c.cyan}openclaw plugins enable clawmem${c.reset}`);
1486
+ console.log(` ${c.dim}(Switches plugins.slots.memory to clawmem and disables memory-core if active.)${c.reset}`);
1402
1487
  console.log();
1403
- console.log(` 2. Set ClawMem as the active context engine (after restart):`);
1404
- console.log(` ${c.cyan}openclaw config set plugins.slots.contextEngine clawmem${c.reset}`);
1488
+ console.log(` 2. Restart the gateway to apply:`);
1489
+ console.log(` ${c.cyan}openclaw gateway restart${c.reset}`);
1405
1490
  console.log();
1406
1491
  console.log(` 3. Configure GPU endpoints (if not using defaults):`);
1407
1492
  console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuEmbed http://YOUR_GPU:8088${c.reset}`);
@@ -1411,7 +1496,24 @@ async function cmdSetupOpenClaw(args: string[]) {
1411
1496
  console.log(` 4. Start the REST API (for agent tools):`);
1412
1497
  console.log(` ${c.cyan}clawmem serve &${c.reset}`);
1413
1498
  console.log();
1499
+ console.log(`${c.bold}Important: keep dreaming disabled${c.reset}`);
1500
+ console.log(` ClawMem runs its own consolidation workers (CLAWMEM_ENABLE_CONSOLIDATION`);
1501
+ console.log(` light lane and CLAWMEM_HEAVY_LANE heavy lane). Keep ${c.cyan}dreaming.enabled = false${c.reset}`);
1502
+ console.log(` in OpenClaw's memory config to avoid auto-loading the bundled memory-core`);
1503
+ console.log(` dreaming engine alongside ClawMem (#65411 coexistence rule).`);
1504
+ console.log();
1505
+ console.log(`${c.bold}Compaction:${c.reset} OpenClaw's built-in LegacyContextEngine handles compaction`);
1506
+ console.log(`by default. Install a third-party context-engine plugin (hermes-lcm, etc.)`);
1507
+ console.log(`if you want a different compaction strategy. ClawMem injects pre-emptive`);
1508
+ console.log(`precompact state via ${c.cyan}before_prompt_build${c.reset} when token usage approaches the`);
1509
+ console.log(`compaction threshold.`);
1510
+ console.log();
1414
1511
  console.log(`${c.dim}ClawMem will work alongside Claude Code hooks — both modes share the same vault.${c.reset}`);
1512
+
1513
+ if (migrationApplied) {
1514
+ console.log();
1515
+ console.log(`${c.green}✓ Upgrade migration applied — restart OpenClaw to pick up the new plugin kind.${c.reset}`);
1516
+ }
1415
1517
  }
1416
1518
 
1417
1519
  function findClawmemBinary(): string {
@@ -1729,6 +1831,37 @@ async function cmdDoctor() {
1729
1831
  // Skip
1730
1832
  }
1731
1833
 
1834
+ // 8. OpenClaw plugin slot config (§14.3 upgrade migration check)
1835
+ try {
1836
+ const stale = readOpenClawConfigValue("plugins.slots.contextEngine");
1837
+ if (stale === "clawmem") {
1838
+ console.log(
1839
+ `${c.red}✗${c.reset} OpenClaw config: stale ${c.cyan}plugins.slots.contextEngine = "clawmem"${c.reset}`,
1840
+ );
1841
+ console.log(
1842
+ ` ${c.dim}ClawMem v0.10.0 is now a memory plugin. Run ${c.cyan}clawmem setup openclaw${c.dim} to migrate,${c.reset}`,
1843
+ );
1844
+ console.log(
1845
+ ` ${c.dim}or manually: ${c.cyan}openclaw config set plugins.slots.contextEngine legacy${c.reset}`,
1846
+ );
1847
+ issues++;
1848
+ } else if (stale && stale !== "legacy") {
1849
+ console.log(
1850
+ `${c.green}✓${c.reset} OpenClaw context-engine slot: ${c.cyan}${stale}${c.reset} (third-party LCM)`,
1851
+ );
1852
+ }
1853
+ const memSlot = readOpenClawConfigValue("plugins.slots.memory");
1854
+ if (memSlot === "clawmem") {
1855
+ console.log(`${c.green}✓${c.reset} OpenClaw memory slot: ${c.cyan}clawmem${c.reset}`);
1856
+ } else if (memSlot) {
1857
+ console.log(
1858
+ `${c.dim}-${c.reset} OpenClaw memory slot: ${c.cyan}${memSlot}${c.reset} (ClawMem hooks will not fire under this agent)`,
1859
+ );
1860
+ }
1861
+ } catch {
1862
+ // openclaw CLI unavailable — skip silently
1863
+ }
1864
+
1732
1865
  console.log();
1733
1866
  if (issues > 0) {
1734
1867
  console.log(`${c.yellow}${issues} issue(s) found.${c.reset}`);
@@ -1906,6 +2039,91 @@ async function cmdProfile(args: string[]) {
1906
2039
  }
1907
2040
  }
1908
2041
 
2042
+ // §11.4 (v0.9.0): session-scoped focus topic — read/write/clear the
2043
+ // per-session focus file at ~/.cache/clawmem/sessions/<session_id>.focus.
2044
+ // The file is the primary signal read by context-surfacing for topic
2045
+ // boosting; the CLAWMEM_SESSION_FOCUS env var is a debug-only override
2046
+ // that does NOT provide per-session scoping on multi-session hosts.
2047
+ async function cmdFocus(args: string[]) {
2048
+ const subCmd = args[0];
2049
+
2050
+ function resolveSessionId(rest: string[]): string {
2051
+ const sidIdx = rest.indexOf("--session-id");
2052
+ if (sidIdx >= 0 && rest[sidIdx + 1]) return rest[sidIdx + 1]!;
2053
+ const envSid = (
2054
+ process.env.CLAUDE_SESSION_ID ||
2055
+ process.env.CLAWMEM_SESSION_ID ||
2056
+ ""
2057
+ ).trim();
2058
+ if (envSid) return envSid;
2059
+ die(
2060
+ "No session id. Pass --session-id <id>, or set CLAUDE_SESSION_ID " +
2061
+ "(Claude Code exposes this) or CLAWMEM_SESSION_ID env var before " +
2062
+ "invoking this command."
2063
+ );
2064
+ }
2065
+
2066
+ function stripSessionIdArg(rest: string[]): string[] {
2067
+ const sidIdx = rest.indexOf("--session-id");
2068
+ if (sidIdx < 0) return rest;
2069
+ return [...rest.slice(0, sidIdx), ...rest.slice(sidIdx + 2)];
2070
+ }
2071
+
2072
+ switch (subCmd) {
2073
+ case "set": {
2074
+ const rest = args.slice(1);
2075
+ const sessionId = resolveSessionId(rest);
2076
+ const positional = stripSessionIdArg(rest);
2077
+ const topic = positional.join(" ").trim();
2078
+ if (!topic) {
2079
+ die("Usage: clawmem focus set <topic> [--session-id <id>]");
2080
+ }
2081
+ try {
2082
+ writeSessionFocus(sessionId, topic);
2083
+ } catch (err: any) {
2084
+ die(`Failed to set focus: ${err?.message ?? err}`);
2085
+ }
2086
+ console.log(
2087
+ `${c.green}Focus set${c.reset} for session ${c.cyan}${sessionId}${c.reset}: ${topic}`
2088
+ );
2089
+ console.log(`${c.dim}File: ${focusFilePath(sessionId)}${c.reset}`);
2090
+ break;
2091
+ }
2092
+ case "show": {
2093
+ const rest = args.slice(1);
2094
+ const sessionId = resolveSessionId(rest);
2095
+ const topic = readSessionFocus(sessionId);
2096
+ if (topic) {
2097
+ console.log(
2098
+ `${c.green}Focus${c.reset} for session ${c.cyan}${sessionId}${c.reset}: ${topic}`
2099
+ );
2100
+ console.log(`${c.dim}File: ${focusFilePath(sessionId)}${c.reset}`);
2101
+ } else {
2102
+ console.log(
2103
+ `${c.yellow}No focus${c.reset} set for session ${c.cyan}${sessionId}${c.reset}.`
2104
+ );
2105
+ console.log(
2106
+ `${c.dim}Expected file: ${focusFilePath(sessionId)}${c.reset}`
2107
+ );
2108
+ }
2109
+ break;
2110
+ }
2111
+ case "clear": {
2112
+ const rest = args.slice(1);
2113
+ const sessionId = resolveSessionId(rest);
2114
+ clearSessionFocus(sessionId);
2115
+ console.log(
2116
+ `${c.green}Focus cleared${c.reset} for session ${c.cyan}${sessionId}${c.reset}.`
2117
+ );
2118
+ break;
2119
+ }
2120
+ default:
2121
+ die(
2122
+ "Usage: clawmem focus <set|show|clear> [<topic>] [--session-id <id>]"
2123
+ );
2124
+ }
2125
+ }
2126
+
1909
2127
  // =============================================================================
1910
2128
  // Main dispatch
1911
2129
  // =============================================================================
@@ -1994,6 +2212,9 @@ async function main() {
1994
2212
  case "profile":
1995
2213
  await cmdProfile(subArgs);
1996
2214
  break;
2215
+ case "focus":
2216
+ await cmdFocus(subArgs);
2217
+ break;
1997
2218
  case "update-context":
1998
2219
  await cmdUpdateContext();
1999
2220
  break;
@@ -2644,6 +2865,9 @@ ${c.bold}Memory:${c.reset}
2644
2865
  clawmem log [--last N] Session history
2645
2866
  clawmem profile Show user profile
2646
2867
  clawmem profile rebuild Force profile rebuild
2868
+ clawmem focus set <topic> [--session-id ID] Set per-session focus topic (steers context-surfacing)
2869
+ clawmem focus show [--session-id ID] Show current focus topic
2870
+ clawmem focus clear [--session-id ID] Clear focus topic
2647
2871
 
2648
2872
  ${c.bold}Hooks:${c.reset}
2649
2873
  clawmem hook <name> Run hook (stdin JSON)
package/src/config.ts CHANGED
@@ -84,12 +84,23 @@ export interface ProfileConfig {
84
84
  deepEscalation: boolean;
85
85
  /** Max time (ms) allowed for the fast path before escalation is considered */
86
86
  escalationBudgetMs: number;
87
+ /**
88
+ * §11.1 (v0.9.0): sub-budget for the `<vault-facts>` KG injection block.
89
+ * Dedicated token allowance so `<vault-facts>` cannot steal budget from
90
+ * the existing `<facts>` / `<relationships>` blocks. `speed` profile is
91
+ * gated off (factsTokens=0 → stage skipped entirely). `balanced` / `deep`
92
+ * get 200 / 250 respectively. If the serialized facts would exceed this
93
+ * sub-budget, truncation happens at the triple boundary. If the total
94
+ * hook output would push past `tokenBudget + factsTokens`, the whole
95
+ * `<vault-facts>` block is dropped (established blocks take priority).
96
+ */
97
+ factsTokens: number;
87
98
  }
88
99
 
89
100
  export const PROFILES: Record<PerformanceProfile, ProfileConfig> = {
90
- speed: { tokenBudget: 400, maxResults: 5, useVector: false, vectorTimeout: 0, minScore: 0.55, minScoreRatio: 0.65, absoluteFloor: 0.18, activationFloor: 0.24, thresholdMode: "adaptive", deepEscalation: false, escalationBudgetMs: 0 },
91
- balanced: { tokenBudget: 800, maxResults: 10, useVector: true, vectorTimeout: 900, minScore: 0.45, minScoreRatio: 0.55, absoluteFloor: 0.15, activationFloor: 0.20, thresholdMode: "adaptive", deepEscalation: false, escalationBudgetMs: 0 },
92
- deep: { tokenBudget: 1200, maxResults: 15, useVector: true, vectorTimeout: 2000, minScore: 0.25, minScoreRatio: 0.45, absoluteFloor: 0.12, activationFloor: 0.16, thresholdMode: "adaptive", deepEscalation: true, escalationBudgetMs: 4000 },
101
+ speed: { tokenBudget: 400, maxResults: 5, useVector: false, vectorTimeout: 0, minScore: 0.55, minScoreRatio: 0.65, absoluteFloor: 0.18, activationFloor: 0.24, thresholdMode: "adaptive", deepEscalation: false, escalationBudgetMs: 0, factsTokens: 0 },
102
+ balanced: { tokenBudget: 800, maxResults: 10, useVector: true, vectorTimeout: 900, minScore: 0.45, minScoreRatio: 0.55, absoluteFloor: 0.15, activationFloor: 0.20, thresholdMode: "adaptive", deepEscalation: false, escalationBudgetMs: 0, factsTokens: 200 },
103
+ deep: { tokenBudget: 1200, maxResults: 15, useVector: true, vectorTimeout: 2000, minScore: 0.25, minScoreRatio: 0.45, absoluteFloor: 0.12, activationFloor: 0.16, thresholdMode: "adaptive", deepEscalation: true, escalationBudgetMs: 4000, factsTokens: 250 },
93
104
  };
94
105
 
95
106
  export function getActiveProfile(): ProfileConfig {
@@ -31,6 +31,12 @@ import { sanitizeSnippet } from "../promptguard.ts";
31
31
  import { shouldSkipRetrieval, isRetrievedNoise } from "../retrieval-gate.ts";
32
32
  import { MAX_QUERY_LENGTH } from "../limits.ts";
33
33
  import { writeRecallEvents, hashQuery } from "../recall-buffer.ts";
34
+ import { resolveSessionTopic, applyTopicBoost } from "../session-focus.ts";
35
+ import {
36
+ extractPromptEntities,
37
+ buildVaultFactsBlock,
38
+ type VaultFactsTriple,
39
+ } from "../vault-facts.ts";
34
40
 
35
41
  // =============================================================================
36
42
  // Config
@@ -143,6 +149,20 @@ export async function contextSurfacing(
143
149
  const tokenBudget = profile.tokenBudget;
144
150
  const startTime = Date.now();
145
151
 
152
+ // §11.4: Resolve session-scoped focus topic. Primary signal is the
153
+ // per-session focus file at ~/.cache/clawmem/sessions/<id>.focus
154
+ // (file > env var precedence via resolveSessionTopic). Env var
155
+ // CLAWMEM_SESSION_FOCUS is a debug-only override and does NOT
156
+ // provide per-session scoping on multi-session hosts. Used as
157
+ // (a) optional `intent` on expandQuery/rerank/extractSnippet call
158
+ // sites below, and (b) the driver for the post-composite topic
159
+ // boost stage. Fail-open: missing / unreadable / corrupt / empty /
160
+ // oversized focus file → undefined → every consumer no-ops.
161
+ const sessionTopic = resolveSessionTopic(
162
+ input.sessionId,
163
+ process.env.CLAWMEM_SESSION_FOCUS
164
+ );
165
+
146
166
  const isRecency = hasRecencyIntent(prompt);
147
167
  const minScore = isRecency ? MIN_COMPOSITE_SCORE_RECENCY : profile.minScore;
148
168
 
@@ -239,7 +259,7 @@ export async function contextSurfacing(
239
259
  if (elapsed < profile.escalationBudgetMs) {
240
260
  try {
241
261
  // Phase 1: Query expansion — discover candidates BM25+vector missed
242
- const expanded = await store.expandQuery(retrievalQuery, DEFAULT_QUERY_MODEL);
262
+ const expanded = await store.expandQuery(retrievalQuery, DEFAULT_QUERY_MODEL, sessionTopic);
243
263
  if (expanded.length > 0) {
244
264
  const seen = new Set(results.map(r => r.filepath));
245
265
  for (const eq of expanded.slice(0, 3)) {
@@ -263,7 +283,7 @@ export async function contextSurfacing(
263
283
  file: r.filepath,
264
284
  text: (r.body || "").slice(0, 2000),
265
285
  }));
266
- const reranked = await store.rerank(prompt, toRerank, DEFAULT_RERANK_MODEL);
286
+ const reranked = await store.rerank(prompt, toRerank, DEFAULT_RERANK_MODEL, sessionTopic);
267
287
  if (reranked.length > 0) {
268
288
  const rerankedMap = new Map(reranked.map(r => [r.file, r.score]));
269
289
  // Blend: 60% original score + 40% reranker score for stability
@@ -335,6 +355,15 @@ export async function contextSurfacing(
335
355
  // Apply composite scoring
336
356
  const allScored = applyCompositeScoring(enriched, prompt);
337
357
 
358
+ // §11.4: Session-scoped topic boost — post-composite, pre-threshold.
359
+ // Boosts docs whose title/path/body match all tokens of the declared
360
+ // session focus topic (1.4×); demotes non-matching docs (0.75×, floor
361
+ // 50%). Mutates compositeScore in place and re-sorts. Fail-open: no
362
+ // topic set → no-op (byte-identical pre-§11.4 output).
363
+ if (sessionTopic) {
364
+ applyTopicBoost(allScored, sessionTopic, { boostFactor: 1.4, demoteFactor: 0.75 });
365
+ }
366
+
338
367
  // Threshold filtering — adaptive (ratio-based) or absolute (legacy)
339
368
  let scored: typeof allScored;
340
369
  if (profile.thresholdMode === "adaptive") {
@@ -400,7 +429,7 @@ export async function contextSurfacing(
400
429
  // in afterward using whatever budget remains and are the first thing
401
430
  // truncated when the payload would overflow.
402
431
  const factsBudget = Math.max(0, tokenBudget - INSTRUCTION_TOKEN_COST);
403
- const { context, paths, tokens } = buildContext(scored, prompt, factsBudget);
432
+ const { context, paths, tokens } = buildContext(scored, prompt, factsBudget, sessionTopic);
404
433
 
405
434
  if (!context) {
406
435
  logEmptyTurn(store, input, prompt);
@@ -489,9 +518,60 @@ export async function contextSurfacing(
489
518
  );
490
519
  const vaultInner = buildVaultContextInner(context, relationSnippets, relationBudget);
491
520
 
521
+ // §11.1 (v0.9.0): `<vault-facts>` KG injection.
522
+ //
523
+ // Stage ordering (frozen in BACKLOG.md §11.1): retrieval + rerank +
524
+ // scoring + topic boost (§11.4) + threshold + diversification → build
525
+ // <facts>/<relationships> → compute remaining facts-block budget →
526
+ // inject <vault-facts> if entities resolve AND budget allows.
527
+ //
528
+ // Prompt-only seeding (HARD CONSTRAINT): entity seeds come from the
529
+ // raw user prompt ONLY, never from `surfacedDocs[i].body`, snippets,
530
+ // or any retrieval-phase field. Without this, a topic-boosted
531
+ // off-topic doc (§11.4) could pollute the facts block with facts
532
+ // about entities that have nothing to do with the user's actual
533
+ // prompt.
534
+ //
535
+ // Profile-gated via `profile.factsTokens`: `speed` profile sets this
536
+ // to 0, which naturally disables the stage. `balanced`/`deep` get a
537
+ // dedicated sub-budget that cannot steal from <facts>/<relationships>.
538
+ //
539
+ // Fail-open: any DB error, empty entity set, empty triple set, or
540
+ // budget-too-small case returns the baseline `vaultInner` unchanged
541
+ // (byte-identical pre-§11.1 output).
542
+ let vaultInnerWithFacts = vaultInner;
543
+ if (profile.factsTokens > 0) {
544
+ try {
545
+ const entities = extractPromptEntities(prompt, store.db, "default");
546
+ if (entities.length > 0) {
547
+ const queryTriples = (entityId: string): VaultFactsTriple[] =>
548
+ store
549
+ .queryEntityTriples(entityId)
550
+ .map(t => ({
551
+ subject: t.subject,
552
+ predicate: t.predicate,
553
+ object: t.object,
554
+ validTo: t.validTo,
555
+ confidence: t.confidence,
556
+ }));
557
+ const factsBlock = buildVaultFactsBlock(
558
+ entities,
559
+ queryTriples,
560
+ profile.factsTokens,
561
+ { estimateTokens }
562
+ );
563
+ if (factsBlock) {
564
+ vaultInnerWithFacts = `${vaultInner}\n${factsBlock}`;
565
+ }
566
+ }
567
+ } catch {
568
+ /* fail-open: degraded vault behaves identically to pre-§11.1 */
569
+ }
570
+ }
571
+
492
572
  const parts: string[] = [];
493
573
  if (routingHint) parts.push(`<vault-routing>${routingHint}</vault-routing>`);
494
- parts.push(`<vault-context>\n${vaultInner}\n</vault-context>`);
574
+ parts.push(`<vault-context>\n${vaultInnerWithFacts}\n</vault-context>`);
495
575
  if (nudge) parts.push(`<vault-nudge>${NUDGE_TEXT}</vault-nudge>`);
496
576
 
497
577
  return makeContextOutput("context-surfacing", parts.join("\n"));
@@ -552,7 +632,8 @@ function detectRoutingHint(prompt: string): string | null {
552
632
  function buildContext(
553
633
  scored: ScoredResult[],
554
634
  query: string,
555
- budget: number = DEFAULT_TOKEN_BUDGET
635
+ budget: number = DEFAULT_TOKEN_BUDGET,
636
+ intent?: string
556
637
  ): { context: string; paths: string[]; tokens: number } {
557
638
  const lines: string[] = [];
558
639
  const paths: string[] = [];
@@ -579,7 +660,7 @@ function buildContext(
579
660
  if (sanitized === "[content filtered for security]") continue;
580
661
 
581
662
  const snippet = smartTruncate(
582
- extractSnippet(sanitized, query, tier.snippetLen, r.chunkPos).snippet,
663
+ extractSnippet(sanitized, query, tier.snippetLen, r.chunkPos, intent).snippet,
583
664
  tier.snippetLen
584
665
  );
585
666
  entry = `**${safeTitle}**${typeTag}\n${safePath}\n${snippet}`;