sessionmem 1.3.0 → 1.5.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.
Files changed (68) hide show
  1. package/.github/sessionmem-logo.svg +57 -0
  2. package/CHANGELOG.md +128 -0
  3. package/CONTRIBUTING.md +130 -0
  4. package/README.md +292 -108
  5. package/SECURITY.md +33 -0
  6. package/dist/adapters/diagnostics.js +109 -0
  7. package/dist/adapters/generic.js +6 -2
  8. package/dist/adapters/global/antigravity.js +4 -1
  9. package/dist/adapters/global/claudeCode.js +19 -4
  10. package/dist/adapters/global/codex.js +7 -6
  11. package/dist/adapters/global/qcoder.js +4 -1
  12. package/dist/adapters/ide/cline.js +3 -0
  13. package/dist/adapters/ide/cursor.js +3 -0
  14. package/dist/adapters/ide/installer.js +17 -5
  15. package/dist/adapters/ide/windsurf.js +3 -0
  16. package/dist/adapters/installedHosts.js +51 -0
  17. package/dist/cli/commands/dedupe.js +1 -1
  18. package/dist/cli/commands/doctor.js +146 -0
  19. package/dist/cli/commands/entities.js +27 -0
  20. package/dist/cli/commands/history.js +23 -0
  21. package/dist/cli/commands/import.js +15 -44
  22. package/dist/cli/commands/ingestHook.js +4 -78
  23. package/dist/cli/commands/install.js +37 -59
  24. package/dist/cli/commands/list.js +3 -0
  25. package/dist/cli/commands/run.js +10 -12
  26. package/dist/cli/commands/scan.js +16 -0
  27. package/dist/cli/commands/search.js +9 -4
  28. package/dist/cli/commands/team.js +7 -3
  29. package/dist/cli/commands/ui.js +67 -0
  30. package/dist/cli/commands/uninstall.js +2 -1
  31. package/dist/cli/commands/watch.js +48 -0
  32. package/dist/cli/context.js +8 -6
  33. package/dist/cli/index.js +48 -7
  34. package/dist/cli/projectId.js +1 -2
  35. package/dist/cli/transcriptSweep.js +182 -0
  36. package/dist/core/api/contracts.js +1 -0
  37. package/dist/core/api/memoryCoreService.js +8 -0
  38. package/dist/core/api/sessionLifecycleService.js +13 -1
  39. package/dist/core/capture/toolEvent.js +82 -0
  40. package/dist/core/config/policyConfig.js +5 -1
  41. package/dist/core/extract/factClaims.js +97 -0
  42. package/dist/core/extract/knowledge.js +127 -0
  43. package/dist/core/injection/tokenBudget.js +10 -2
  44. package/dist/core/retrieve/retrieveMemories.js +20 -4
  45. package/dist/core/schema/migrations/012_transcript_checkpoints.sql +4 -0
  46. package/dist/core/schema/migrations/013_fts_porter.sql +32 -0
  47. package/dist/core/schema/migrations/014_memory_entities.sql +19 -0
  48. package/dist/core/schema/migrations/015_memory_supersessions.sql +41 -0
  49. package/dist/core/schema/runMigrations.js +55 -5
  50. package/dist/core/storage/db.js +31 -9
  51. package/dist/core/storage/knowledgeRepo.js +67 -0
  52. package/dist/core/storage/memoryRepo.js +45 -12
  53. package/dist/core/storage/memorySearchRepo.js +30 -20
  54. package/dist/core/storage/supersessionRepo.js +101 -0
  55. package/dist/core/summarize/cloudSummarizer.js +3 -8
  56. package/dist/core/summarize/summaryShape.js +1 -1
  57. package/dist/ui/assets.js +181 -0
  58. package/dist/ui/server.js +147 -0
  59. package/docs/REGISTRY_LISTING.md +88 -0
  60. package/docs/architecture.md +77 -0
  61. package/docs/benchmark.md +122 -0
  62. package/docs/cloud-summarization.md +57 -0
  63. package/docs/migration.md +44 -0
  64. package/docs/privacy-and-retention.md +139 -0
  65. package/docs/team-mode.md +117 -0
  66. package/docs/troubleshooting.md +126 -0
  67. package/docs/typescript-upgrade.md +48 -0
  68. package/package.json +25 -13
@@ -14,7 +14,7 @@ export async function importCommand(pathArg, options, ctx) {
14
14
  // Read and parse the JSON file
15
15
  let parsed;
16
16
  try {
17
- const raw = readFileSync(inPath, "utf8");
17
+ const raw = new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(inPath));
18
18
  parsed = JSON.parse(raw);
19
19
  }
20
20
  catch (err) {
@@ -27,61 +27,32 @@ export async function importCommand(pathArg, options, ctx) {
27
27
  console.error("Import file must be a JSON array of memory records.");
28
28
  process.exit(1);
29
29
  }
30
- const records = parsed;
31
- let toImport = records;
32
- let skippedCount = 0;
33
- if (!options.merge) {
34
- // By default, skip existing IDs (pre-filter). `id` is a
35
- // globally-unique PRIMARY KEY (not scoped by project), so the duplicate
36
- // check must consider every project's ids -- otherwise a record whose id
37
- // collides with another project's memory would not be pre-filtered here
38
- // and could fall through to the service's ON CONFLICT(id) upsert.
39
- const existingIds = listAllMemoryIds(context.db);
40
- const filtered = records.filter((r) => {
41
- const id = typeof r.id === "string" ? r.id : undefined;
42
- return id === undefined || !existingIds.has(id);
43
- });
44
- skippedCount = records.length - filtered.length;
45
- toImport = filtered;
46
- }
47
- if (toImport.length === 0 && skippedCount > 0) {
48
- console.log(`Imported 0, skipped ${skippedCount} duplicates.`);
49
- return;
50
- }
51
- // Map to the expected shape, then validate each record before sending to the service
52
- const mapped = toImport.map((r) => ({
53
- id: r.id,
54
- projectId: r.projectId ?? context.projectId,
55
- sessionId: r.sessionId,
56
- sourceAdapter: r.sourceAdapter,
57
- kind: r.kind,
58
- content: r.content,
59
- importance: r.importance,
60
- createdAt: r.createdAt,
61
- updatedAt: r.updatedAt,
62
- }));
63
- // Validate each record but skip-and-warn on individual invalid
64
- // records (consistent with the duplicate-skip UX) instead of aborting the
65
- // entire import on the first invalid record, which would discard earlier
66
- // valid records with no partial import.
30
+ const existingIds = options.merge ? undefined : listAllMemoryIds(context.db);
67
31
  const validMemories = [];
32
+ let skippedCount = 0;
68
33
  let invalidCount = 0;
69
- for (let i = 0; i < mapped.length; i++) {
70
- const check = importMemoryRecordSchema.safeParse(mapped[i]);
34
+ for (const [index, record] of parsed.entries()) {
35
+ const isRecord = record !== null && typeof record === "object" && !Array.isArray(record);
36
+ if (isRecord && typeof record.id === "string" && existingIds?.has(record.id)) {
37
+ skippedCount += 1;
38
+ continue;
39
+ }
40
+ // Validate unknown values before accessing fields. Keep the source index in
41
+ // diagnostics even when earlier records were duplicates, and preserve the
42
+ // optional provenance fields carried by exports.
43
+ const check = importMemoryRecordSchema.safeParse(isRecord ? { ...record, projectId: record.projectId ?? context.projectId } : record);
71
44
  if (!check.success) {
72
- console.error(`Record at index ${i} is invalid, skipping: ${check.error.message}`);
45
+ console.error(`Record at index ${index} is invalid, skipping: ${check.error.message}`);
73
46
  invalidCount += 1;
74
47
  continue;
75
48
  }
76
- // Use the parsed/transformed record so `kind` is narrowed to the
77
- // canonical enum (memoryKindSchema maps legacy values like `architecture`).
78
49
  validMemories.push(check.data);
79
50
  }
80
51
  if (validMemories.length === 0) {
81
52
  if (invalidCount > 0) {
82
53
  console.log(`Imported 0, skipped ${invalidCount} invalid record(s).`);
83
54
  }
84
- else if (skippedCount > 0) {
55
+ else {
85
56
  console.log(`Imported 0, skipped ${skippedCount} duplicates.`);
86
57
  }
87
58
  return;
@@ -1,26 +1,12 @@
1
1
  import { createCliContext } from "../context.js";
2
2
  import { randomUUID } from "node:crypto";
3
- // High-signal tool types that we capture as session events.
4
- const CAPTURED_TOOL_TYPES = new Set(["Bash", "Edit", "Write", "MultiEdit"]);
5
- // Max payload size stored per hook event (chars). The full hook payload can be
6
- // large (file diffs); cap it so a single tool use can't bloat session_events.
7
- const MAX_HOOK_PAYLOAD_CHARS = 4000;
8
- // Per-field cap applied BEFORE serialization. Truncating the serialized JSON
9
- // instead would produce a string that is no longer valid JSON, which the
10
- // `payloadJson` contract rejects (it parses the value) — the event would then be
11
- // dropped, silently, by the catch-all below. Trim the inputs, not the output.
12
- const MAX_FIELD_CHARS = 1200;
13
- // One-line human summary shown in session summaries (summaryShape reads `text`).
14
- const MAX_TEXT_CHARS = 300;
3
+ import { buildToolEvent } from "../../core/capture/toolEvent.js";
15
4
  /** Emit a hook diagnostic to stderr when SESSIONMEM_DEBUG=1. */
16
5
  function debug(message) {
17
6
  if (process.env.SESSIONMEM_DEBUG === "1") {
18
7
  process.stderr.write(`[sessionmem] ingest-hook: ${message}\n`);
19
8
  }
20
9
  }
21
- function truncate(value, max) {
22
- return value.length > max ? `${value.slice(0, max)}…` : value;
23
- }
24
10
  async function readStdinJson() {
25
11
  if (process.stdin.isTTY)
26
12
  return {};
@@ -56,65 +42,6 @@ function resolveSessionId(payload) {
56
42
  }
57
43
  return process.env.CLAUDE_CODE_SESSION_ID ?? process.env.SESSION_ID ?? `session-${Date.now()}`;
58
44
  }
59
- /**
60
- * Shorten every string in a tool input to MAX_FIELD_CHARS, recursively. Keeps
61
- * the object's shape (so it still serializes to valid JSON) while bounding the
62
- * size contributed by large fields such as a Write tool's `content` or an Edit
63
- * tool's replacement text.
64
- */
65
- function shrinkValue(value, depth = 0) {
66
- if (typeof value === "string")
67
- return truncate(value, MAX_FIELD_CHARS);
68
- if (depth >= 4)
69
- return undefined;
70
- if (Array.isArray(value)) {
71
- return value.slice(0, 20).map((item) => shrinkValue(item, depth + 1));
72
- }
73
- if (value && typeof value === "object") {
74
- const out = {};
75
- for (const [key, inner] of Object.entries(value)) {
76
- out[key] = shrinkValue(inner, depth + 1);
77
- }
78
- return out;
79
- }
80
- // Numbers/booleans/null pass through; functions/undefined/symbols drop out.
81
- return value === undefined || typeof value === "function" || typeof value === "symbol"
82
- ? undefined
83
- : value;
84
- }
85
- /**
86
- * A short human-readable line describing the tool use. Stored alongside the
87
- * structured input as `text`, which the local summarizer prefers over the raw
88
- * payload — so session summaries read as prose instead of dumped JSON.
89
- */
90
- function describeToolUse(toolName, input) {
91
- const str = (key) => typeof input?.[key] === "string" ? input[key] : "";
92
- if (toolName === "Bash") {
93
- const command = str("command").replace(/\s+/g, " ").trim();
94
- return truncate(command ? `Ran: ${command}` : "Ran a shell command", MAX_TEXT_CHARS);
95
- }
96
- const filePath = str("file_path") || str("path") || str("notebook_path");
97
- const verb = toolName === "Write" ? "Wrote" : "Edited";
98
- return truncate(filePath ? `${verb} ${filePath}` : `${verb} a file`, MAX_TEXT_CHARS);
99
- }
100
- /**
101
- * Serialize the event payload, guaranteeing valid JSON within
102
- * MAX_HOOK_PAYLOAD_CHARS. Per-field shrinking handles the common case; if the
103
- * result is still over budget (many fields, or one huge key set), drop the
104
- * structured input and keep only the human-readable summary rather than
105
- * emitting truncated — and therefore unparseable — JSON.
106
- */
107
- function buildPayloadJson(toolName, text, input, cwd) {
108
- const full = JSON.stringify({
109
- tool: toolName,
110
- text,
111
- input: shrinkValue(input),
112
- cwd,
113
- });
114
- if (full.length <= MAX_HOOK_PAYLOAD_CHARS)
115
- return full;
116
- return JSON.stringify({ tool: toolName, text, cwd, truncated: true });
117
- }
118
45
  /**
119
46
  * `sessionmem ingest-hook` — PostToolUse hook handler.
120
47
  * Reads Claude Code's PostToolUse JSON payload from stdin and stores it as a
@@ -126,14 +53,14 @@ export async function ingestHookCommand(ctx) {
126
53
  try {
127
54
  const payload = await readStdinJson();
128
55
  const toolName = payload.tool_name ?? "";
129
- if (!CAPTURED_TOOL_TYPES.has(toolName)) {
56
+ const event = buildToolEvent(toolName, payload.tool_input, payload.cwd);
57
+ if (!event) {
130
58
  debug(`skipped: tool "${toolName}" is not captured`);
131
59
  return;
132
60
  }
133
61
  context = ctx ?? createCliContext();
134
62
  const sessionId = resolveSessionId(payload);
135
63
  const projectId = context.projectId;
136
- const text = describeToolUse(toolName, payload.tool_input);
137
64
  // eventIndex is intentionally omitted: the service assigns the next free
138
65
  // index per session inside an immediate transaction, so parallel tool calls
139
66
  // cannot collide (see nextSessionEventIndex).
@@ -142,8 +69,7 @@ export async function ingestHookCommand(ctx) {
142
69
  sessionId,
143
70
  events: [{
144
71
  id: randomUUID(),
145
- eventType: `tool_use:${toolName.toLowerCase()}`,
146
- payloadJson: buildPayloadJson(toolName, text, payload.tool_input, payload.cwd),
72
+ ...event,
147
73
  }],
148
74
  });
149
75
  if (!result.ok) {
@@ -1,26 +1,9 @@
1
1
  import { existsSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { homedir } from "os";
4
- import { AdapterFactory } from "../../adapters/factory.js";
4
+ import { resolveInstallAdapters } from "../../adapters/installedHosts.js";
5
5
  import { injectGuidanceBlock } from "../../adapters/claudeMdInjector.js";
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
- }
6
+ import { createCliContext, resolveDbPath } from "../context.js";
24
7
  import { configFilePath, writePolicyConfig, DEFAULT_POLICY_CONFIG, } from "../../core/config/policyConfig.js";
25
8
  export const MANUAL_CONFIG_BLOCK = JSON.stringify({
26
9
  mcpServers: {
@@ -37,10 +20,10 @@ export function printManualFallback(adapterName) {
37
20
  export async function installCommand(options, contextOverrides) {
38
21
  // Resolve the target adapter up front so an invalid `--adapter` fails before
39
22
  // any filesystem work (DB init, config write).
40
- let adapter;
23
+ let adapters;
41
24
  let forced;
42
25
  try {
43
- ({ adapter, forced } = resolveAdapter(options));
26
+ ({ adapters, forced } = resolveInstallAdapters(options));
44
27
  }
45
28
  catch (err) {
46
29
  console.error(err instanceof Error ? err.message : String(err));
@@ -51,10 +34,12 @@ export async function installCommand(options, contextOverrides) {
51
34
  try {
52
35
  const ctx = createCliContext(contextOverrides);
53
36
  dbPath = ctx.dbPath;
37
+ if (!contextOverrides?.db)
38
+ ctx.db.close();
54
39
  }
55
40
  catch (err) {
56
- console.error(`✗ DB init failed (~/.sessionmem/memories.db): ${err instanceof Error ? err.message : String(err)}`);
57
- console.error("Hint: ensure ~/.sessionmem directory is writable and run `sessionmem install` again.");
41
+ console.error(`✗ DB init failed (${resolveDbPath(contextOverrides?.dbPath)}): ${err instanceof Error ? err.message : String(err)}`);
42
+ console.error("Hint: ensure the database parent directory is writable and run `sessionmem install` again.");
58
43
  process.exit(1);
59
44
  }
60
45
  console.log(`✓ DB initialized (${dbPath})`);
@@ -69,47 +54,40 @@ export async function installCommand(options, contextOverrides) {
69
54
  writePolicyConfig(configPath, { ...DEFAULT_POLICY_CONFIG });
70
55
  console.log(`✓ config.json initialized (${configPath})`);
71
56
  }
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>)`);
78
- if (!adapter.install) {
79
- console.error(`✗ ${adapter.name} config update failed`);
80
- printManualFallback(adapter.name);
81
- process.exit(1);
82
- }
83
- const success = await adapter.install();
84
- if (!success) {
85
- console.error(`✗ ${adapter.name} config update failed`);
86
- printManualFallback(adapter.name);
87
- process.exit(1);
88
- }
89
- console.log(`✓ ${adapter.name} config updated`);
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) {
57
+ // Try every detected host even when one host's config cannot be updated.
58
+ let failed = false;
59
+ for (const adapter of adapters) {
60
+ console.log(forced
61
+ ? `→ Installing for adapter: ${adapter.name} (forced)`
62
+ : `→ Detected host: ${adapter.name} (override with --adapter <name>)`);
97
63
  try {
98
- const injected = injectGuidanceBlock(target);
99
- if (injected) {
100
- console.log(`✓ Agent guidance injected (${target})`);
64
+ if (!adapter.install || !(await adapter.install())) {
65
+ throw new Error("config update failed");
101
66
  }
102
- else {
103
- console.error(`✗ Agent guidance injection failed (non-fatal): ${target}`);
67
+ console.log(`✓ ${adapter.name} config updated`);
68
+ }
69
+ catch (err) {
70
+ console.error(`✗ ${adapter.name} config update failed: ${err instanceof Error ? err.message : String(err)}`);
71
+ printManualFallback(adapter.name);
72
+ failed = true;
73
+ continue;
74
+ }
75
+ const guidanceTargets = adapter.guidanceTargets?.() ?? [join(homedir(), ".claude", "CLAUDE.md")];
76
+ for (const target of guidanceTargets) {
77
+ try {
78
+ if (!injectGuidanceBlock(target))
79
+ throw new Error("injection failed");
80
+ console.log(`✓ ${adapter.name}: Agent guidance injected (${target})`);
81
+ }
82
+ catch {
83
+ console.error(`✗ ${adapter.name}: Agent guidance injection failed (non-fatal): ${target}`);
104
84
  }
105
85
  }
106
- catch {
107
- console.error(`✗ Agent guidance injection failed (non-fatal): ${target}`);
86
+ if (adapter.name === "Claude Code") {
87
+ console.log("✓ Claude Code: SessionStart, SessionEnd, PostToolUse hooks configured (~/.claude/settings.json)");
108
88
  }
109
89
  }
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
- }
90
+ if (failed)
91
+ process.exit(1);
114
92
  console.log("✓ sessionmem ready — restart your editor/agent so it picks up the MCP server");
115
93
  }
@@ -10,4 +10,7 @@ export async function listCommand(ctx) {
10
10
  process.exit(1);
11
11
  }
12
12
  console.log(formatTable(result.memories));
13
+ if (result.total > result.memories.length) {
14
+ console.error(`Showing ${result.memories.length} of ${result.total} memories. Use \`sessionmem export\` for the full project.`);
15
+ }
13
16
  }
@@ -1,19 +1,15 @@
1
1
  import { AdapterFactory } from "../../adapters/factory.js";
2
2
  import { mkdirSync, writeFileSync } from "fs";
3
- import { join } from "path";
4
- import { homedir } from "os";
3
+ import { join, dirname } from "path";
5
4
  import { deriveProjectId } from "../projectId.js";
6
- import { expandTilde } from "../context.js";
5
+ import { resolveDbPath } from "../context.js";
7
6
  export async function runMcpServer() {
8
7
  const adapter = AdapterFactory.detectAdapter();
9
- // Startup diagnostics: written to ~/.sessionmem/logs/mcp.log
10
- const logDir = join(homedir(), ".sessionmem", "logs");
8
+ // Keep diagnostics beside the selected database so an override never
9
+ // creates the default home directory just to write a log.
10
+ const dbPath = resolveDbPath();
11
+ const logDir = join(dirname(dbPath), "logs");
11
12
  const logPath = join(logDir, "mcp.log");
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
13
  // Derive project ID for diagnostics. Shares the exact derivation the server
18
14
  // uses (createCliContext → deriveProjectId) so the logged id never diverges
19
15
  // from the bucket the server actually serves.
@@ -21,8 +17,10 @@ export async function runMcpServer() {
21
17
  const adapterName = adapter.name;
22
18
  const logMessage = `[${new Date().toISOString()}] Started sessionmem | adapter=${adapterName} db=${dbPath} project=${projectId}\n`;
23
19
  try {
24
- mkdirSync(logDir, { recursive: true });
25
- writeFileSync(logPath, logMessage, { flag: "a" });
20
+ if (dbPath !== ":memory:") {
21
+ mkdirSync(logDir, { recursive: true });
22
+ writeFileSync(logPath, logMessage, { flag: "a" });
23
+ }
26
24
  }
27
25
  catch {
28
26
  // best-effort logging; ignore failures
@@ -0,0 +1,16 @@
1
+ import { createCliContext } from "../context.js";
2
+ import { printSweepSummary, sweepTranscripts } from "../transcriptSweep.js";
3
+ export async function scanCommand(options = {}, ctx) {
4
+ const context = ctx ?? createCliContext();
5
+ try {
6
+ const summary = await sweepTranscripts(context, options);
7
+ printSweepSummary("scan", summary);
8
+ if (summary.errors)
9
+ process.exitCode = 1;
10
+ return summary;
11
+ }
12
+ finally {
13
+ if (!ctx)
14
+ context.db.close();
15
+ }
16
+ }
@@ -1,20 +1,25 @@
1
1
  import { createCliContext } from "../context.js";
2
2
  import { formatTable } from "../output.js";
3
- const DEFAULT_LIMIT = 20;
3
+ const DEFAULT_LIMIT = 10;
4
4
  function coerceLimit(value) {
5
5
  if (value === undefined)
6
6
  return DEFAULT_LIMIT;
7
- const parsed = Number.parseInt(String(value), 10);
8
- return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIMIT;
7
+ const parsed = Number(value);
8
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
9
+ throw new Error(`Invalid --limit "${value}": expected an integer from 1 to 100.`);
10
+ }
11
+ return parsed;
9
12
  }
10
13
  export async function searchCommand(query, options = {}, ctx) {
14
+ const limit = coerceLimit(options.limit);
11
15
  const context = ctx ?? createCliContext();
12
16
  const result = await context.service.call("retrieveMemories", {
13
17
  projectId: context.projectId,
14
18
  query,
15
- limit: coerceLimit(options.limit),
19
+ limit,
16
20
  mode: "auto",
17
21
  depth: "default",
22
+ includeHistory: options.includeHistory,
18
23
  });
19
24
  if (!result.ok) {
20
25
  console.error(result.error.message);
@@ -3,6 +3,7 @@ import { join } from "path";
3
3
  import { randomUUID } from "crypto";
4
4
  import { configFilePath, readPolicyConfig, writePolicyConfig, } from "../../core/config/policyConfig.js";
5
5
  import { createCliContext } from "../context.js";
6
+ import { reconcileProjectSupersessions } from "../../core/storage/supersessionRepo.js";
6
7
  function resolvePath(options) {
7
8
  return options?.configPath ?? configFilePath();
8
9
  }
@@ -89,8 +90,11 @@ export function teamDisableCommand(options, ctx) {
89
90
  return;
90
91
  }
91
92
  const context = ctx ?? createCliContext();
92
- const result = context.db
93
- .prepare("DELETE FROM memories WHERE project_id = ? AND author != ? AND author != ''")
94
- .run(context.projectId, context.username);
93
+ const result = context.db.transaction(() => {
94
+ const removed = context.db.prepare("DELETE FROM memories WHERE project_id = ? AND author != ? AND author != ''").run(context.projectId, context.username);
95
+ if (removed.changes)
96
+ reconcileProjectSupersessions(context.db, context.projectId);
97
+ return removed;
98
+ })();
95
99
  console.log(`Team mode disabled. Removed ${result.changes} teammate-authored ${result.changes === 1 ? "memory" : "memories"}.`);
96
100
  }
@@ -0,0 +1,67 @@
1
+ import BetterSqlite3 from "better-sqlite3";
2
+ import { spawn } from "node:child_process";
3
+ import { once } from "node:events";
4
+ import { createMemoryCoreService } from "../../core/api/memoryCoreService.js";
5
+ import { deriveProjectId } from "../projectId.js";
6
+ import { resolveDbPath } from "../context.js";
7
+ import { inspectMigrations } from "../../core/schema/runMigrations.js";
8
+ import { parseUiPort, startUiServer } from "../../ui/server.js";
9
+ /** Open the OS browser without a shell or an additional runtime dependency. */
10
+ export function openBrowser(url) {
11
+ const command = process.platform === "win32" ? "rundll32.exe" : process.platform === "darwin" ? "open" : "xdg-open";
12
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
13
+ const child = spawn(command, args, { stdio: "ignore", detached: true, windowsHide: true });
14
+ const failed = () => console.error("Could not open a browser. Open the printed URL manually.");
15
+ child.once("error", failed);
16
+ child.once("exit", code => { if (code)
17
+ failed(); });
18
+ child.unref();
19
+ }
20
+ export async function uiCommand(options = {}) {
21
+ const port = parseUiPort(options.port);
22
+ // Unlike the normal CLI context, a viewer must not create, migrate or write the store.
23
+ let db;
24
+ try {
25
+ db = new BetterSqlite3(resolveDbPath(), { readonly: true, fileMustExist: true });
26
+ }
27
+ catch {
28
+ throw new Error("Cannot open the memory store. Run sessionmem ping first, or check SESSIONMEM_DB_PATH.");
29
+ }
30
+ try {
31
+ db.pragma("query_only = ON");
32
+ db.pragma("busy_timeout = 5000");
33
+ const migrations = inspectMigrations(db);
34
+ if (migrations.unknown.length) {
35
+ throw new Error("The memory store uses an unrecognized schema. Use the sessionmem version that created it.");
36
+ }
37
+ if (migrations.pending.length) {
38
+ throw new Error("The memory store needs an upgrade. Run sessionmem ping first to apply pending migrations.");
39
+ }
40
+ const viewer = await startUiServer({ db, service: createMemoryCoreService({ db }), projectId: deriveProjectId() }, port);
41
+ let stopping = false;
42
+ const stop = () => {
43
+ if (stopping)
44
+ return;
45
+ stopping = true;
46
+ void viewer.close().catch(error => {
47
+ console.error(error instanceof Error ? error.message : String(error));
48
+ process.exitCode = 1;
49
+ });
50
+ };
51
+ process.once("SIGINT", stop);
52
+ process.once("SIGTERM", stop);
53
+ try {
54
+ const closed = once(viewer.server, "close");
55
+ console.log(`sessionmem viewer: ${viewer.url}\nRead-only. Press Ctrl-C to stop.`);
56
+ openBrowser(viewer.url);
57
+ await closed;
58
+ }
59
+ finally {
60
+ process.removeListener("SIGINT", stop);
61
+ process.removeListener("SIGTERM", stop);
62
+ }
63
+ }
64
+ finally {
65
+ db.close();
66
+ }
67
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, rmSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { homedir } from "os";
4
+ import { resolveDbPath } from "../context.js";
4
5
  import { AdapterFactory } from "../../adapters/factory.js";
5
6
  import { removeClaudeMdBlock } from "../../adapters/claudeMdInjector.js";
6
7
  /**
@@ -51,7 +52,7 @@ export async function uninstallCommand(options = {}) {
51
52
  }
52
53
  }
53
54
  // Resolve dbPath: use injected override (for tests) or the default location
54
- const dbPath = options.dbPath ?? join(homedir(), ".sessionmem", "memories.db");
55
+ const dbPath = resolveDbPath(options.dbPath);
55
56
  if (options.purge) {
56
57
  // --purge: delete only memories.db, not logs or the ~/.sessionmem directory
57
58
  if (existsSync(dbPath)) {
@@ -0,0 +1,48 @@
1
+ import { setTimeout } from "node:timers/promises";
2
+ import { createCliContext } from "../context.js";
3
+ import { printSweepSummary, sweepTranscripts } from "../transcriptSweep.js";
4
+ export async function watchCommand(options = {}, ctx) {
5
+ const interval = Number(options.interval ?? 5);
6
+ if (!Number.isFinite(interval) || interval <= 0 || interval > 86400) {
7
+ throw new Error("--interval must be a number greater than 0 and at most 86400 seconds");
8
+ }
9
+ const context = ctx ?? createCliContext();
10
+ const controller = new AbortController();
11
+ const stop = () => controller.abort();
12
+ process.once("SIGINT", stop);
13
+ process.once("SIGTERM", stop);
14
+ let firstSweep = true;
15
+ try {
16
+ do {
17
+ try {
18
+ const summary = await sweepTranscripts(context, options);
19
+ if (firstSweep || options.once || summary.sessionsImported || summary.errors) {
20
+ printSweepSummary("watch", summary);
21
+ }
22
+ firstSweep = false;
23
+ if (options.once && summary.errors)
24
+ process.exitCode = 1;
25
+ }
26
+ catch (error) {
27
+ if (options.once)
28
+ throw error;
29
+ console.error(`[sessionmem] watch: ${error instanceof Error ? error.message : String(error)}`);
30
+ }
31
+ if (options.once || controller.signal.aborted)
32
+ break;
33
+ try {
34
+ await setTimeout(interval * 1000, undefined, { signal: controller.signal });
35
+ }
36
+ catch (error) {
37
+ if (!controller.signal.aborted)
38
+ throw error;
39
+ }
40
+ } while (!controller.signal.aborted);
41
+ }
42
+ finally {
43
+ process.removeListener("SIGINT", stop);
44
+ process.removeListener("SIGTERM", stop);
45
+ if (!ctx)
46
+ context.db.close();
47
+ }
48
+ }
@@ -42,19 +42,21 @@ export function expandTilde(p) {
42
42
  }
43
43
  return p;
44
44
  }
45
- function defaultDbPath(dir) {
45
+ export function resolveDbPath(override) {
46
+ if (override !== undefined)
47
+ return expandTilde(override);
46
48
  // Env override seam (see deriveProjectId). Defaults to ~/.sessionmem/memories.db.
47
49
  const envDbPath = process.env.SESSIONMEM_DB_PATH;
48
50
  if (envDbPath && envDbPath.trim() !== "")
49
51
  return expandTilde(envDbPath);
50
- // `dir` is the fixed ~/.sessionmem dir computed above, not user input.
51
52
  // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
52
- return join(dir, "memories.db");
53
+ return join(homedir(), ".sessionmem", "memories.db");
53
54
  }
54
55
  export function createCliContext(overrides = {}) {
55
- const dir = join(homedir(), ".sessionmem");
56
- mkdirSync(dir, { recursive: true });
57
- const dbPath = overrides.dbPath ?? defaultDbPath(dir);
56
+ const dbPath = resolveDbPath(overrides.dbPath);
57
+ if (!overrides.db && dbPath !== ":memory:") {
58
+ mkdirSync(dirname(dbPath), { recursive: true });
59
+ }
58
60
  const here = dirname(fileURLToPath(import.meta.url));
59
61
  const migrationsDir = overrides.migrationsDir ?? join(here, "..", "core", "schema", "migrations");
60
62
  const username = overrides.username ?? localUsername();