sessionmem 1.0.5 → 1.1.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/LICENSE +21 -21
- package/README.md +372 -365
- package/dist/adapters/capabilities/fallbackTools.js +33 -18
- package/dist/adapters/claudeMdInjector.js +164 -0
- package/dist/adapters/factory.js +68 -9
- package/dist/adapters/generic.js +221 -15
- package/dist/adapters/global/antigravity.js +14 -7
- package/dist/adapters/global/claudeCode.js +46 -10
- package/dist/adapters/global/codex.js +73 -13
- package/dist/adapters/global/qcoder.js +18 -5
- package/dist/adapters/ide/cline.js +54 -9
- package/dist/adapters/ide/cursor.js +15 -13
- package/dist/adapters/ide/installer.js +201 -8
- package/dist/adapters/ide/windsurf.js +14 -13
- package/dist/adapters/tools/ping.js +4 -1
- package/dist/cli/commands/config.js +10 -1
- package/dist/cli/commands/import.js +6 -1
- package/dist/cli/commands/install.js +63 -5
- package/dist/cli/commands/ping.js +42 -8
- package/dist/cli/commands/reEmbed.js +48 -0
- package/dist/cli/commands/run.js +18 -2
- package/dist/cli/commands/savings.js +91 -0
- package/dist/cli/commands/sessionEnd.js +124 -0
- package/dist/cli/commands/sessionStart.js +52 -0
- package/dist/cli/commands/sync.js +39 -9
- package/dist/cli/commands/uninstall.js +37 -1
- package/dist/cli/context.js +14 -18
- package/dist/cli/index.js +30 -4
- package/dist/cli/output.js +11 -3
- package/dist/cli/projectId.js +69 -0
- package/dist/core/api/contracts.js +182 -45
- package/dist/core/api/errors.js +4 -7
- package/dist/core/api/memoryCoreService.js +409 -240
- package/dist/core/api/sessionLifecycleService.js +20 -2
- package/dist/core/config/policyConfig.js +53 -6
- package/dist/core/injection/formatStartupInjection.js +55 -10
- package/dist/core/injection/tokenBudget.js +8 -0
- package/dist/core/retrieve/importance.js +4 -3
- package/dist/core/retrieve/recencyBands.js +6 -10
- package/dist/core/retrieve/retrieveMemories.js +19 -4
- package/dist/core/retrieve/score.js +11 -1
- package/dist/core/schema/migrations/005_team_provenance.sql +14 -9
- package/dist/core/schema/migrations/006_access_pattern_boosting.sql +10 -0
- package/dist/core/schema/migrations/007_feedback_manual_delete.sql +23 -0
- package/dist/core/schema/migrations/008_fts5_search.sql +37 -0
- package/dist/core/schema/migrations/009_session_events_unique.sql +24 -0
- package/dist/core/schema/runMigrations.js +64 -2
- package/dist/core/storage/db.js +6 -0
- package/dist/core/storage/memoryFeedbackRepo.js +14 -4
- package/dist/core/storage/memoryRepo.js +292 -121
- package/dist/core/storage/memorySearchRepo.js +125 -13
- package/dist/core/storage/sessionEventsRepo.js +33 -10
- package/dist/core/storage/summarizationFailuresRepo.js +36 -26
- package/dist/core/storage/tokenSavingsRepo.js +20 -0
- package/dist/core/summarize/cloudSummarizer.js +34 -5
- package/dist/core/summarize/localSummarizer.js +1 -10
- package/dist/core/summarize/redaction.js +45 -8
- package/package.json +50 -48
|
@@ -20,6 +20,13 @@ function coerceRetentionDays(raw) {
|
|
|
20
20
|
}
|
|
21
21
|
return n;
|
|
22
22
|
}
|
|
23
|
+
function coerceInjectionCap(raw) {
|
|
24
|
+
const n = coerceInt(raw);
|
|
25
|
+
if (n < 100 || n > 10000) {
|
|
26
|
+
throw new Error(`injectionCap must be an integer between 100 and 10000, got "${raw}"`);
|
|
27
|
+
}
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
23
30
|
function coerceBool(raw) {
|
|
24
31
|
const v = raw.trim().toLowerCase();
|
|
25
32
|
if (v === "true")
|
|
@@ -34,6 +41,7 @@ const CONFIG_KEYS = {
|
|
|
34
41
|
"retention.days": { field: "retentionDays", coerce: coerceRetentionDays },
|
|
35
42
|
retentionDays: { field: "retentionDays", coerce: coerceRetentionDays },
|
|
36
43
|
redactionEnabled: { field: "redactionEnabled", coerce: coerceBool },
|
|
44
|
+
injectionCap: { field: "injectionCap", coerce: coerceInjectionCap },
|
|
37
45
|
};
|
|
38
46
|
function resolvePath(options) {
|
|
39
47
|
return options?.configPath ?? configFilePath();
|
|
@@ -50,7 +58,8 @@ export function configGetCommand(key, options) {
|
|
|
50
58
|
return;
|
|
51
59
|
}
|
|
52
60
|
const config = readPolicyConfig(resolvePath(options));
|
|
53
|
-
|
|
61
|
+
const val = config[def.field];
|
|
62
|
+
console.log(val === undefined ? "(not set)" : String(val));
|
|
54
63
|
}
|
|
55
64
|
/**
|
|
56
65
|
* `sessionmem config set <key> <value>` — coerce and persist to config.json.
|
|
@@ -73,7 +73,9 @@ export async function importCommand(pathArg, options, ctx) {
|
|
|
73
73
|
invalidCount += 1;
|
|
74
74
|
continue;
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
// Use the parsed/transformed record so `kind` is narrowed to the
|
|
77
|
+
// canonical enum (memoryKindSchema maps legacy values like `architecture`).
|
|
78
|
+
validMemories.push(check.data);
|
|
77
79
|
}
|
|
78
80
|
if (validMemories.length === 0) {
|
|
79
81
|
if (invalidCount > 0) {
|
|
@@ -100,6 +102,9 @@ export async function importCommand(pathArg, options, ctx) {
|
|
|
100
102
|
let suffix = result.skippedCrossProject > 0
|
|
101
103
|
? ` (${result.skippedCrossProject} skipped: id belongs to another project)`
|
|
102
104
|
: "";
|
|
105
|
+
if (result.skippedExisting > 0) {
|
|
106
|
+
suffix += ` (${result.skippedExisting} skipped: id already exists in this project)`;
|
|
107
|
+
}
|
|
103
108
|
if (invalidCount > 0) {
|
|
104
109
|
suffix += ` (${invalidCount} invalid record(s) skipped)`;
|
|
105
110
|
}
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
2
4
|
import { AdapterFactory } from "../../adapters/factory.js";
|
|
5
|
+
import { injectGuidanceBlock } from "../../adapters/claudeMdInjector.js";
|
|
3
6
|
import { createCliContext } from "../context.js";
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the adapter to install into: an explicit `--adapter` flag wins, then
|
|
9
|
+
* the SESSIONMEM_ADAPTER env override, else auto-detection from the host env.
|
|
10
|
+
* Throws (via AdapterFactory.forName) on an unknown explicit name so the CLI
|
|
11
|
+
* surfaces a clear error rather than silently installing the generic adapter.
|
|
12
|
+
*/
|
|
13
|
+
function resolveAdapter(options) {
|
|
14
|
+
const explicit = options?.adapter && options.adapter.trim() !== ""
|
|
15
|
+
? options.adapter.trim()
|
|
16
|
+
: process.env.SESSIONMEM_ADAPTER && process.env.SESSIONMEM_ADAPTER.trim() !== ""
|
|
17
|
+
? process.env.SESSIONMEM_ADAPTER.trim()
|
|
18
|
+
: undefined;
|
|
19
|
+
if (explicit) {
|
|
20
|
+
return { adapter: AdapterFactory.forName(explicit), forced: true };
|
|
21
|
+
}
|
|
22
|
+
return { adapter: AdapterFactory.detectAdapter(), forced: false };
|
|
23
|
+
}
|
|
4
24
|
import { configFilePath, writePolicyConfig, DEFAULT_POLICY_CONFIG, } from "../../core/config/policyConfig.js";
|
|
5
25
|
export const MANUAL_CONFIG_BLOCK = JSON.stringify({
|
|
6
26
|
mcpServers: {
|
|
@@ -14,7 +34,18 @@ export function printManualFallback(adapterName) {
|
|
|
14
34
|
console.error(`Auto-config for ${adapterName} failed. Add this block to your MCP config manually:`);
|
|
15
35
|
console.log(MANUAL_CONFIG_BLOCK);
|
|
16
36
|
}
|
|
17
|
-
export async function installCommand(
|
|
37
|
+
export async function installCommand(options, contextOverrides) {
|
|
38
|
+
// Resolve the target adapter up front so an invalid `--adapter` fails before
|
|
39
|
+
// any filesystem work (DB init, config write).
|
|
40
|
+
let adapter;
|
|
41
|
+
let forced;
|
|
42
|
+
try {
|
|
43
|
+
({ adapter, forced } = resolveAdapter(options));
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
18
49
|
// Step 1: DB init — run migrations and confirm DB init
|
|
19
50
|
let dbPath;
|
|
20
51
|
try {
|
|
@@ -38,8 +69,12 @@ export async function installCommand(_options, contextOverrides) {
|
|
|
38
69
|
writePolicyConfig(configPath, { ...DEFAULT_POLICY_CONFIG });
|
|
39
70
|
console.log(`✓ config.json initialized (${configPath})`);
|
|
40
71
|
}
|
|
41
|
-
// Step 2: Adapter config —
|
|
42
|
-
|
|
72
|
+
// Step 2: Adapter config — install into the resolved adapter. Print which
|
|
73
|
+
// adapter was selected and how, so the user can see whether auto-detection
|
|
74
|
+
// picked the host they expected (and re-run with `--adapter` if not).
|
|
75
|
+
console.log(forced
|
|
76
|
+
? `→ Installing for adapter: ${adapter.name} (forced)`
|
|
77
|
+
: `→ Detected host: ${adapter.name} (override with --adapter <name>)`);
|
|
43
78
|
if (!adapter.install) {
|
|
44
79
|
console.error(`✗ ${adapter.name} config update failed`);
|
|
45
80
|
printManualFallback(adapter.name);
|
|
@@ -52,6 +87,29 @@ export async function installCommand(_options, contextOverrides) {
|
|
|
52
87
|
process.exit(1);
|
|
53
88
|
}
|
|
54
89
|
console.log(`✓ ${adapter.name} config updated`);
|
|
55
|
-
// Step 3:
|
|
56
|
-
|
|
90
|
+
// Step 3: Guidance injection — non-fatal. Inject the sessionmem instruction
|
|
91
|
+
// block into the file(s) the detected host actually reads at session start so
|
|
92
|
+
// the agent automatically knows the MCP exists and how to use it. Adapters
|
|
93
|
+
// declare their own target(s); when none are declared (e.g. a minimal mock or
|
|
94
|
+
// an unknown host) fall back to the global Claude Code memory file.
|
|
95
|
+
const guidanceTargets = adapter.guidanceTargets?.() ?? [join(homedir(), ".claude", "CLAUDE.md")];
|
|
96
|
+
for (const target of guidanceTargets) {
|
|
97
|
+
try {
|
|
98
|
+
const injected = injectGuidanceBlock(target);
|
|
99
|
+
if (injected) {
|
|
100
|
+
console.log(`✓ Agent guidance injected (${target})`);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
console.error(`✗ Agent guidance injection failed (non-fatal): ${target}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
console.error(`✗ Agent guidance injection failed (non-fatal): ${target}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Step 4: Full success checklist
|
|
111
|
+
if (adapter.name === "Claude Code") {
|
|
112
|
+
console.log("✓ Auto-injection hook installed (~/.claude/settings.json) — prior memories load automatically at the start of every Claude Code session");
|
|
113
|
+
}
|
|
114
|
+
console.log("✓ sessionmem ready — restart your editor/agent so it picks up the MCP server");
|
|
57
115
|
}
|
|
@@ -1,12 +1,46 @@
|
|
|
1
1
|
import { pingTool } from "../../adapters/tools/ping.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import { createCliContext } from "../context.js";
|
|
3
|
+
/**
|
|
4
|
+
* `sessionmem ping` — report the local version AND verify the memory store is
|
|
5
|
+
* actually reachable.
|
|
6
|
+
*
|
|
7
|
+
* The previous implementation only echoed the package version and always
|
|
8
|
+
* printed "ok", so a broken/locked database still reported healthy. This opens
|
|
9
|
+
* the DB (running migrations) and executes a trivial query; a failure is
|
|
10
|
+
* surfaced as a non-ok status and a non-zero exit code.
|
|
11
|
+
*/
|
|
12
|
+
export async function pingCommand(ctx) {
|
|
13
|
+
const versionResult = await pingTool.execute();
|
|
14
|
+
console.log(`version: ${versionResult.version}`);
|
|
15
|
+
let context;
|
|
16
|
+
try {
|
|
17
|
+
context = ctx ?? createCliContext();
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
console.log("status: error");
|
|
21
|
+
console.log(`message: database could not be opened: ${err instanceof Error ? err.message : String(err)}`);
|
|
22
|
+
console.error("sessionmem ping failed: database unreachable");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
// Trivial round-trip through the service layer proves migrations ran and
|
|
27
|
+
// the DB answers queries for the current project.
|
|
28
|
+
const result = await context.service.call("stats", {
|
|
29
|
+
projectId: context.projectId,
|
|
30
|
+
});
|
|
31
|
+
if (!result.ok) {
|
|
32
|
+
console.log("status: error");
|
|
33
|
+
console.log(`message: database query failed: ${result.error.message}`);
|
|
34
|
+
console.error("sessionmem ping failed: database query failed");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
console.log("status: error");
|
|
40
|
+
console.log(`message: database query failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
41
|
+
console.error("sessionmem ping failed: database query failed");
|
|
9
42
|
process.exit(1);
|
|
10
43
|
}
|
|
11
|
-
|
|
44
|
+
console.log("status: ok");
|
|
45
|
+
console.log("message: sessionmem is operational (database reachable).");
|
|
12
46
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createCliContext } from "../context.js";
|
|
2
|
+
import { deterministicEmbed } from "../../core/embed/deterministicEmbed.js";
|
|
3
|
+
import { EMBEDDING_VERSION } from "../../core/embed/embeddingVersion.js";
|
|
4
|
+
const DEFAULT_EMBEDDING_DIMENSION = 32;
|
|
5
|
+
/**
|
|
6
|
+
* `sessionmem re-embed`
|
|
7
|
+
*
|
|
8
|
+
* Bulk-update embeddings for all memories whose embedding_version does not
|
|
9
|
+
* match the current EMBEDDING_VERSION. Recomputes each embedding with
|
|
10
|
+
* deterministicEmbed and writes the new vector + version back to the row.
|
|
11
|
+
*/
|
|
12
|
+
export async function reEmbedCommand(ctx) {
|
|
13
|
+
const context = ctx ?? createCliContext();
|
|
14
|
+
const { db, projectId } = context;
|
|
15
|
+
const stale = db
|
|
16
|
+
.prepare(`
|
|
17
|
+
SELECT id, content, embedding_dim
|
|
18
|
+
FROM memories
|
|
19
|
+
WHERE project_id = ?
|
|
20
|
+
AND (embedding_version IS NULL OR embedding_version != ?)
|
|
21
|
+
`)
|
|
22
|
+
.all(projectId, EMBEDDING_VERSION);
|
|
23
|
+
const total = stale.length;
|
|
24
|
+
if (total === 0) {
|
|
25
|
+
console.log("All embeddings are up to date.");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
console.log(`Found ${total} memories with stale embeddings. Re-embedding...`);
|
|
29
|
+
const updateStmt = db.prepare(`
|
|
30
|
+
UPDATE memories
|
|
31
|
+
SET embedding = ?, embedding_dim = ?, embedding_version = ?
|
|
32
|
+
WHERE id = ?
|
|
33
|
+
`);
|
|
34
|
+
let count = 0;
|
|
35
|
+
const runAll = db.transaction(() => {
|
|
36
|
+
for (const row of stale) {
|
|
37
|
+
const dim = row.embedding_dim ?? DEFAULT_EMBEDDING_DIMENSION;
|
|
38
|
+
const result = deterministicEmbed(row.content, dim);
|
|
39
|
+
updateStmt.run(JSON.stringify(result.vector), result.dimension, EMBEDDING_VERSION, row.id);
|
|
40
|
+
count += 1;
|
|
41
|
+
if (count % 100 === 0 || count === total) {
|
|
42
|
+
console.log(` ${count}/${total}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
runAll();
|
|
47
|
+
console.log(`Re-embedded ${count} memories to version ${EMBEDDING_VERSION}.`);
|
|
48
|
+
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -2,12 +2,24 @@ import { AdapterFactory } from "../../adapters/factory.js";
|
|
|
2
2
|
import { mkdirSync, writeFileSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { homedir } from "os";
|
|
5
|
+
import { deriveProjectId } from "../projectId.js";
|
|
6
|
+
import { expandTilde } from "../context.js";
|
|
5
7
|
export async function runMcpServer() {
|
|
6
8
|
const adapter = AdapterFactory.detectAdapter();
|
|
7
|
-
//
|
|
9
|
+
// Startup diagnostics: written to ~/.sessionmem/logs/mcp.log
|
|
8
10
|
const logDir = join(homedir(), ".sessionmem", "logs");
|
|
9
11
|
const logPath = join(logDir, "mcp.log");
|
|
10
|
-
|
|
12
|
+
// Derive db path for diagnostics (mirrors context.ts defaultDbPath)
|
|
13
|
+
const envDbPath = process.env.SESSIONMEM_DB_PATH;
|
|
14
|
+
const dbPath = envDbPath && envDbPath.trim() !== ""
|
|
15
|
+
? expandTilde(envDbPath)
|
|
16
|
+
: join(homedir(), ".sessionmem", "memories.db");
|
|
17
|
+
// Derive project ID for diagnostics. Shares the exact derivation the server
|
|
18
|
+
// uses (createCliContext → deriveProjectId) so the logged id never diverges
|
|
19
|
+
// from the bucket the server actually serves.
|
|
20
|
+
const projectId = deriveProjectId();
|
|
21
|
+
const adapterName = adapter.name;
|
|
22
|
+
const logMessage = `[${new Date().toISOString()}] Started sessionmem | adapter=${adapterName} db=${dbPath} project=${projectId}\n`;
|
|
11
23
|
try {
|
|
12
24
|
mkdirSync(logDir, { recursive: true });
|
|
13
25
|
writeFileSync(logPath, logMessage, { flag: "a" });
|
|
@@ -15,6 +27,10 @@ export async function runMcpServer() {
|
|
|
15
27
|
catch {
|
|
16
28
|
// best-effort logging; ignore failures
|
|
17
29
|
}
|
|
30
|
+
// Debug output to stderr (never stdout — that's the MCP protocol channel)
|
|
31
|
+
if (process.env.SESSIONMEM_DEBUG === "1") {
|
|
32
|
+
process.stderr.write(`[sessionmem] db=${dbPath} project=${projectId} adapter=${adapterName}\n`);
|
|
33
|
+
}
|
|
18
34
|
// Start the server
|
|
19
35
|
if (adapter.startMcpServer) {
|
|
20
36
|
await adapter.startMcpServer();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createCliContext } from "../context.js";
|
|
2
|
+
import { countTokens } from "../../core/injection/tokenBudget.js";
|
|
3
|
+
import { listMemoryContentsByProject } from "../../core/storage/memoryRepo.js";
|
|
4
|
+
import { countDistinctSessions, listEventPayloads, } from "../../core/storage/tokenSavingsRepo.js";
|
|
5
|
+
import { readPolicyConfig, configFilePath, } from "../../core/config/policyConfig.js";
|
|
6
|
+
/** Default injection token cap, mirroring formatStartupInjection.ts. */
|
|
7
|
+
const DEFAULT_INJECTION_CAP = 450;
|
|
8
|
+
export function savingsCommand(ctx, options) {
|
|
9
|
+
const context = ctx ?? createCliContext();
|
|
10
|
+
const { db, projectId } = context;
|
|
11
|
+
// --- gather raw numbers ---
|
|
12
|
+
const memoryTokens = listMemoryContentsByProject(db, projectId).reduce((sum, content) => sum + countTokens(content), 0);
|
|
13
|
+
const rawEventTokens = listEventPayloads(db, projectId).reduce((sum, p) => sum + countTokens(p), 0);
|
|
14
|
+
const sessions = countDistinctSessions(db, projectId);
|
|
15
|
+
// Read the injection cap from policy config if it exists; fall back to 450.
|
|
16
|
+
const policyConfig = readPolicyConfig(options?.configPath ?? configFilePath());
|
|
17
|
+
const injectionCap = policyConfig.injectionCap ?? DEFAULT_INJECTION_CAP;
|
|
18
|
+
// --- calculations ---
|
|
19
|
+
const tokensSaved = rawEventTokens - memoryTokens;
|
|
20
|
+
// Clamp for display. When memories exist but no session events were ingested
|
|
21
|
+
// (rawEventTokens === 0), the raw subtraction goes negative, which is
|
|
22
|
+
// misleading. Never present a negative "tokens saved" to the user.
|
|
23
|
+
const displayedTokensSaved = Math.max(0, tokensSaved);
|
|
24
|
+
const savingsPct = rawEventTokens > 0 ? (tokensSaved / rawEventTokens) * 100 : 0;
|
|
25
|
+
const estimatedReexplainTokens = memoryTokens * 3;
|
|
26
|
+
const injectionSavings = estimatedReexplainTokens - sessions * injectionCap;
|
|
27
|
+
const overallSaved = tokensSaved + Math.max(0, injectionSavings);
|
|
28
|
+
const displayedOverallSaved = Math.max(0, overallSaved);
|
|
29
|
+
const overallPct = rawEventTokens + estimatedReexplainTokens > 0
|
|
30
|
+
? (overallSaved / (rawEventTokens + estimatedReexplainTokens)) * 100
|
|
31
|
+
: 0;
|
|
32
|
+
const displayedOverallPct = displayedOverallSaved > 0 ? overallPct : 0;
|
|
33
|
+
// per-session injection overhead (fixed cap, not a measured average)
|
|
34
|
+
const avgInjectionCost = sessions > 0 ? injectionCap : 0;
|
|
35
|
+
// --- JSON output ---
|
|
36
|
+
if (options?.json) {
|
|
37
|
+
const payload = {
|
|
38
|
+
memoryTokens,
|
|
39
|
+
rawEventTokens,
|
|
40
|
+
tokensSaved: displayedTokensSaved,
|
|
41
|
+
savingsPct: Math.round(savingsPct * 10) / 10,
|
|
42
|
+
sessions,
|
|
43
|
+
injectionCap,
|
|
44
|
+
estimatedReexplainTokens,
|
|
45
|
+
injectionSavings,
|
|
46
|
+
overallSaved: displayedOverallSaved,
|
|
47
|
+
overallPct: Math.round(displayedOverallPct * 10) / 10,
|
|
48
|
+
};
|
|
49
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// --- empty state ---
|
|
53
|
+
if (rawEventTokens === 0 && memoryTokens === 0) {
|
|
54
|
+
process.stdout.write("No session data yet. Token savings will appear after your first session.\n");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// --- formatted report ---
|
|
58
|
+
const fmt = (n) => n.toLocaleString("en-US");
|
|
59
|
+
// When no session events have been ingested, the storage-compression numbers
|
|
60
|
+
// are meaningless (and "tokens saved" would be negative). Show a helpful note
|
|
61
|
+
// instead of misleading figures.
|
|
62
|
+
const storageLines = rawEventTokens === 0
|
|
63
|
+
? [
|
|
64
|
+
"Storage compression:",
|
|
65
|
+
" No session data ingested yet.",
|
|
66
|
+
" Tip: call ingestSessionEvents during sessions to track token usage.",
|
|
67
|
+
]
|
|
68
|
+
: [
|
|
69
|
+
"Storage compression:",
|
|
70
|
+
` Raw session tokens: ${fmt(rawEventTokens).padStart(10)}`,
|
|
71
|
+
` Memory tokens: ${fmt(memoryTokens).padStart(10)}`,
|
|
72
|
+
` Tokens saved: ${fmt(displayedTokensSaved).padStart(10)} (${(Math.round(savingsPct * 10) / 10).toFixed(1)}%)`,
|
|
73
|
+
];
|
|
74
|
+
const lines = [
|
|
75
|
+
"sessionmem token savings",
|
|
76
|
+
"",
|
|
77
|
+
...storageLines,
|
|
78
|
+
"",
|
|
79
|
+
"Session injection:",
|
|
80
|
+
` Total sessions: ${fmt(sessions).padStart(10)}`,
|
|
81
|
+
` Avg injection cost: ${fmt(avgInjectionCost).padStart(10)} tokens/session`,
|
|
82
|
+
` Est. re-explain cost:${fmt(estimatedReexplainTokens).padStart(10)} tokens (without sessionmem)`,
|
|
83
|
+
` Injection savings: ${fmt(Math.max(0, injectionSavings)).padStart(10)} tokens across ${fmt(sessions)} sessions`,
|
|
84
|
+
"",
|
|
85
|
+
"Overall:",
|
|
86
|
+
` Total tokens saved: ${fmt(displayedOverallSaved).padStart(10)}`,
|
|
87
|
+
` Efficiency: ${(Math.round(displayedOverallPct * 10) / 10).toFixed(1)}%`,
|
|
88
|
+
"",
|
|
89
|
+
];
|
|
90
|
+
process.stdout.write(lines.join("\n"));
|
|
91
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createCliContext } from "../context.js";
|
|
2
|
+
import { handleSessionEndConfigSchema } from "../../core/api/contracts.js";
|
|
3
|
+
/**
|
|
4
|
+
* Read the Claude Code hook JSON payload from stdin, if any. The `SessionEnd`
|
|
5
|
+
* hook pipes a JSON object that includes `session_id`, `cwd`, and `reason`. When
|
|
6
|
+
* the command is run interactively (TTY) or no payload arrives, returns `{}`.
|
|
7
|
+
*
|
|
8
|
+
* A short timeout guards against a non-TTY stdin that never reaches EOF so the
|
|
9
|
+
* hook can never hang and block the session from ending.
|
|
10
|
+
*/
|
|
11
|
+
async function readHookPayload() {
|
|
12
|
+
if (process.stdin.isTTY) {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
const raw = await new Promise((resolve) => {
|
|
16
|
+
let data = "";
|
|
17
|
+
let settled = false;
|
|
18
|
+
const finish = () => {
|
|
19
|
+
if (settled)
|
|
20
|
+
return;
|
|
21
|
+
settled = true;
|
|
22
|
+
resolve(data);
|
|
23
|
+
};
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
// The non-TTY stdin never reached EOF. Detach our listeners and pause the
|
|
26
|
+
// stream so the dangling read can't keep the event loop alive (which would
|
|
27
|
+
// hang the hook), then resolve with whatever was buffered.
|
|
28
|
+
process.stdin.removeAllListeners();
|
|
29
|
+
process.stdin.pause();
|
|
30
|
+
finish();
|
|
31
|
+
}, 500);
|
|
32
|
+
timer.unref?.();
|
|
33
|
+
process.stdin.setEncoding("utf8");
|
|
34
|
+
process.stdin.on("data", (chunk) => {
|
|
35
|
+
data += chunk;
|
|
36
|
+
});
|
|
37
|
+
process.stdin.on("end", () => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
finish();
|
|
40
|
+
});
|
|
41
|
+
process.stdin.on("error", () => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
finish();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
if (raw.trim() === "") {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
return parsed && typeof parsed === "object"
|
|
52
|
+
? parsed
|
|
53
|
+
: {};
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the session id for the session-end pipeline. Prefers the hook
|
|
61
|
+
* payload's `session_id`, then the Claude Code `CLAUDE_CODE_SESSION_ID` env var
|
|
62
|
+
* (set inside a Claude Code session), and finally a stable per-day fallback so
|
|
63
|
+
* the retention prune still runs even with no session context.
|
|
64
|
+
*/
|
|
65
|
+
function resolveSessionId(payload) {
|
|
66
|
+
const fromPayload = payload.session_id;
|
|
67
|
+
if (typeof fromPayload === "string" && fromPayload.trim() !== "") {
|
|
68
|
+
return fromPayload.trim();
|
|
69
|
+
}
|
|
70
|
+
const fromEnv = process.env.CLAUDE_CODE_SESSION_ID;
|
|
71
|
+
if (fromEnv && fromEnv.trim() !== "") {
|
|
72
|
+
return fromEnv.trim();
|
|
73
|
+
}
|
|
74
|
+
return `session-end-${new Date().toISOString().slice(0, 10)}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* `sessionmem session-end` — the deterministic write-side counterpart to
|
|
78
|
+
* `session-start`. Installed as a Claude Code `SessionEnd` hook so the
|
|
79
|
+
* session-end pipeline runs automatically once per session:
|
|
80
|
+
* 1. Auto-summarize any ingested session events into a durable memory.
|
|
81
|
+
* 2. Run a light retention prune of memories older than the retention window.
|
|
82
|
+
*
|
|
83
|
+
* Then prints a one-line human summary of what happened. Every error is
|
|
84
|
+
* swallowed so a session can never be blocked from ending.
|
|
85
|
+
*/
|
|
86
|
+
export async function sessionEndCommand(ctx) {
|
|
87
|
+
try {
|
|
88
|
+
const payload = ctx ? {} : await readHookPayload();
|
|
89
|
+
const context = ctx ?? createCliContext();
|
|
90
|
+
const sessionId = resolveSessionId(payload);
|
|
91
|
+
const result = await context.service.call("handleSessionEnd", {
|
|
92
|
+
projectId: context.projectId,
|
|
93
|
+
sessionId,
|
|
94
|
+
sourceAdapter: "sessionmem-cli",
|
|
95
|
+
// Resolve the default config (autoSummarize on, threshold 3 events, local
|
|
96
|
+
// summarizer; cloud summarization stays opt-in) so the call matches the
|
|
97
|
+
// request type without relying on the schema default at the call site.
|
|
98
|
+
config: handleSessionEndConfigSchema.parse({}),
|
|
99
|
+
});
|
|
100
|
+
if (!result.ok) {
|
|
101
|
+
// Never block session end — report quietly to stderr only.
|
|
102
|
+
process.stderr.write(`[sessionmem] session-end: ${result.error.message}\n`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
switch (result.status) {
|
|
106
|
+
case "stored":
|
|
107
|
+
console.log(`sessionmem: session summary stored (${result.usedMode}); retention prune applied.`);
|
|
108
|
+
break;
|
|
109
|
+
case "skipped_threshold":
|
|
110
|
+
console.log("sessionmem: not enough session events to summarize; retention prune applied.");
|
|
111
|
+
break;
|
|
112
|
+
case "skipped_disabled":
|
|
113
|
+
console.log("sessionmem: auto-summarization disabled; retention prune applied.");
|
|
114
|
+
break;
|
|
115
|
+
case "failed":
|
|
116
|
+
console.log("sessionmem: session summarization failed (recorded); retention prune applied.");
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
// Best-effort: a failure here must never fail the session-end hook.
|
|
122
|
+
process.stderr.write(`[sessionmem] session-end skipped: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createCliContext } from "../context.js";
|
|
2
|
+
/**
|
|
3
|
+
* Generic startup query. When few memories contain these literal tokens the
|
|
4
|
+
* FTS pre-filter falls back to the importance/recency candidate set
|
|
5
|
+
* (searchMemoryCandidatesFTS → searchMemoryCandidates), so the injection
|
|
6
|
+
* surfaces the most important and recent memories rather than nothing.
|
|
7
|
+
*/
|
|
8
|
+
const STARTUP_QUERY = "session startup context recent decisions architecture warnings preferences";
|
|
9
|
+
/**
|
|
10
|
+
* Emit prior project memories as Claude Code SessionStart hook output.
|
|
11
|
+
*
|
|
12
|
+
* This is the deterministic auto-injection path the user was missing: the
|
|
13
|
+
* installed `SessionStart` hook runs `sessionmem session-start` at the start of
|
|
14
|
+
* every Claude Code session and Claude Code adds this command's
|
|
15
|
+
* `additionalContext` to the conversation — with zero reliance on the agent
|
|
16
|
+
* choosing to call the `startup_inject_memories` tool.
|
|
17
|
+
*
|
|
18
|
+
* Contract notes:
|
|
19
|
+
* - ONLY the JSON envelope is written to stdout; diagnostics never touch
|
|
20
|
+
* stdout (Claude Code parses stdout as the hook result).
|
|
21
|
+
* - The command must never fail a session start: every error is swallowed and
|
|
22
|
+
* results in empty output (the session simply starts without injected
|
|
23
|
+
* context).
|
|
24
|
+
* - When there are no memories yet, nothing is emitted so a fresh project
|
|
25
|
+
* starts clean.
|
|
26
|
+
*/
|
|
27
|
+
export async function sessionStartCommand(ctx) {
|
|
28
|
+
try {
|
|
29
|
+
const context = ctx ?? createCliContext();
|
|
30
|
+
const result = await context.service.call("retrieveMemories", {
|
|
31
|
+
projectId: context.projectId,
|
|
32
|
+
query: STARTUP_QUERY,
|
|
33
|
+
limit: 20,
|
|
34
|
+
mode: "auto",
|
|
35
|
+
depth: "default",
|
|
36
|
+
});
|
|
37
|
+
if (result.ok &&
|
|
38
|
+
result.total > 0 &&
|
|
39
|
+
result.startupInjection.trim() !== "") {
|
|
40
|
+
const payload = {
|
|
41
|
+
hookSpecificOutput: {
|
|
42
|
+
hookEventName: "SessionStart",
|
|
43
|
+
additionalContext: result.startupInjection,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
process.stdout.write(JSON.stringify(payload));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Never block session start — emit nothing on any failure.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { join } from "path";
|
|
2
|
-
import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "fs";
|
|
2
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, } from "fs";
|
|
3
3
|
import { configFilePath, readPolicyConfig, } from "../../core/config/policyConfig.js";
|
|
4
4
|
import { importMemoryRecordSchema } from "../../core/api/contracts.js";
|
|
5
5
|
import { createCliContext } from "../context.js";
|
|
6
|
+
/**
|
|
7
|
+
* Skip any teammate file larger than this. A network/shared drive can host an
|
|
8
|
+
* arbitrarily large (or maliciously huge) file; reading it whole with
|
|
9
|
+
* `readFileSync` would balloon memory. Skip-and-warn instead, matching the
|
|
10
|
+
* resilient pull semantics for unreadable/non-array files.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_TEAMMATE_FILE_BYTES = 10 * 1024 * 1024; // 10MB
|
|
13
|
+
/**
|
|
14
|
+
* `pullMemoriesRequestSchema` rejects any batch over MAX_IMPORT_SIZE (1000)
|
|
15
|
+
* records, so accumulating every teammate's memories into one array would let a
|
|
16
|
+
* large team blow the cap and discard the whole pull. Send in chunks of this
|
|
17
|
+
* size instead.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_BATCH = 1000;
|
|
6
20
|
/**
|
|
7
21
|
* `sessionmem sync` — push a full snapshot of local project memories to the
|
|
8
22
|
* shared path and pull every teammate's snapshot back into the local DB.
|
|
@@ -70,14 +84,27 @@ export async function syncCommand(ctx, options) {
|
|
|
70
84
|
}
|
|
71
85
|
const memories = [];
|
|
72
86
|
for (const file of teammateFiles) {
|
|
87
|
+
// file comes from readdirSync(dir) filtered to *.json entries, so it is a
|
|
88
|
+
// plain filename with no path separators, not user input.
|
|
89
|
+
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
90
|
+
const filePath = join(dir, file);
|
|
91
|
+
// Guard against an oversized teammate file before reading it whole.
|
|
92
|
+
try {
|
|
93
|
+
const stat = statSync(filePath);
|
|
94
|
+
if (stat.size > MAX_TEAMMATE_FILE_BYTES) {
|
|
95
|
+
console.warn(`sessionmem: skipping ${file} (${stat.size} bytes exceeds 10MB limit)`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
console.error(`Skipping unreadable teammate file: ${file}`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
73
103
|
let parsed;
|
|
74
104
|
try {
|
|
75
105
|
// A truncated/corrupt teammate file is skipped-and-warned, never
|
|
76
106
|
// aborting the rest of the pull.
|
|
77
|
-
|
|
78
|
-
// a plain filename with no path separators, not user input.
|
|
79
|
-
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
80
|
-
parsed = JSON.parse(readFileSync(join(dir, file), "utf8"));
|
|
107
|
+
parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
81
108
|
}
|
|
82
109
|
catch {
|
|
83
110
|
console.error(`Skipping unreadable teammate file: ${file}`);
|
|
@@ -100,18 +127,21 @@ export async function syncCommand(ctx, options) {
|
|
|
100
127
|
}
|
|
101
128
|
let pulledNew = 0;
|
|
102
129
|
let pulledUpdated = 0;
|
|
103
|
-
|
|
130
|
+
// Split into batches of MAX_BATCH (1000) so a large team never trips
|
|
131
|
+
// pullMemoriesRequestSchema's max(MAX_IMPORT_SIZE) and discards the whole pull.
|
|
132
|
+
for (let i = 0; i < memories.length; i += MAX_BATCH) {
|
|
133
|
+
const batch = memories.slice(i, i + MAX_BATCH);
|
|
104
134
|
const pullRes = await context.service.call("pullMemories", {
|
|
105
135
|
projectId: context.projectId,
|
|
106
|
-
memories,
|
|
136
|
+
memories: batch,
|
|
107
137
|
});
|
|
108
138
|
if (!pullRes.ok) {
|
|
109
139
|
console.error(pullRes.error.message);
|
|
110
140
|
process.exit(1);
|
|
111
141
|
return;
|
|
112
142
|
}
|
|
113
|
-
pulledNew
|
|
114
|
-
pulledUpdated
|
|
143
|
+
pulledNew += pullRes.pulledNew;
|
|
144
|
+
pulledUpdated += pullRes.pulledUpdated;
|
|
115
145
|
}
|
|
116
146
|
// The exact summary string.
|
|
117
147
|
console.log(`Pushed ${pushed} memories, pulled ${pulledNew} new + updated ${pulledUpdated} from teammates.`);
|