sessionmem 1.0.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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +344 -0
  3. package/dist/adapters/capabilities/fallbackTools.js +36 -0
  4. package/dist/adapters/contract/hostAdapterContract.js +1 -0
  5. package/dist/adapters/factory.js +40 -0
  6. package/dist/adapters/generic.js +128 -0
  7. package/dist/adapters/global/antigravity.js +22 -0
  8. package/dist/adapters/global/claudeCode.js +22 -0
  9. package/dist/adapters/global/codex.js +22 -0
  10. package/dist/adapters/global/qcoder.js +22 -0
  11. package/dist/adapters/ide/cline.js +20 -0
  12. package/dist/adapters/ide/cursor.js +28 -0
  13. package/dist/adapters/ide/installer.js +57 -0
  14. package/dist/adapters/ide/windsurf.js +28 -0
  15. package/dist/adapters/tools/ping.js +15 -0
  16. package/dist/cli/commands/config.js +79 -0
  17. package/dist/cli/commands/export.js +28 -0
  18. package/dist/cli/commands/forget.js +28 -0
  19. package/dist/cli/commands/import.js +112 -0
  20. package/dist/cli/commands/install.js +57 -0
  21. package/dist/cli/commands/list.js +13 -0
  22. package/dist/cli/commands/ping.js +12 -0
  23. package/dist/cli/commands/redactScan.js +40 -0
  24. package/dist/cli/commands/retention.js +54 -0
  25. package/dist/cli/commands/run.js +26 -0
  26. package/dist/cli/commands/search.js +29 -0
  27. package/dist/cli/commands/show.js +15 -0
  28. package/dist/cli/commands/stats.js +46 -0
  29. package/dist/cli/commands/sync.js +118 -0
  30. package/dist/cli/commands/team.js +96 -0
  31. package/dist/cli/commands/uninstall.js +30 -0
  32. package/dist/cli/context.js +69 -0
  33. package/dist/cli/index.js +147 -0
  34. package/dist/cli/output.js +37 -0
  35. package/dist/core/api/contracts.js +263 -0
  36. package/dist/core/api/errors.js +29 -0
  37. package/dist/core/api/localOnlyPolicy.js +29 -0
  38. package/dist/core/api/memoryCoreService.js +595 -0
  39. package/dist/core/api/sessionLifecycleService.js +289 -0
  40. package/dist/core/config/policyConfig.js +131 -0
  41. package/dist/core/embed/deterministicEmbed.js +31 -0
  42. package/dist/core/embed/embeddingVersion.js +1 -0
  43. package/dist/core/embed/reembedPolicy.js +9 -0
  44. package/dist/core/embed/textNormalize.js +12 -0
  45. package/dist/core/injection/formatStartupInjection.js +97 -0
  46. package/dist/core/injection/tokenBudget.js +38 -0
  47. package/dist/core/retrieve/decay.js +15 -0
  48. package/dist/core/retrieve/importance.js +6 -0
  49. package/dist/core/retrieve/recencyBands.js +18 -0
  50. package/dist/core/retrieve/retrieveMemories.js +83 -0
  51. package/dist/core/retrieve/score.js +25 -0
  52. package/dist/core/schema/migrations/001_initial.sql +25 -0
  53. package/dist/core/schema/migrations/002_indexes.sql +18 -0
  54. package/dist/core/schema/migrations/003_summarization_failures.sql +14 -0
  55. package/dist/core/schema/migrations/004_memory_feedback.sql +12 -0
  56. package/dist/core/schema/migrations/005_team_provenance.sql +9 -0
  57. package/dist/core/schema/runMigrations.js +38 -0
  58. package/dist/core/session.js +4 -0
  59. package/dist/core/storage/db.js +8 -0
  60. package/dist/core/storage/memoryFeedbackRepo.js +16 -0
  61. package/dist/core/storage/memoryRepo.js +179 -0
  62. package/dist/core/storage/memorySearchRepo.js +30 -0
  63. package/dist/core/storage/sessionEventsRepo.js +20 -0
  64. package/dist/core/storage/summarizationFailuresRepo.js +39 -0
  65. package/dist/core/storage/types.js +1 -0
  66. package/dist/core/summarize/cloudSummarizer.js +19 -0
  67. package/dist/core/summarize/localSummarizer.js +31 -0
  68. package/dist/core/summarize/redaction.js +48 -0
  69. package/dist/core/summarize/strategySelector.js +7 -0
  70. package/dist/core/summarize/summaryShape.js +49 -0
  71. package/package.json +48 -0
@@ -0,0 +1,46 @@
1
+ import { statSync } from "fs";
2
+ import { createCliContext } from "../context.js";
3
+ import { countTokens } from "../../core/injection/tokenBudget.js";
4
+ import { listMemoriesByProject } from "../../core/storage/memoryRepo.js";
5
+ import { configFilePath, readPolicyConfig, } from "../../core/config/policyConfig.js";
6
+ export async function statsCommand(ctx, options) {
7
+ const context = ctx ?? createCliContext();
8
+ const result = await context.service.call("stats", {
9
+ projectId: context.projectId,
10
+ });
11
+ if (!result.ok) {
12
+ console.error(result.error.message);
13
+ process.exit(1);
14
+ }
15
+ let sizeBytes = 0;
16
+ try {
17
+ sizeBytes = statSync(context.dbPath).size;
18
+ }
19
+ catch {
20
+ // dbPath may be ":memory:" or the file may have been removed; report 0
21
+ }
22
+ const totalTokens = listMemoriesByProject(context.db, context.projectId).reduce((sum, m) => sum + countTokens(m.content), 0);
23
+ // Effective policy: retention window + redaction state for visibility.
24
+ const { retentionDays, redactionEnabled } = readPolicyConfig(options?.configPath ?? configFilePath());
25
+ // retentionDays<=0 disables pruning; report that rather than a
26
+ // misleading eligible count against a non-positive window.
27
+ let retentionLine;
28
+ if (retentionDays <= 0) {
29
+ retentionLine = "Retention: pruning disabled (retentionDays <= 0)";
30
+ }
31
+ else {
32
+ const prune = await context.service.call("pruneMemories", {
33
+ projectId: context.projectId,
34
+ retentionDays,
35
+ dryRun: true,
36
+ });
37
+ const eligible = prune.ok ? prune.eligible : 0;
38
+ retentionLine = `Retention: ${retentionDays} days (${eligible} memories eligible for pruning)`;
39
+ }
40
+ const redactionLine = `Redaction: ${redactionEnabled ? "enabled" : "disabled"}`;
41
+ process.stdout.write(`memories: ${result.totalMemories}\n` +
42
+ `db_size_bytes: ${sizeBytes}\n` +
43
+ `total_content_tokens: ${totalTokens}\n` +
44
+ `${retentionLine}\n` +
45
+ `${redactionLine}\n`);
46
+ }
@@ -0,0 +1,118 @@
1
+ import { join } from "path";
2
+ import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "fs";
3
+ import { configFilePath, readPolicyConfig, } from "../../core/config/policyConfig.js";
4
+ import { importMemoryRecordSchema } from "../../core/api/contracts.js";
5
+ import { createCliContext } from "../context.js";
6
+ /**
7
+ * `sessionmem sync` — push a full snapshot of local project memories to the
8
+ * shared path and pull every teammate's snapshot back into the local DB.
9
+ *
10
+ * No-ops with a clear message when team mode is disabled. Push writes
11
+ * `{sharedPath}/{projectId}/{username}.json` atomically (temp-file + rename).
12
+ * Pull enumerates every other `*.json` in that dir, skip-and-warns
13
+ * on unreadable/non-array files, validates each record, and merges
14
+ * via `pullMemories` (MAX-importance LWW + re-redaction + cross-project skip).
15
+ */
16
+ export async function syncCommand(ctx, options) {
17
+ const context = ctx ?? createCliContext();
18
+ const config = readPolicyConfig(options?.configPath ?? configFilePath());
19
+ const { enabled, sharedPath } = config.team;
20
+ // Clean no-op (exit 0) when team mode is off or unconfigured.
21
+ if (!enabled || !sharedPath) {
22
+ console.log("Team mode is not enabled. Run `sessionmem team enable <path>`.");
23
+ return;
24
+ }
25
+ // ---- PUSH (model export.ts) ----
26
+ const exportRes = await context.service.call("exportMemories", {
27
+ projectId: context.projectId,
28
+ });
29
+ if (!exportRes.ok) {
30
+ console.error(exportRes.error.message);
31
+ process.exit(1);
32
+ return;
33
+ }
34
+ // Build every shared path with path.join ONLY (Windows/UNC safe).
35
+ // The project dir + filename derive from the LOCAL projectId/username, never
36
+ // from a string inside a teammate file.
37
+ // projectId/username are sanitized to [A-Za-z0-9._-] in context.ts
38
+ // (localUsername/deriveProjectId), so no path traversal here.
39
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
40
+ const dir = join(sharedPath, context.projectId);
41
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
42
+ const finalPath = join(dir, `${context.username}.json`);
43
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
44
+ const tmpPath = join(dir, `${context.username}.json.tmp`);
45
+ try {
46
+ mkdirSync(dir, { recursive: true });
47
+ // Atomic write — temp file in the SAME dir then rename,
48
+ // so a teammate never reads a half-written snapshot off a network drive.
49
+ writeFileSync(tmpPath, JSON.stringify(exportRes.memories, null, 2), "utf8");
50
+ renameSync(tmpPath, finalPath);
51
+ }
52
+ catch (err) {
53
+ const message = err instanceof Error ? err.message : String(err);
54
+ // Missing/unwritable shared path -> stderr + non-zero exit.
55
+ console.error(`Failed to write to shared path: ${message}`);
56
+ process.exit(1);
57
+ return;
58
+ }
59
+ const pushed = exportRes.memories.length;
60
+ // ---- PULL (model import.ts) ----
61
+ let teammateFiles;
62
+ try {
63
+ teammateFiles = readdirSync(dir).filter((f) => f.endsWith(".json") && f !== `${context.username}.json`);
64
+ }
65
+ catch (err) {
66
+ const message = err instanceof Error ? err.message : String(err);
67
+ console.error(`Failed to read shared path: ${message}`);
68
+ process.exit(1);
69
+ return;
70
+ }
71
+ const memories = [];
72
+ for (const file of teammateFiles) {
73
+ let parsed;
74
+ try {
75
+ // A truncated/corrupt teammate file is skipped-and-warned, never
76
+ // aborting the rest of the pull.
77
+ // file comes from readdirSync(dir) filtered to *.json entries, so it is
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"));
81
+ }
82
+ catch {
83
+ console.error(`Skipping unreadable teammate file: ${file}`);
84
+ continue;
85
+ }
86
+ if (!Array.isArray(parsed)) {
87
+ console.error(`Skipping teammate file (not an array): ${file}`);
88
+ continue;
89
+ }
90
+ for (const raw of parsed) {
91
+ // Carry author/originProjectId through; per-record skip-and-warn so one
92
+ // bad record never discards the rest.
93
+ const check = importMemoryRecordSchema.safeParse(raw);
94
+ if (!check.success) {
95
+ console.error(`Skipping invalid record in ${file}: ${check.error.message}`);
96
+ continue;
97
+ }
98
+ memories.push(check.data);
99
+ }
100
+ }
101
+ let pulledNew = 0;
102
+ let pulledUpdated = 0;
103
+ if (memories.length > 0) {
104
+ const pullRes = await context.service.call("pullMemories", {
105
+ projectId: context.projectId,
106
+ memories,
107
+ });
108
+ if (!pullRes.ok) {
109
+ console.error(pullRes.error.message);
110
+ process.exit(1);
111
+ return;
112
+ }
113
+ pulledNew = pullRes.pulledNew;
114
+ pulledUpdated = pullRes.pulledUpdated;
115
+ }
116
+ // The exact summary string.
117
+ console.log(`Pushed ${pushed} memories, pulled ${pulledNew} new + updated ${pulledUpdated} from teammates.`);
118
+ }
@@ -0,0 +1,96 @@
1
+ import { accessSync, constants, existsSync, unlinkSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { randomUUID } from "crypto";
4
+ import { configFilePath, readPolicyConfig, writePolicyConfig, } from "../../core/config/policyConfig.js";
5
+ import { createCliContext } from "../context.js";
6
+ function resolvePath(options) {
7
+ return options?.configPath ?? configFilePath();
8
+ }
9
+ /**
10
+ * `sessionmem team enable <path>` — turn on team mode and record the shared path.
11
+ * Missing/empty path -> error to stderr + exit 1.
12
+ */
13
+ export function teamEnableCommand(sharedPath, options) {
14
+ if (!sharedPath || sharedPath.trim() === "") {
15
+ console.error("team enable requires a shared path argument.");
16
+ process.exit(1);
17
+ return;
18
+ }
19
+ writePolicyConfig(resolvePath(options), {
20
+ team: { enabled: true, sharedPath },
21
+ });
22
+ console.log(`Team mode enabled. Shared path: ${sharedPath}`);
23
+ }
24
+ /**
25
+ * `sessionmem team status` — print enabled state + the shared path, and report
26
+ * whether that path exists and is writable. Reads local fs only.
27
+ * Does NOT print a last-sync time — there is no synced_at column.
28
+ */
29
+ export function teamStatusCommand(options) {
30
+ const config = readPolicyConfig(resolvePath(options));
31
+ const { enabled, sharedPath } = config.team;
32
+ console.log(`Team mode: ${enabled ? "enabled" : "disabled"}`);
33
+ if (!sharedPath) {
34
+ console.log("Shared path: not set");
35
+ return;
36
+ }
37
+ console.log(`Shared path: ${sharedPath}`);
38
+ if (!existsSync(sharedPath)) {
39
+ console.log("Shared path status: does not exist");
40
+ return;
41
+ }
42
+ // accessSync(..., W_OK) is unreliable on Windows for directories
43
+ // (NTFS ACL semantics differ from POSIX access(), and Node largely ignores
44
+ // W_OK for directories on Windows). Probe by creating and removing a temp
45
+ // file, falling back to accessSync only if the probe itself errors
46
+ // unexpectedly (e.g. sharedPath is not a directory).
47
+ // Defensive init: guarantees a definite value before the read below even if a
48
+ // probe branch is later added.
49
+ // eslint-disable-next-line no-useless-assignment
50
+ let writable = false;
51
+ // sharedPath is operator-configured (team enable <path>); the filename
52
+ // suffix is a randomUUID(), not user input.
53
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
54
+ const probePath = join(sharedPath, `.sessionmem-write-test-${randomUUID()}`);
55
+ try {
56
+ writeFileSync(probePath, "");
57
+ writable = true;
58
+ try {
59
+ unlinkSync(probePath);
60
+ }
61
+ catch {
62
+ /* best-effort cleanup */
63
+ }
64
+ }
65
+ catch {
66
+ try {
67
+ accessSync(sharedPath, constants.W_OK);
68
+ writable = true;
69
+ }
70
+ catch {
71
+ writable = false;
72
+ }
73
+ }
74
+ console.log(`Shared path status: exists, ${writable ? "writable" : "not writable"}`);
75
+ }
76
+ /**
77
+ * `sessionmem team disable [--remove-team-memories]` — stop team sync.
78
+ *
79
+ * By default, flip enabled to false and KEEP already-pulled teammate
80
+ * rows (no data loss). With `--remove-team-memories`, additionally
81
+ * delete rows authored by someone other than the local username for the current
82
+ * project, reverting to a local-only store. The DELETE binds projectId/username
83
+ * as parameters — never string-concatenated.
84
+ */
85
+ export function teamDisableCommand(options, ctx) {
86
+ writePolicyConfig(resolvePath(options), { team: { enabled: false } });
87
+ if (!options?.removeTeamMemories) {
88
+ console.log("Team mode disabled. Teammate memories preserved.");
89
+ return;
90
+ }
91
+ 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);
95
+ console.log(`Team mode disabled. Removed ${result.changes} teammate-authored ${result.changes === 1 ? "memory" : "memories"}.`);
96
+ }
@@ -0,0 +1,30 @@
1
+ import { existsSync, rmSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { AdapterFactory } from "../../adapters/factory.js";
5
+ export async function uninstallCommand(options = {}) {
6
+ const adapter = AdapterFactory.detectAdapter();
7
+ if (!adapter.uninstall) {
8
+ console.error(`${adapter.name} does not support automated uninstall. Remove sessionmem from your MCP config manually.`);
9
+ process.exit(1);
10
+ }
11
+ const success = await adapter.uninstall();
12
+ if (!success) {
13
+ console.error(`✗ ${adapter.name} config removal failed`);
14
+ process.exit(1);
15
+ }
16
+ console.log(`✓ ${adapter.name} config removed`);
17
+ // Resolve dbPath: use injected override (for tests) or the default location
18
+ const dbPath = options.dbPath ?? join(homedir(), ".sessionmem", "memories.db");
19
+ if (options.purge) {
20
+ // --purge: delete only memories.db, not logs or the ~/.sessionmem directory
21
+ if (existsSync(dbPath)) {
22
+ rmSync(dbPath, { force: true });
23
+ }
24
+ console.log("✓ memories.db deleted");
25
+ }
26
+ else {
27
+ // Default: preserve memories.db
28
+ console.log(`Memory DB preserved at ${dbPath}`);
29
+ }
30
+ }
@@ -0,0 +1,69 @@
1
+ import { homedir, userInfo } from "os";
2
+ import { join, dirname } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { mkdirSync } from "fs";
5
+ import { openDb } from "../core/storage/db.js";
6
+ import { createMemoryCoreService } from "../core/api/memoryCoreService.js";
7
+ /**
8
+ * Resolve the local OS username once per invocation and sanitize it to a
9
+ * filename-safe token so it can be embedded in exports/filenames without path
10
+ * traversal. Any character outside [A-Za-z0-9._-] becomes "_"; an empty result
11
+ * falls back to "user".
12
+ */
13
+ export function localUsername() {
14
+ // Env override is a test-injection seam (mirrors deriveProjectId): the CLI
15
+ // runs as the invoking user, so SESSIONMEM_USERNAME is operator-controlled at
16
+ // the same trust level.
17
+ const envUsername = process.env.SESSIONMEM_USERNAME;
18
+ const raw = envUsername && envUsername.trim() !== "" ? envUsername : safeUserInfoName();
19
+ const sanitized = raw.replace(/[^A-Za-z0-9._-]/g, "_");
20
+ return sanitized === "" ? "user" : sanitized;
21
+ }
22
+ function safeUserInfoName() {
23
+ try {
24
+ return userInfo().username ?? "";
25
+ }
26
+ catch {
27
+ // userInfo() can throw on some platforms when there is no /etc/passwd entry.
28
+ return "";
29
+ }
30
+ }
31
+ function deriveProjectId() {
32
+ // Env override is a test-injection seam (mirrors Plan 01's override pattern):
33
+ // it lets a spawned binary target a deterministic projectId without touching
34
+ // the real ~/.sessionmem. No privilege boundary is crossed — the CLI runs as
35
+ // the invoking user and the env var is operator-controlled.
36
+ const envProjectId = process.env.SESSIONMEM_PROJECT_ID;
37
+ if (envProjectId && envProjectId.trim() !== "")
38
+ return envProjectId;
39
+ const cwd = process.cwd();
40
+ const parts = cwd.replace(/\\/g, "/").split("/");
41
+ const raw = parts[parts.length - 1] || "default";
42
+ // Sanitize to a filename-safe token (mirrors localUsername) so it can be
43
+ // embedded in shared-path join()s without path traversal.
44
+ const sanitized = raw.replace(/[^A-Za-z0-9._-]/g, "_");
45
+ return sanitized === "" || sanitized === "." || sanitized === ".."
46
+ ? "default"
47
+ : sanitized;
48
+ }
49
+ function defaultDbPath(dir) {
50
+ // Env override seam (see deriveProjectId). Defaults to ~/.sessionmem/memories.db.
51
+ const envDbPath = process.env.SESSIONMEM_DB_PATH;
52
+ if (envDbPath && envDbPath.trim() !== "")
53
+ return envDbPath;
54
+ // `dir` is the fixed ~/.sessionmem dir computed above, not user input.
55
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
56
+ return join(dir, "memories.db");
57
+ }
58
+ export function createCliContext(overrides = {}) {
59
+ const dir = join(homedir(), ".sessionmem");
60
+ mkdirSync(dir, { recursive: true });
61
+ const dbPath = overrides.dbPath ?? defaultDbPath(dir);
62
+ const here = dirname(fileURLToPath(import.meta.url));
63
+ const migrationsDir = overrides.migrationsDir ?? join(here, "..", "core", "schema", "migrations");
64
+ const username = overrides.username ?? localUsername();
65
+ const db = overrides.db ?? openDb({ dbPath, migrationsDir });
66
+ const service = overrides.service ?? createMemoryCoreService({ db, username });
67
+ const projectId = overrides.projectId ?? deriveProjectId();
68
+ return { db, service, projectId, username, dbPath };
69
+ }
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { createRequire } from "node:module";
4
+ import { runMcpServer } from "./commands/run.js";
5
+ import { installCommand } from "./commands/install.js";
6
+ import { uninstallCommand } from "./commands/uninstall.js";
7
+ import { pingCommand } from "./commands/ping.js";
8
+ import { searchCommand } from "./commands/search.js";
9
+ import { listCommand } from "./commands/list.js";
10
+ import { showCommand } from "./commands/show.js";
11
+ import { forgetCommand } from "./commands/forget.js";
12
+ import { exportCommand } from "./commands/export.js";
13
+ import { importCommand } from "./commands/import.js";
14
+ import { statsCommand } from "./commands/stats.js";
15
+ import { redactScanCommand } from "./commands/redactScan.js";
16
+ import { retentionPruneCommand } from "./commands/retention.js";
17
+ import { configGetCommand, configSetCommand } from "./commands/config.js";
18
+ import { teamEnableCommand, teamDisableCommand, teamStatusCommand, } from "./commands/team.js";
19
+ import { syncCommand } from "./commands/sync.js";
20
+ // Source the version from package.json (single source of truth) so `--version`
21
+ // never drifts from the published manifest. createRequire + resolveJsonModule
22
+ // reads the manifest relative to this module; from dist/cli/index.js the
23
+ // "../../package.json" specifier resolves to the package root.
24
+ const pkg = createRequire(import.meta.url)("../../package.json");
25
+ const program = new Command();
26
+ program.name("sessionmem").version(pkg.version);
27
+ program
28
+ .command("run")
29
+ .description("Start the sessionmem MCP server")
30
+ .action(runMcpServer);
31
+ program
32
+ .command("install")
33
+ .description("Install sessionmem into the current MCP host")
34
+ .action(installCommand);
35
+ program
36
+ .command("uninstall")
37
+ .description("Remove sessionmem from the current MCP host")
38
+ .option("--purge", "Also delete the local memories database")
39
+ .action(uninstallCommand);
40
+ program
41
+ .command("ping")
42
+ .description("Check sessionmem server connectivity")
43
+ .action(pingCommand);
44
+ // NOTE: commander always appends its own Command instance as the final
45
+ // argument to .action() callbacks. The search/list/show/forget/export/import/
46
+ // stats commands all declare a trailing `ctx?: CliContext` parameter (the
47
+ // test-injection seam). Passing a bare function reference would let commander's
48
+ // Command object land in the ctx slot, so `ctx ?? createCliContext()` resolves
49
+ // to a Command (which has no `.service`) and every command crashes at runtime.
50
+ // Arrow-wrap each handler to forward ONLY the real positional args/options and
51
+ // drop commander's trailing Command argument, leaving ctx undefined in
52
+ // production so each command falls through to createCliContext().
53
+ program
54
+ .command("search <query>")
55
+ .description("Search memories by semantic query")
56
+ .option("--limit <n>", "Maximum number of results", "10")
57
+ .action((query, options) => searchCommand(query, options));
58
+ program
59
+ .command("list")
60
+ .description("List all memories for the current project")
61
+ .action(() => listCommand());
62
+ program
63
+ .command("show <id>")
64
+ .description("Show full details of a memory by ID")
65
+ .action((id) => showCommand(id));
66
+ program
67
+ .command("forget <id>")
68
+ .description("Delete a memory by ID")
69
+ .option("--force", "Confirm deletion without dry-run prompt")
70
+ .action((id, options) => forgetCommand(id, options));
71
+ program
72
+ .command("export [path]")
73
+ .description("Export memories to a JSON file")
74
+ .action((path) => exportCommand(path));
75
+ program
76
+ .command("import <path>")
77
+ .description("Import memories from a JSON file")
78
+ .option("--merge", "Overwrite existing memories with imported data")
79
+ .action((path, options) => importCommand(path, options));
80
+ program
81
+ .command("stats")
82
+ .description("Show memory statistics for the current project")
83
+ .action(() => statsCommand());
84
+ // redact-scan — one-time scrub over existing memories. Scan is the
85
+ // non-destructive default; --apply redacts matching rows in place.
86
+ program
87
+ .command("redact-scan")
88
+ .description("Scan existing memories for secrets (dry-run by default)")
89
+ .option("--apply", "Redact matching memories in place")
90
+ .action((options) => redactScanCommand(options));
91
+ // retention command group — room for future subcommands. The "prune"
92
+ // subcommand is dry-run by default; --force confirms the hard delete.
93
+ const retention = program
94
+ .command("retention")
95
+ .description("Retention policy operations");
96
+ retention
97
+ .command("prune")
98
+ .description("Delete memories older than the retention window (dry-run by default)")
99
+ .option("--force", "Confirm deletion without dry-run")
100
+ .option("--days <n>", "Override the retention window in days")
101
+ .action((options) => retentionPruneCommand(options));
102
+ // config command group — generic get/set over ~/.sessionmem/config.json.
103
+ // config get/set are synchronous and take no CliContext, so the arrow-wrap here
104
+ // only drops commander's trailing Command argument.
105
+ const config = program
106
+ .command("config")
107
+ .description("Read and write sessionmem policy config");
108
+ config
109
+ .command("get <key>")
110
+ .description("Print the effective value of a config key")
111
+ .action((key) => configGetCommand(key));
112
+ config
113
+ .command("set <key> <value>")
114
+ .description("Persist a config key to ~/.sessionmem/config.json")
115
+ .action((key, value) => configSetCommand(key, value));
116
+ // team command group — turn shared-memory mode on/off and inspect it.
117
+ // enable/status take no CliContext (config-only), so their arrow-wraps just drop
118
+ // commander's trailing Command argument. disable accepts an optional CliContext
119
+ // seam for the --remove-team-memories DB delete; the arrow-wrap forwards only
120
+ // options so production falls through to createCliContext() (NOTE above).
121
+ const team = program
122
+ .command("team")
123
+ .description("Manage shared-path team memory mode");
124
+ team
125
+ .command("enable <path>")
126
+ .description("Enable team mode and record the shared memory path")
127
+ .action((path) => teamEnableCommand(path));
128
+ team
129
+ .command("disable")
130
+ .description("Disable team mode (teammate memories preserved by default)")
131
+ .option("--remove-team-memories", "Also delete teammate-authored memories for this project (local-only revert)")
132
+ .action((options) => teamDisableCommand(options));
133
+ team
134
+ .command("status")
135
+ .description("Show team mode state and shared-path availability")
136
+ .action(() => teamStatusCommand());
137
+ // sync — push a local snapshot to the shared path and pull every
138
+ // teammate snapshot back. syncCommand declares a trailing `ctx?` test seam, so
139
+ // arrow-wrap to drop commander's trailing Command argument (NOTE above).
140
+ program
141
+ .command("sync")
142
+ .description("Push local memories and pull teammate memories via the shared path")
143
+ .action(() => syncCommand());
144
+ program.parseAsync(process.argv).catch((err) => {
145
+ console.error(err instanceof Error ? err.message : String(err));
146
+ process.exit(1);
147
+ });
@@ -0,0 +1,37 @@
1
+ export function formatTable(rows) {
2
+ const ID_WIDTH = 36;
3
+ const IMP_WIDTH = 10;
4
+ const DATE_WIDTH = 10;
5
+ const PREVIEW_WIDTH = 60;
6
+ const header = "ID".padEnd(ID_WIDTH) +
7
+ " | " +
8
+ "importance".padEnd(IMP_WIDTH) +
9
+ " | " +
10
+ "date".padEnd(DATE_WIDTH) +
11
+ " | " +
12
+ "preview";
13
+ const lines = rows.map((row) => {
14
+ const preview = row.content.replace(/\s+/g, " ").slice(0, PREVIEW_WIDTH);
15
+ const date = row.createdAt.slice(0, 10);
16
+ return (row.id.padEnd(ID_WIDTH) +
17
+ " | " +
18
+ String(row.importance).padEnd(IMP_WIDTH) +
19
+ " | " +
20
+ date.padEnd(DATE_WIDTH) +
21
+ " | " +
22
+ preview);
23
+ });
24
+ return [header, ...lines].join("\n");
25
+ }
26
+ export function formatKeyValue(memory) {
27
+ const lines = [
28
+ `id: ${memory.id}`,
29
+ `content: ${memory.content}`,
30
+ `importance: ${memory.importance}`,
31
+ `created_at: ${memory.createdAt}`,
32
+ `session_id: ${memory.sessionId}`,
33
+ `project_id: ${memory.projectId}`,
34
+ `source_adapter: ${memory.sourceAdapter}`,
35
+ ];
36
+ return lines.join("\n");
37
+ }