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.
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/dist/adapters/capabilities/fallbackTools.js +36 -0
- package/dist/adapters/contract/hostAdapterContract.js +1 -0
- package/dist/adapters/factory.js +40 -0
- package/dist/adapters/generic.js +128 -0
- package/dist/adapters/global/antigravity.js +22 -0
- package/dist/adapters/global/claudeCode.js +22 -0
- package/dist/adapters/global/codex.js +22 -0
- package/dist/adapters/global/qcoder.js +22 -0
- package/dist/adapters/ide/cline.js +20 -0
- package/dist/adapters/ide/cursor.js +28 -0
- package/dist/adapters/ide/installer.js +57 -0
- package/dist/adapters/ide/windsurf.js +28 -0
- package/dist/adapters/tools/ping.js +15 -0
- package/dist/cli/commands/config.js +79 -0
- package/dist/cli/commands/export.js +28 -0
- package/dist/cli/commands/forget.js +28 -0
- package/dist/cli/commands/import.js +112 -0
- package/dist/cli/commands/install.js +57 -0
- package/dist/cli/commands/list.js +13 -0
- package/dist/cli/commands/ping.js +12 -0
- package/dist/cli/commands/redactScan.js +40 -0
- package/dist/cli/commands/retention.js +54 -0
- package/dist/cli/commands/run.js +26 -0
- package/dist/cli/commands/search.js +29 -0
- package/dist/cli/commands/show.js +15 -0
- package/dist/cli/commands/stats.js +46 -0
- package/dist/cli/commands/sync.js +118 -0
- package/dist/cli/commands/team.js +96 -0
- package/dist/cli/commands/uninstall.js +30 -0
- package/dist/cli/context.js +69 -0
- package/dist/cli/index.js +147 -0
- package/dist/cli/output.js +37 -0
- package/dist/core/api/contracts.js +263 -0
- package/dist/core/api/errors.js +29 -0
- package/dist/core/api/localOnlyPolicy.js +29 -0
- package/dist/core/api/memoryCoreService.js +595 -0
- package/dist/core/api/sessionLifecycleService.js +289 -0
- package/dist/core/config/policyConfig.js +131 -0
- package/dist/core/embed/deterministicEmbed.js +31 -0
- package/dist/core/embed/embeddingVersion.js +1 -0
- package/dist/core/embed/reembedPolicy.js +9 -0
- package/dist/core/embed/textNormalize.js +12 -0
- package/dist/core/injection/formatStartupInjection.js +97 -0
- package/dist/core/injection/tokenBudget.js +38 -0
- package/dist/core/retrieve/decay.js +15 -0
- package/dist/core/retrieve/importance.js +6 -0
- package/dist/core/retrieve/recencyBands.js +18 -0
- package/dist/core/retrieve/retrieveMemories.js +83 -0
- package/dist/core/retrieve/score.js +25 -0
- package/dist/core/schema/migrations/001_initial.sql +25 -0
- package/dist/core/schema/migrations/002_indexes.sql +18 -0
- package/dist/core/schema/migrations/003_summarization_failures.sql +14 -0
- package/dist/core/schema/migrations/004_memory_feedback.sql +12 -0
- package/dist/core/schema/migrations/005_team_provenance.sql +9 -0
- package/dist/core/schema/runMigrations.js +38 -0
- package/dist/core/session.js +4 -0
- package/dist/core/storage/db.js +8 -0
- package/dist/core/storage/memoryFeedbackRepo.js +16 -0
- package/dist/core/storage/memoryRepo.js +179 -0
- package/dist/core/storage/memorySearchRepo.js +30 -0
- package/dist/core/storage/sessionEventsRepo.js +20 -0
- package/dist/core/storage/summarizationFailuresRepo.js +39 -0
- package/dist/core/storage/types.js +1 -0
- package/dist/core/summarize/cloudSummarizer.js +19 -0
- package/dist/core/summarize/localSummarizer.js +31 -0
- package/dist/core/summarize/redaction.js +48 -0
- package/dist/core/summarize/strategySelector.js +7 -0
- package/dist/core/summarize/summaryShape.js +49 -0
- 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
|
+
}
|