sessionmem 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/adapters/capabilities/fallbackTools.js +2 -2
  2. package/dist/adapters/claudeMdInjector.js +49 -5
  3. package/dist/adapters/factory.js +68 -9
  4. package/dist/adapters/generic.js +147 -12
  5. package/dist/adapters/global/antigravity.js +14 -7
  6. package/dist/adapters/global/claudeCode.js +46 -10
  7. package/dist/adapters/global/codex.js +73 -13
  8. package/dist/adapters/global/qcoder.js +18 -5
  9. package/dist/adapters/ide/cline.js +54 -9
  10. package/dist/adapters/ide/cursor.js +15 -13
  11. package/dist/adapters/ide/installer.js +201 -8
  12. package/dist/adapters/ide/windsurf.js +14 -13
  13. package/dist/cli/commands/config.js +10 -1
  14. package/dist/cli/commands/import.js +6 -1
  15. package/dist/cli/commands/install.js +57 -16
  16. package/dist/cli/commands/ping.js +42 -8
  17. package/dist/cli/commands/reEmbed.js +4 -3
  18. package/dist/cli/commands/run.js +7 -17
  19. package/dist/cli/commands/savings.js +33 -17
  20. package/dist/cli/commands/sessionEnd.js +124 -0
  21. package/dist/cli/commands/sessionStart.js +52 -0
  22. package/dist/cli/commands/sync.js +39 -9
  23. package/dist/cli/commands/uninstall.js +35 -9
  24. package/dist/cli/context.js +14 -18
  25. package/dist/cli/index.js +16 -4
  26. package/dist/cli/projectId.js +69 -0
  27. package/dist/core/api/contracts.js +155 -42
  28. package/dist/core/api/errors.js +4 -7
  29. package/dist/core/api/memoryCoreService.js +319 -252
  30. package/dist/core/api/sessionLifecycleService.js +8 -0
  31. package/dist/core/config/policyConfig.js +33 -6
  32. package/dist/core/injection/formatStartupInjection.js +53 -9
  33. package/dist/core/retrieve/recencyBands.js +4 -1
  34. package/dist/core/retrieve/retrieveMemories.js +10 -8
  35. package/dist/core/schema/migrations/005_team_provenance.sql +5 -0
  36. package/dist/core/schema/migrations/006_access_pattern_boosting.sql +5 -0
  37. package/dist/core/schema/migrations/008_fts5_search.sql +6 -2
  38. package/dist/core/schema/migrations/009_session_events_unique.sql +24 -0
  39. package/dist/core/schema/runMigrations.js +64 -2
  40. package/dist/core/storage/memoryRepo.js +164 -7
  41. package/dist/core/storage/memorySearchRepo.js +45 -7
  42. package/dist/core/storage/sessionEventsRepo.js +15 -2
  43. package/dist/core/summarize/cloudSummarizer.js +15 -2
  44. package/dist/core/summarize/redaction.js +45 -8
  45. package/package.json +2 -2
@@ -16,7 +16,7 @@ function getSearchStatements(db) {
16
16
  WHERE project_id = ?
17
17
  AND (
18
18
  importance >= 8
19
- OR updated_at > datetime('now', '-90 days')
19
+ OR updated_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-90 days')
20
20
  )
21
21
  `),
22
22
  searchCandidatesFTS: db.prepare(`
@@ -51,6 +51,17 @@ function parseEmbedding(value) {
51
51
  return null;
52
52
  }
53
53
  }
54
+ function dedupById(candidates) {
55
+ // Defensive — FTS should not emit duplicates, but this guards backfill
56
+ // corruption (e.g. a double-run 008 migration) from inflating results.
57
+ const seen = new Set();
58
+ return candidates.filter((candidate) => {
59
+ if (seen.has(candidate.id))
60
+ return false;
61
+ seen.add(candidate.id);
62
+ return true;
63
+ });
64
+ }
54
65
  function mapRows(rows) {
55
66
  return rows.map((row) => {
56
67
  const parsed = parseEmbedding(row.embedding);
@@ -80,9 +91,19 @@ function sanitizeFtsQuery(queryText) {
80
91
  }
81
92
  /**
82
93
  * Pre-filter candidates using FTS5 full-text search before cosine similarity.
83
- * Returns top-50 candidates by FTS rank. Falls back to full
84
- * searchMemoryCandidates when FTS returns fewer than 5 results
85
- * (poor keyword overlap).
94
+ * Returns up to FTS_CANDIDATE_LIMIT candidates.
95
+ *
96
+ * FTS keyword overlap can be sparse, so the fallback recency/importance scan is
97
+ * UNIONed with (never substituted for) the FTS hits:
98
+ * - >= FTS_FALLBACK_THRESHOLD FTS hits: use the FTS hits as-is (well-matched).
99
+ * - 0 FTS hits: use the fallback scan only.
100
+ * - 1..threshold-1 FTS hits: UNION the FTS hits with the fallback scan,
101
+ * deduplicated by id (FTS hits first), capped at FTS_CANDIDATE_LIMIT.
102
+ *
103
+ * The previous behavior REPLACED a small FTS hit set with the fallback scan,
104
+ * which silently dropped genuine matches that were old (>90d) and low-importance
105
+ * (<8) — exactly the rows the fallback's filter excludes — returning zero
106
+ * candidates for a query that matched only such rows.
86
107
  */
87
108
  export function searchMemoryCandidatesFTS(db, projectId, queryText) {
88
109
  const sanitized = sanitizeFtsQuery(queryText);
@@ -97,8 +118,25 @@ export function searchMemoryCandidatesFTS(db, projectId, queryText) {
97
118
  // FTS5 MATCH can throw on malformed queries — fall back to full scan
98
119
  return searchMemoryCandidates(db, projectId);
99
120
  }
100
- if (rows.length < FTS_FALLBACK_THRESHOLD) {
101
- return searchMemoryCandidates(db, projectId);
121
+ if (rows.length >= FTS_FALLBACK_THRESHOLD) {
122
+ return dedupById(mapRows(rows));
102
123
  }
103
- return mapRows(rows);
124
+ const fallback = searchMemoryCandidates(db, projectId);
125
+ if (rows.length === 0) {
126
+ return fallback;
127
+ }
128
+ // UNION FTS hits (first) with the fallback scan, deduplicated by id and
129
+ // capped at the same total limit FTS would have returned.
130
+ const ftsHits = mapRows(rows);
131
+ const seen = new Set(ftsHits.map((candidate) => candidate.id));
132
+ const merged = [...ftsHits];
133
+ for (const candidate of fallback) {
134
+ if (merged.length >= FTS_CANDIDATE_LIMIT)
135
+ break;
136
+ if (seen.has(candidate.id))
137
+ continue;
138
+ seen.add(candidate.id);
139
+ merged.push(candidate);
140
+ }
141
+ return merged;
104
142
  }
@@ -4,8 +4,11 @@ function getSessionEventsStatements(db) {
4
4
  if (stmts)
5
5
  return stmts;
6
6
  stmts = {
7
+ // INSERT OR IGNORE so re-ingesting an event with the same logical key
8
+ // (project_id, session_id, event_index) — now a UNIQUE index, migration 009
9
+ // — is a no-op rather than a duplicate row or a PK error.
7
10
  insertEvent: db.prepare(`
8
- INSERT INTO session_events (
11
+ INSERT OR IGNORE INTO session_events (
9
12
  id, project_id, session_id, event_index, event_type, payload_json, created_at
10
13
  ) VALUES (
11
14
  @id, @project_id, @session_id, @event_index, @event_type, @payload_json,
@@ -18,12 +21,22 @@ function getSessionEventsStatements(db) {
18
21
  WHERE project_id = ? AND session_id = ?
19
22
  ORDER BY event_index ASC
20
23
  `),
24
+ countAll: db.prepare("SELECT COUNT(*) AS count FROM session_events WHERE project_id = ?"),
21
25
  };
22
26
  sessionEventsStmtCache.set(db, stmts);
23
27
  return stmts;
24
28
  }
29
+ /**
30
+ * Insert a session event. Returns the number of rows written (1, or 0 when the
31
+ * (project_id, session_id, event_index) key already exists and the insert was
32
+ * ignored).
33
+ */
25
34
  export function insertSessionEvent(db, input) {
26
- getSessionEventsStatements(db).insertEvent.run(input);
35
+ return getSessionEventsStatements(db).insertEvent.run(input).changes;
36
+ }
37
+ export function countAllSessionEvents(db, projectId) {
38
+ const row = getSessionEventsStatements(db).countAll.get(projectId);
39
+ return row.count;
27
40
  }
28
41
  export function listSessionEventsBySession(db, projectId, sessionId) {
29
42
  return getSessionEventsStatements(db).listBySession.all(projectId, sessionId);
@@ -19,15 +19,28 @@ export async function summarizeWithCloud(input) {
19
19
  const client = new Anthropic({ apiKey: input.anthropicApiKey });
20
20
  const response = await client.messages.create({
21
21
  model,
22
- max_tokens: input.summaryTokenCap * 2,
22
+ // Cap the per-response token budget so a large summaryTokenCap cannot pass
23
+ // an out-of-range max_tokens to the API (which would error).
24
+ max_tokens: Math.min(Math.floor((input.summaryTokenCap ?? 4000) * 1.5), 8192),
23
25
  system: CLOUD_SYSTEM_PROMPT,
24
26
  messages: [{ role: "user", content: localResult.summary }],
25
27
  });
26
28
  // Extract text from response content blocks
27
- const text = response.content
29
+ let text = response.content
28
30
  .filter((block) => block.type === "text")
29
31
  .map((block) => block.text)
30
32
  .join("\n");
33
+ // Throw on an empty response rather than storing an empty memory; the
34
+ // session-lifecycle retry/fallback-to-local path handles the failure.
35
+ if (!text || text.trim().length === 0) {
36
+ throw new Error("Cloud summarizer returned empty response");
37
+ }
38
+ // Cap cloud output to summaryTokenCap (rough token→char ratio) for parity
39
+ // with the local summarizer, so a verbose cloud response cannot blow past the
40
+ // configured budget.
41
+ if (input.summaryTokenCap && text.length > input.summaryTokenCap * 4) {
42
+ text = text.slice(0, input.summaryTokenCap * 4);
43
+ }
31
44
  return {
32
45
  summary: text,
33
46
  warningCodes: localResult.warningCodes,
@@ -3,8 +3,20 @@
3
3
  // a fragment of a larger secret and leave a partial body behind. All patterns are
4
4
  // anchored with explicit literal prefixes/markers and use bounded quantifiers to
5
5
  // avoid catastrophic backtracking (ReDoS).
6
- function defaultRules() {
6
+ //
7
+ // The rule set is allocated once at module load (not per call): the rules are
8
+ // pure, stateless closures, so hoisting avoids re-allocating 8 closures on every
9
+ // applyRedaction invocation — a measurable saving in batch/import/pull loops.
10
+ // The regex literals carry no `lastIndex` state because every pattern is used
11
+ // with String.prototype.replace (not stateful .test()/.exec() on a shared regex).
12
+ const DEFAULT_RULES = createDefaultRules();
13
+ function createDefaultRules() {
7
14
  return [
15
+ // URL-embedded credentials: scheme://user:password@host. Run BEFORE the
16
+ // email rule so the `password@host` segment is collapsed here rather than
17
+ // partially matched as an email. Scheme + host are preserved; the
18
+ // user:password pair is redacted. Bounded quantifiers stay ReDoS-safe.
19
+ (input) => input.replace(/\b([a-z][a-z0-9+.-]{0,20}:\/\/)[^\s:/@]{1,256}:[^\s/@]{1,256}@/gi, "$1[REDACTED_CREDENTIALS]@"),
8
20
  // Email (original rule — unchanged).
9
21
  (input) => input.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]"),
10
22
  // PEM private key block: ----BEGIN <type> PRIVATE KEY---- ... ----END ... ----.
@@ -14,14 +26,39 @@ function defaultRules() {
14
26
  (input) => input.replace(/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/g, "[REDACTED_JWT]"),
15
27
  // AWS access key id: AKIA + 16 uppercase alphanumerics.
16
28
  (input) => input.replace(/\bAKIA[A-Z0-9]{16}\b/g, "[REDACTED_AWS_KEY]"),
17
- // GitHub tokens: ghp_/gho_/ghu_/ghs_/ghr_ + 36 alphanumerics.
18
- (input) => input.replace(/\bgh[poushr]_[A-Za-z0-9]{36}\b/g, "[REDACTED_GITHUB_TOKEN]"),
19
- // OpenAI-style API key (original rule unchanged).
20
- (input) => input.replace(/sk-[a-zA-Z0-9]{12,}/g, "[REDACTED_API_KEY]"),
29
+ // AWS temporary credentials access key id: ASIA + 16 uppercase alphanumerics.
30
+ (input) => input.replace(/\bASIA[A-Z0-9]{16}\b/g, "[REDACTED_AWS_KEY]"),
31
+ // GitHub tokens: ghp_/gho_/ghu_/ghs_/ghr_ + 36 OR MORE alphanumerics
32
+ // (GitHub has lengthened tokens over time; `{36,}` future-proofs the rule).
33
+ (input) => input.replace(/\bgh[poushr]_[A-Za-z0-9]{36,}\b/g, "[REDACTED_GITHUB_TOKEN]"),
34
+ // GitHub fine-grained personal access tokens: github_pat_ + long body.
35
+ (input) => input.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "[REDACTED_GITHUB_TOKEN]"),
36
+ // Slack tokens: xoxb-/xoxp-/xoxa-/xoxr-/xoxs-/xoxe-/xoxc-/xoxt- and the
37
+ // app-level xapp- prefix + dash-delimited segments.
38
+ (input) => input.replace(/\b(?:xox[baprsect]|xapp)-[A-Za-z0-9-]{10,256}\b/g, "[REDACTED_SLACK_TOKEN]"),
39
+ // Stripe live keys: secret (sk_live_), restricted (rk_live_), and
40
+ // publishable (pk_live_) + 24 or more alphanumerics.
41
+ (input) => input.replace(/\b[srp]k_live_[A-Za-z0-9]{24,}\b/g, "[REDACTED_STRIPE_KEY]"),
42
+ // npm access tokens: npm_ + 36 alphanumerics.
43
+ (input) => input.replace(/\bnpm_[A-Za-z0-9]{36}\b/g, "[REDACTED_NPM_TOKEN]"),
44
+ // Google API keys: AIza + 35 url-safe chars (39 total).
45
+ (input) => input.replace(/\bAIza[0-9A-Za-z_-]{35}\b/g, "[REDACTED_GOOGLE_API_KEY]"),
46
+ // OpenAI-style API key. Allow an optional internal dash segment so
47
+ // project-scoped keys (sk-proj-...) are fully redacted rather than leaving
48
+ // the project segment behind. Bounded quantifiers stay ReDoS-safe.
49
+ (input) => input.replace(/\bsk-(?:[a-zA-Z0-9]{1,32}-){0,4}[a-zA-Z0-9]{12,200}\b/g, "[REDACTED_API_KEY]"),
21
50
  // Bearer token header value: "Bearer <token>" (case-insensitive), token redacted.
22
51
  (input) => input.replace(/\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, "Bearer [REDACTED_BEARER_TOKEN]"),
23
- // Connection-string assignment: password=/secret= value -> key kept, value redacted.
24
- (input) => input.replace(/\b(password|secret)=([^\s"'&;]+)/gi, "$1=[REDACTED]"),
52
+ // Config/connection-string assignments: key=value where key is a known
53
+ // secret-bearing name. The key is kept and the value redacted. Covers
54
+ // password/secret plus api_key/apikey/token/access_token/pwd in URL query
55
+ // strings and config files. The `=` separator may carry surrounding spaces.
56
+ (input) => input.replace(/\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|token)\s*=\s*([^\s"'&;]+)/gi, "$1=[REDACTED]"),
57
+ // JSON-form secret assignments: "key": "value" for known secret-bearing
58
+ // keys. Complements the key=value rule above so secrets embedded in JSON
59
+ // payloads (config files, logged request bodies) are redacted too. The key
60
+ // is preserved; the quoted value is collapsed.
61
+ (input) => input.replace(/"(password|secret|api_key|token|access_token|auth_token|client_secret|private_key|pwd|passwd)"\s*:\s*"[^"]{4,}"/gi, '"$1": "[REDACTED]"'),
25
62
  ];
26
63
  }
27
64
  export function applyRedaction(input, options) {
@@ -33,7 +70,7 @@ export function applyRedaction(input, options) {
33
70
  }
34
71
  let text = input;
35
72
  const warningCodes = [];
36
- for (const rule of options.rules ?? defaultRules()) {
73
+ for (const rule of options.rules ?? DEFAULT_RULES) {
37
74
  try {
38
75
  text = rule(text);
39
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sessionmem",
3
- "version": "1.0.6",
3
+ "version": "1.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local-first MCP memory layer for coding agents across Claude Code, Codex, Cursor, Cline, Windsurf, and other MCP-compatible hosts.",
@@ -28,7 +28,7 @@
28
28
  "lint": "eslint .",
29
29
  "typecheck": "tsc --noEmit",
30
30
  "benchmark": "node scripts/benchmark.mjs",
31
- "postversion": "node -e \"const v=require('./package.json').version; ['package/package.json','.claude-plugin/plugin.json','server.json'].forEach(f=>{const o=require('./'+f);o.version=v;if(o.packages&&o.packages[0])o.packages[0].version=v;require('fs').writeFileSync('./'+f,JSON.stringify(o,null,2)+'\\n')})\""
31
+ "postversion": "node -e \"const v=require('./package.json').version; ['package/package.json','.claude-plugin/plugin.json','server.json'].forEach(f=>{const o=require('./'+f);o.version=v;if(o.packages&&o.packages[0])o.packages[0].version=v;require('fs').writeFileSync('./'+f,JSON.stringify(o,null,2)+'\\n')})\" && npm run build"
32
32
  },
33
33
  "dependencies": {
34
34
  "@anthropic-ai/sdk": "^0.105.0",