sessionmem 1.1.2 → 1.3.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/README.md +74 -42
- package/dist/adapters/claudeMdInjector.js +17 -1
- package/dist/adapters/generic.js +3 -0
- package/dist/adapters/global/claudeCode.js +8 -3
- package/dist/adapters/ide/installer.js +35 -8
- package/dist/cli/commands/browse.js +33 -0
- package/dist/cli/commands/dedupe.js +139 -0
- package/dist/cli/commands/ingestHook.js +193 -0
- package/dist/cli/commands/savings.js +3 -1
- package/dist/cli/commands/stats.js +7 -0
- package/dist/cli/index.js +23 -11
- package/dist/cli/projectId.js +48 -2
- package/dist/core/api/contracts.js +13 -1
- package/dist/core/api/memoryCoreService.js +40 -4
- package/dist/core/retrieve/decay.js +11 -4
- package/dist/core/retrieve/retrieveMemories.js +1 -1
- package/dist/core/schema/migrations/010_memory_tags.sql +9 -0
- package/dist/core/schema/migrations/011_memory_expiry.sql +9 -0
- package/dist/core/storage/memoryRepo.js +18 -1
- package/dist/core/storage/memorySearchRepo.js +36 -12
- package/dist/core/storage/sessionEventsRepo.js +21 -0
- package/dist/core/summarize/redaction.js +70 -10
- package/dist/core/summarize/summaryShape.js +50 -20
- package/package.json +4 -3
- package/dist/core/session.js +0 -4
|
@@ -6,10 +6,12 @@ const UPSERT_INSERT_HEAD = `
|
|
|
6
6
|
INSERT INTO memories (
|
|
7
7
|
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
8
8
|
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
9
|
+
tags, expires_at,
|
|
9
10
|
created_at, updated_at
|
|
10
11
|
) VALUES (
|
|
11
12
|
@id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
|
|
12
13
|
@importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
|
|
14
|
+
@tags, @expires_at,
|
|
13
15
|
COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
14
16
|
COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
15
17
|
)
|
|
@@ -26,6 +28,8 @@ const UPSERT_INSERT_TAIL = `
|
|
|
26
28
|
embedding_version = excluded.embedding_version,
|
|
27
29
|
author = excluded.author,
|
|
28
30
|
origin_project_id = excluded.origin_project_id,
|
|
31
|
+
tags = excluded.tags,
|
|
32
|
+
expires_at = excluded.expires_at,
|
|
29
33
|
created_at = excluded.created_at,
|
|
30
34
|
updated_at = excluded.updated_at
|
|
31
35
|
`;
|
|
@@ -39,10 +43,12 @@ function getStatements(db) {
|
|
|
39
43
|
INSERT INTO memories (
|
|
40
44
|
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
41
45
|
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
46
|
+
tags, expires_at,
|
|
42
47
|
created_at, updated_at
|
|
43
48
|
) VALUES (
|
|
44
49
|
@id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
|
|
45
50
|
@importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
|
|
51
|
+
@tags, @expires_at,
|
|
46
52
|
COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
47
53
|
COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
48
54
|
)
|
|
@@ -51,10 +57,12 @@ function getStatements(db) {
|
|
|
51
57
|
INSERT INTO memories (
|
|
52
58
|
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
53
59
|
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
60
|
+
tags, expires_at,
|
|
54
61
|
created_at, updated_at
|
|
55
62
|
) VALUES (
|
|
56
63
|
@id, @project_id, @session_id, @source_adapter, 'summary', @content, @normalized_content,
|
|
57
64
|
@importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
|
|
65
|
+
@tags, @expires_at,
|
|
58
66
|
COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
59
67
|
COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
60
68
|
)
|
|
@@ -70,6 +78,8 @@ function getStatements(db) {
|
|
|
70
78
|
embedding_version = excluded.embedding_version,
|
|
71
79
|
author = excluded.author,
|
|
72
80
|
origin_project_id = excluded.origin_project_id,
|
|
81
|
+
tags = excluded.tags,
|
|
82
|
+
expires_at = excluded.expires_at,
|
|
73
83
|
updated_at = COALESCE(excluded.updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
74
84
|
ON CONFLICT(id)
|
|
75
85
|
DO UPDATE SET
|
|
@@ -84,16 +94,20 @@ function getStatements(db) {
|
|
|
84
94
|
embedding_version = excluded.embedding_version,
|
|
85
95
|
author = excluded.author,
|
|
86
96
|
origin_project_id = excluded.origin_project_id,
|
|
97
|
+
tags = excluded.tags,
|
|
98
|
+
expires_at = excluded.expires_at,
|
|
87
99
|
updated_at = COALESCE(excluded.updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
88
100
|
`),
|
|
89
101
|
listByProject: db.prepare(`
|
|
90
102
|
SELECT
|
|
91
103
|
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
92
104
|
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
105
|
+
tags, expires_at,
|
|
93
106
|
access_count, last_accessed, created_at, updated_at
|
|
94
107
|
FROM memories
|
|
95
108
|
WHERE project_id = ?
|
|
96
|
-
|
|
109
|
+
AND (expires_at IS NULL OR expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
110
|
+
ORDER BY updated_at DESC, id ASC
|
|
97
111
|
`),
|
|
98
112
|
// Lightweight projection for token-savings accounting: only `content` is
|
|
99
113
|
// needed to count tokens, so we deliberately avoid pulling the (potentially
|
|
@@ -110,6 +124,7 @@ function getStatements(db) {
|
|
|
110
124
|
SELECT
|
|
111
125
|
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
112
126
|
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
127
|
+
tags, expires_at,
|
|
113
128
|
access_count, last_accessed, created_at, updated_at
|
|
114
129
|
FROM memories
|
|
115
130
|
WHERE project_id = ? AND id = ?
|
|
@@ -194,6 +209,8 @@ function toParams(input) {
|
|
|
194
209
|
embedding_version: input.embedding_version ?? null,
|
|
195
210
|
author: input.author ?? "",
|
|
196
211
|
origin_project_id: input.origin_project_id ?? null,
|
|
212
|
+
tags: input.tags ?? null,
|
|
213
|
+
expires_at: input.expires_at ?? null,
|
|
197
214
|
created_at: input.created_at ?? null,
|
|
198
215
|
updated_at: input.updated_at ?? null,
|
|
199
216
|
};
|
|
@@ -1,22 +1,36 @@
|
|
|
1
1
|
import { EMBEDDING_VERSION } from "../embed/embeddingVersion.js";
|
|
2
2
|
const FTS_CANDIDATE_LIMIT = 50;
|
|
3
3
|
const FTS_FALLBACK_THRESHOLD = 5;
|
|
4
|
+
/** Recency window for the fallback candidate scan. */
|
|
5
|
+
const FALLBACK_RECENCY_DAYS = 90;
|
|
6
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
7
|
+
/** Storage timestamp format: ISO-8601 UTC with millisecond precision. */
|
|
8
|
+
function toStorageTimestamp(date) {
|
|
9
|
+
return date.toISOString();
|
|
10
|
+
}
|
|
4
11
|
const searchStmtCache = new WeakMap();
|
|
5
12
|
function getSearchStatements(db) {
|
|
6
13
|
let stmts = searchStmtCache.get(db);
|
|
7
14
|
if (stmts)
|
|
8
15
|
return stmts;
|
|
9
16
|
stmts = {
|
|
17
|
+
// `now` and the recency cutoff are bound parameters rather than
|
|
18
|
+
// strftime('now') so the caller's clock drives both filters. With SQL's own
|
|
19
|
+
// clock, a caller that passes an explicit `now` (retrieveMemories, and every
|
|
20
|
+
// test that pins a date) silently got a different window than it asked for,
|
|
21
|
+
// and fixed-date fixtures aged out of the 90-day band over real calendar
|
|
22
|
+
// time — turning deterministic tests into time bombs.
|
|
10
23
|
searchCandidates: db.prepare(`
|
|
11
24
|
SELECT
|
|
12
25
|
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
13
26
|
importance, author, origin_project_id, access_count, created_at, updated_at,
|
|
14
27
|
embedding, embedding_dim, embedding_version
|
|
15
28
|
FROM memories
|
|
16
|
-
WHERE project_id =
|
|
29
|
+
WHERE project_id = @projectId
|
|
30
|
+
AND (expires_at IS NULL OR expires_at > @now)
|
|
17
31
|
AND (
|
|
18
32
|
importance >= 8
|
|
19
|
-
OR updated_at >
|
|
33
|
+
OR updated_at > @recencyCutoff
|
|
20
34
|
)
|
|
21
35
|
`),
|
|
22
36
|
searchCandidatesFTS: db.prepare(`
|
|
@@ -27,10 +41,11 @@ function getSearchStatements(db) {
|
|
|
27
41
|
m.embedding, m.embedding_dim, m.embedding_version
|
|
28
42
|
FROM memories_fts
|
|
29
43
|
JOIN memories m ON m.rowid = memories_fts.rowid
|
|
30
|
-
WHERE memories_fts MATCH
|
|
31
|
-
AND m.project_id =
|
|
44
|
+
WHERE memories_fts MATCH @match
|
|
45
|
+
AND m.project_id = @projectId
|
|
46
|
+
AND (m.expires_at IS NULL OR m.expires_at > @now)
|
|
32
47
|
ORDER BY rank
|
|
33
|
-
LIMIT
|
|
48
|
+
LIMIT @limit
|
|
34
49
|
`),
|
|
35
50
|
};
|
|
36
51
|
searchStmtCache.set(db, stmts);
|
|
@@ -72,8 +87,12 @@ function mapRows(rows) {
|
|
|
72
87
|
};
|
|
73
88
|
});
|
|
74
89
|
}
|
|
75
|
-
export function searchMemoryCandidates(db, projectId) {
|
|
76
|
-
const rows = getSearchStatements(db).searchCandidates.all(
|
|
90
|
+
export function searchMemoryCandidates(db, projectId, now = new Date()) {
|
|
91
|
+
const rows = getSearchStatements(db).searchCandidates.all({
|
|
92
|
+
projectId,
|
|
93
|
+
now: toStorageTimestamp(now),
|
|
94
|
+
recencyCutoff: toStorageTimestamp(new Date(now.getTime() - FALLBACK_RECENCY_DAYS * MS_PER_DAY)),
|
|
95
|
+
});
|
|
77
96
|
return mapRows(rows);
|
|
78
97
|
}
|
|
79
98
|
/**
|
|
@@ -105,23 +124,28 @@ function sanitizeFtsQuery(queryText) {
|
|
|
105
124
|
* (<8) — exactly the rows the fallback's filter excludes — returning zero
|
|
106
125
|
* candidates for a query that matched only such rows.
|
|
107
126
|
*/
|
|
108
|
-
export function searchMemoryCandidatesFTS(db, projectId, queryText) {
|
|
127
|
+
export function searchMemoryCandidatesFTS(db, projectId, queryText, now = new Date()) {
|
|
109
128
|
const sanitized = sanitizeFtsQuery(queryText);
|
|
110
129
|
if (!sanitized) {
|
|
111
|
-
return searchMemoryCandidates(db, projectId);
|
|
130
|
+
return searchMemoryCandidates(db, projectId, now);
|
|
112
131
|
}
|
|
113
132
|
let rows;
|
|
114
133
|
try {
|
|
115
|
-
rows = getSearchStatements(db).searchCandidatesFTS.all(
|
|
134
|
+
rows = getSearchStatements(db).searchCandidatesFTS.all({
|
|
135
|
+
match: sanitized,
|
|
136
|
+
projectId,
|
|
137
|
+
now: toStorageTimestamp(now),
|
|
138
|
+
limit: FTS_CANDIDATE_LIMIT,
|
|
139
|
+
});
|
|
116
140
|
}
|
|
117
141
|
catch {
|
|
118
142
|
// FTS5 MATCH can throw on malformed queries — fall back to full scan
|
|
119
|
-
return searchMemoryCandidates(db, projectId);
|
|
143
|
+
return searchMemoryCandidates(db, projectId, now);
|
|
120
144
|
}
|
|
121
145
|
if (rows.length >= FTS_FALLBACK_THRESHOLD) {
|
|
122
146
|
return dedupById(mapRows(rows));
|
|
123
147
|
}
|
|
124
|
-
const fallback = searchMemoryCandidates(db, projectId);
|
|
148
|
+
const fallback = searchMemoryCandidates(db, projectId, now);
|
|
125
149
|
if (rows.length === 0) {
|
|
126
150
|
return fallback;
|
|
127
151
|
}
|
|
@@ -22,6 +22,11 @@ function getSessionEventsStatements(db) {
|
|
|
22
22
|
ORDER BY event_index ASC
|
|
23
23
|
`),
|
|
24
24
|
countAll: db.prepare("SELECT COUNT(*) AS count FROM session_events WHERE project_id = ?"),
|
|
25
|
+
nextEventIndex: db.prepare(`
|
|
26
|
+
SELECT COALESCE(MAX(event_index), -1) + 1 AS next
|
|
27
|
+
FROM session_events
|
|
28
|
+
WHERE project_id = ? AND session_id = ?
|
|
29
|
+
`),
|
|
25
30
|
};
|
|
26
31
|
sessionEventsStmtCache.set(db, stmts);
|
|
27
32
|
return stmts;
|
|
@@ -34,6 +39,22 @@ function getSessionEventsStatements(db) {
|
|
|
34
39
|
export function insertSessionEvent(db, input) {
|
|
35
40
|
return getSessionEventsStatements(db).insertEvent.run(input).changes;
|
|
36
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Next free `event_index` for a session (max + 1, or 0 when the session has no
|
|
44
|
+
* events yet).
|
|
45
|
+
*
|
|
46
|
+
* Callers that have no natural ordering key of their own (the PostToolUse hook
|
|
47
|
+
* fires once per tool use, in separate processes) use this instead of inventing
|
|
48
|
+
* one. A wall-clock index collides whenever two tool uses land in the same
|
|
49
|
+
* millisecond — which parallel tool calls routinely do — and the UNIQUE index
|
|
50
|
+
* on (project_id, session_id, event_index) then makes `INSERT OR IGNORE` drop
|
|
51
|
+
* the loser silently. Must be called inside the same immediate transaction as
|
|
52
|
+
* the insert so concurrent writers cannot read the same value.
|
|
53
|
+
*/
|
|
54
|
+
export function nextSessionEventIndex(db, projectId, sessionId) {
|
|
55
|
+
const row = getSessionEventsStatements(db).nextEventIndex.get(projectId, sessionId);
|
|
56
|
+
return row.next;
|
|
57
|
+
}
|
|
37
58
|
export function countAllSessionEvents(db, projectId) {
|
|
38
59
|
const row = getSessionEventsStatements(db).countAll.get(projectId);
|
|
39
60
|
return row.count;
|
|
@@ -9,6 +9,43 @@
|
|
|
9
9
|
// applyRedaction invocation — a measurable saving in batch/import/pull loops.
|
|
10
10
|
// The regex literals carry no `lastIndex` state because every pattern is used
|
|
11
11
|
// with String.prototype.replace (not stateful .test()/.exec() on a shared regex).
|
|
12
|
+
/**
|
|
13
|
+
* Key names that mark an assignment's value as a secret. Matched
|
|
14
|
+
* case-insensitively and allowed to carry bounded affixes on either side, so
|
|
15
|
+
* `AWS_SECRET_ACCESS_KEY` and `SECRET_TOKEN_VALUE` match as readily as `secret`.
|
|
16
|
+
*/
|
|
17
|
+
const SECRET_KEY_NAMES = [
|
|
18
|
+
"password",
|
|
19
|
+
"passwd",
|
|
20
|
+
"pwd",
|
|
21
|
+
"secret",
|
|
22
|
+
"api[_-]?key",
|
|
23
|
+
"apikey",
|
|
24
|
+
"access[_-]?token",
|
|
25
|
+
"refresh[_-]?token",
|
|
26
|
+
"auth[_-]?token",
|
|
27
|
+
"client[_-]?secret",
|
|
28
|
+
"private[_-]?key",
|
|
29
|
+
"credentials?",
|
|
30
|
+
"token",
|
|
31
|
+
].join("|");
|
|
32
|
+
/**
|
|
33
|
+
* `<key><separator><value>` where the key names a secret. Capture groups are
|
|
34
|
+
* (key, separator, value) so the replacement can keep the key and separator
|
|
35
|
+
* verbatim and swap only the value.
|
|
36
|
+
*
|
|
37
|
+
* Both `:` and `=` are accepted (YAML, JSON, `.env`, query strings, log lines),
|
|
38
|
+
* and the value may be backslash-escaped-quoted, double-quoted, single-quoted,
|
|
39
|
+
* or bare. Every quantifier is bounded, so the pattern is ReDoS-safe.
|
|
40
|
+
*
|
|
41
|
+
* The escaped-quote branch comes first and the bare branch excludes `\`, which
|
|
42
|
+
* together keep a JSON payload parseable after redaction. Session events store
|
|
43
|
+
* JSON, so a secret inside a stringified field arrives as `password=\"a b\"`;
|
|
44
|
+
* letting the bare branch match the lone backslash replaced it with
|
|
45
|
+
* `[REDACTED]` and left the rest of the quoted run stranded, producing
|
|
46
|
+
* unparseable JSON.
|
|
47
|
+
*/
|
|
48
|
+
const SECRET_ASSIGNMENT_PATTERN = new RegExp(String.raw `\b([A-Za-z0-9_.-]{0,40}(?:${SECRET_KEY_NAMES})[A-Za-z0-9_.-]{0,40}\\?"?)(\s*[:=]\s*)(\\"(?:\\.|[^"\\]){0,4096}\\"|"[^"\n]{1,4096}"|'[^'\n]{1,4096}'|[^\s"'\\,;&}]{1,4096})`, "gi");
|
|
12
49
|
const DEFAULT_RULES = createDefaultRules();
|
|
13
50
|
function createDefaultRules() {
|
|
14
51
|
return [
|
|
@@ -49,16 +86,39 @@ function createDefaultRules() {
|
|
|
49
86
|
(input) => input.replace(/\bsk-(?:[a-zA-Z0-9]{1,32}-){0,4}[a-zA-Z0-9]{12,200}\b/g, "[REDACTED_API_KEY]"),
|
|
50
87
|
// Bearer token header value: "Bearer <token>" (case-insensitive), token redacted.
|
|
51
88
|
(input) => input.replace(/\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, "Bearer [REDACTED_BEARER_TOKEN]"),
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
(input) => input.replace(
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
89
|
+
// HTTP Basic auth header: the base64 blob encodes user:password verbatim.
|
|
90
|
+
(input) => input.replace(/\bBasic\s+[A-Za-z0-9+/]{12,}={0,2}/g, "Basic [REDACTED_BASIC_AUTH]"),
|
|
91
|
+
// Slack incoming-webhook URLs — the path IS the credential, so the whole
|
|
92
|
+
// path is collapsed rather than a token-shaped substring of it.
|
|
93
|
+
(input) => input.replace(/https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]{10,200}/g, "https://hooks.slack.com/services/[REDACTED_SLACK_WEBHOOK]"),
|
|
94
|
+
// Secret-bearing assignments: `<key><sep><value>` for a known secret-ish key
|
|
95
|
+
// name, covering config files, .env exports, YAML, JSON bodies, log lines
|
|
96
|
+
// and URL query strings in one rule.
|
|
97
|
+
//
|
|
98
|
+
// Three things the narrower predecessors missed, each a real leak:
|
|
99
|
+
// - `:` as a separator (YAML, JSON, `Header: value` log lines). Only `=`
|
|
100
|
+
// was handled, so `aws_secret_access_key: wJalr…` passed through.
|
|
101
|
+
// - Affixed key names. The keyword had to sit on a \b boundary, and `_` is
|
|
102
|
+
// a word character, so `AWS_SECRET_ACCESS_KEY` and `SECRET_TOKEN_VALUE`
|
|
103
|
+
// never matched. Allow bounded affixes on both sides.
|
|
104
|
+
// - Quoted values. The value class excluded quotes, so `password="a b c"`
|
|
105
|
+
// matched nothing at all.
|
|
106
|
+
//
|
|
107
|
+
// Quoted values keep their delimiters so a redacted JSON payload stays
|
|
108
|
+
// parseable. All quantifiers are bounded and the alternation is
|
|
109
|
+
// non-backtracking on failure, so the rule stays ReDoS-safe.
|
|
110
|
+
(input) => input.replace(SECRET_ASSIGNMENT_PATTERN, (_match, key, separator, value) => {
|
|
111
|
+
// Preserve the value's quote style so a redacted JSON payload stays
|
|
112
|
+
// parseable and a redacted YAML line stays well-formed.
|
|
113
|
+
const quote = value.startsWith('\\"')
|
|
114
|
+
? '\\"'
|
|
115
|
+
: value.startsWith('"')
|
|
116
|
+
? '"'
|
|
117
|
+
: value.startsWith("'")
|
|
118
|
+
? "'"
|
|
119
|
+
: "";
|
|
120
|
+
return `${key}${separator}${quote}[REDACTED]${quote}`;
|
|
121
|
+
}),
|
|
62
122
|
];
|
|
63
123
|
}
|
|
64
124
|
export function applyRedaction(input, options) {
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/** Max distinct lines rendered per section. */
|
|
2
|
+
const MAX_SECTION_LINES = 5;
|
|
3
|
+
/** Max `- [type] text` fact lines in the facts section. */
|
|
4
|
+
const MAX_FACT_LINES = 8;
|
|
5
|
+
/**
|
|
6
|
+
* Human-readable text for an event. The auto-ingest hook writes a `text` field
|
|
7
|
+
* carrying a one-line description ("Ran: npm test", "Edited src/foo.ts"); fall
|
|
8
|
+
* back to the raw payload for events written by other producers.
|
|
9
|
+
*/
|
|
1
10
|
function extractEventText(event) {
|
|
2
11
|
try {
|
|
3
12
|
const parsed = JSON.parse(event.payload_json);
|
|
@@ -10,34 +19,55 @@ function extractEventText(event) {
|
|
|
10
19
|
}
|
|
11
20
|
return event.payload_json.trim();
|
|
12
21
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
/** Distinct, non-empty lines, preserving first-seen order. */
|
|
23
|
+
function distinctLines(events) {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const lines = [];
|
|
26
|
+
for (const event of events) {
|
|
27
|
+
const line = extractEventText(event);
|
|
28
|
+
if (line.length === 0 || seen.has(line))
|
|
29
|
+
continue;
|
|
30
|
+
seen.add(line);
|
|
31
|
+
lines.push(line);
|
|
17
32
|
}
|
|
18
|
-
return lines
|
|
33
|
+
return lines;
|
|
19
34
|
}
|
|
35
|
+
function section(title, lines, fallback) {
|
|
36
|
+
const body = lines.length > 0 ? lines : [fallback];
|
|
37
|
+
return [title, ...body.map((line) => `- ${line}`)];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Render the session's events as a structured summary.
|
|
41
|
+
*
|
|
42
|
+
* Sections carry DIFFERENT slices of the session rather than the same lines
|
|
43
|
+
* repeated under every heading: `actions` is what happened in order, `outcomes`
|
|
44
|
+
* is where the session ended up, and `decisions` surfaces the events that
|
|
45
|
+
* recorded a choice (git commits). Repeating one slice under all five headings
|
|
46
|
+
* produced a summary that cost five times the tokens and carried no extra
|
|
47
|
+
* signal — and, once stored, was what every later session got injected with.
|
|
48
|
+
*/
|
|
20
49
|
export function buildStructuredSummary(events, options) {
|
|
21
|
-
const
|
|
50
|
+
const lines = distinctLines(events);
|
|
51
|
+
// Ordered narrative of the session, capped from the front.
|
|
52
|
+
const actions = lines.slice(0, MAX_SECTION_LINES);
|
|
53
|
+
// Where the session ended up: the tail, which is the most recent state.
|
|
54
|
+
const outcomes = lines.length > MAX_SECTION_LINES
|
|
55
|
+
? lines.slice(-Math.min(MAX_SECTION_LINES, lines.length - MAX_SECTION_LINES))
|
|
56
|
+
: lines.slice(-1);
|
|
57
|
+
// Commits are the durable decisions a session leaves behind.
|
|
58
|
+
const decisions = lines
|
|
59
|
+
.filter((line) => /\bgit\s+commit\b/i.test(line))
|
|
60
|
+
.slice(0, MAX_SECTION_LINES);
|
|
22
61
|
const summarySection = [
|
|
23
|
-
"
|
|
24
|
-
...sharedLines.map((line) => `- ${line}`),
|
|
25
|
-
"",
|
|
26
|
-
"actions",
|
|
27
|
-
...sharedLines.map((line) => `- ${line}`),
|
|
28
|
-
"",
|
|
29
|
-
"decisions",
|
|
30
|
-
...sharedLines.map((line) => `- ${line}`),
|
|
62
|
+
...section("actions", actions, "No relevant events captured."),
|
|
31
63
|
"",
|
|
32
|
-
"
|
|
33
|
-
...sharedLines.map((line) => `- ${line}`),
|
|
64
|
+
...section("decisions", decisions, "No explicit decisions recorded."),
|
|
34
65
|
"",
|
|
35
|
-
"outcomes",
|
|
36
|
-
...sharedLines.map((line) => `- ${line}`),
|
|
66
|
+
...section("outcomes", outcomes, "No outcomes captured."),
|
|
37
67
|
].join("\n");
|
|
38
68
|
const facts = events
|
|
39
|
-
.
|
|
40
|
-
.
|
|
69
|
+
.slice(0, MAX_FACT_LINES)
|
|
70
|
+
.map((event) => `- [${event.event_type}] ${extractEventText(event)}`);
|
|
41
71
|
const factSection = ["facts", ...facts].join("\n");
|
|
42
72
|
if (options.factMode === "summary-only") {
|
|
43
73
|
return summarySection;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sessionmem",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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,10 +28,11 @@
|
|
|
28
28
|
"lint": "eslint .",
|
|
29
29
|
"typecheck": "tsc --noEmit",
|
|
30
30
|
"benchmark": "node scripts/benchmark.mjs",
|
|
31
|
-
"
|
|
31
|
+
"version": "node scripts/sync-version.mjs && git add package/package.json .claude-plugin/plugin.json server.json",
|
|
32
|
+
"postversion": "npm run build"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@anthropic-ai/sdk": "^0.
|
|
35
|
+
"@anthropic-ai/sdk": "^0.107.0",
|
|
35
36
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
36
37
|
"better-sqlite3": "^12.4.1",
|
|
37
38
|
"commander": "^15.0.0",
|
package/dist/core/session.js
DELETED