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/AGENTS.md +20 -4
- package/CLAUDE.md +20 -4
- package/README.md +25 -16
- package/SKILL.md +27 -6
- package/package.json +2 -2
- package/src/clawmem.ts +247 -23
- package/src/config.ts +14 -3
- package/src/hooks/context-surfacing.ts +87 -6
- package/src/openclaw/compaction-threshold.ts +166 -0
- package/src/openclaw/engine.ts +520 -241
- package/src/openclaw/index.ts +151 -140
- package/src/openclaw/openclaw.plugin.json +4 -1
- package/src/openclaw/package.json +9 -0
- package/src/openclaw/session-state.ts +55 -0
- package/src/openclaw/transcript-resolver.ts +441 -0
- package/src/session-focus.ts +227 -0
- package/src/store.ts +5 -0
- package/src/vault-facts.ts +506 -0
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
|
-
|
|
1337
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1363
|
-
|
|
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
|
-
|
|
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
|
|
1395
|
-
//
|
|
1396
|
-
//
|
|
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.
|
|
1401
|
-
console.log(` ${c.cyan}openclaw
|
|
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.
|
|
1404
|
-
console.log(` ${c.cyan}openclaw
|
|
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${
|
|
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}`;
|