sessionmem 1.0.6 → 1.1.1
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/dist/adapters/capabilities/fallbackTools.js +2 -2
- package/dist/adapters/claudeMdInjector.js +49 -5
- package/dist/adapters/factory.js +68 -9
- package/dist/adapters/generic.js +147 -12
- package/dist/adapters/global/antigravity.js +14 -7
- package/dist/adapters/global/claudeCode.js +46 -10
- package/dist/adapters/global/codex.js +73 -13
- package/dist/adapters/global/qcoder.js +18 -5
- package/dist/adapters/ide/cline.js +56 -9
- package/dist/adapters/ide/cursor.js +15 -13
- package/dist/adapters/ide/installer.js +201 -8
- package/dist/adapters/ide/windsurf.js +14 -13
- package/dist/cli/commands/config.js +10 -1
- package/dist/cli/commands/import.js +6 -1
- package/dist/cli/commands/install.js +57 -16
- package/dist/cli/commands/ping.js +42 -8
- package/dist/cli/commands/reEmbed.js +4 -3
- package/dist/cli/commands/run.js +7 -17
- package/dist/cli/commands/savings.js +33 -17
- package/dist/cli/commands/sessionEnd.js +124 -0
- package/dist/cli/commands/sessionStart.js +52 -0
- package/dist/cli/commands/sync.js +39 -9
- package/dist/cli/commands/uninstall.js +35 -9
- package/dist/cli/context.js +17 -18
- package/dist/cli/index.js +16 -4
- package/dist/cli/projectId.js +69 -0
- package/dist/core/api/contracts.js +155 -42
- package/dist/core/api/errors.js +4 -7
- package/dist/core/api/memoryCoreService.js +319 -252
- package/dist/core/api/sessionLifecycleService.js +8 -0
- package/dist/core/config/policyConfig.js +33 -6
- package/dist/core/injection/formatStartupInjection.js +53 -9
- package/dist/core/retrieve/recencyBands.js +4 -1
- package/dist/core/retrieve/retrieveMemories.js +10 -8
- package/dist/core/schema/migrations/005_team_provenance.sql +5 -0
- package/dist/core/schema/migrations/006_access_pattern_boosting.sql +5 -0
- package/dist/core/schema/migrations/008_fts5_search.sql +6 -2
- package/dist/core/schema/migrations/009_session_events_unique.sql +24 -0
- package/dist/core/schema/runMigrations.js +64 -2
- package/dist/core/storage/memoryRepo.js +164 -7
- package/dist/core/storage/memorySearchRepo.js +45 -7
- package/dist/core/storage/sessionEventsRepo.js +15 -2
- package/dist/core/summarize/cloudSummarizer.js +15 -2
- package/dist/core/summarize/redaction.js +45 -8
- package/package.json +2 -2
|
@@ -5,11 +5,11 @@ import { retrieveMemories } from "../retrieve/retrieveMemories.js";
|
|
|
5
5
|
import { computeEffectiveImportance } from "../retrieve/score.js";
|
|
6
6
|
import { formatStartupInjection } from "../injection/formatStartupInjection.js";
|
|
7
7
|
import { applyRedaction } from "../summarize/redaction.js";
|
|
8
|
-
import { countMemoriesBySession, countMemoriesOlderThan, deleteMemoriesOlderThan, incrementAccessCounts, insertMemory, listMemoriesByProject, resetAccessCounts, updateMemoryContent, upsertSessionSummaryMemory, } from "../storage/memoryRepo.js";
|
|
8
|
+
import { countAllMemoriesByProject, countMemoriesBySession, countMemoriesOlderThan, deleteMemoriesOlderThan, deleteMemoryById, getMemoryOwnerProjectId, getMemoryRecordById, incrementAccessCounts, insertMemory, listMemoriesByProject, resetAccessCounts, updateMemoryContent, upsertImportedMemory, upsertPulledMemory, upsertSessionSummaryMemory, } from "../storage/memoryRepo.js";
|
|
9
9
|
import { SESSION_WRITE_SOFT_LIMIT, configFilePath, DEEP_MODE_RETRIEVAL_CAP, readPolicyConfig, resolvePolicySettings, } from "../config/policyConfig.js";
|
|
10
10
|
import { insertMemoryFeedbackEvent } from "../storage/memoryFeedbackRepo.js";
|
|
11
|
-
import { insertSessionEvent } from "../storage/sessionEventsRepo.js";
|
|
12
|
-
import { batchStoreMemoryItemSchema, batchStoreMemoryRequestSchema, exportMemoriesRequestSchema, forgetMemoryRequestSchema, getMemoryRequestSchema, handleSessionEndRequestSchema, importMemoriesRequestSchema, pullMemoriesRequestSchema, ingestSessionEventsRequestSchema, listMemoriesRequestSchema, pruneMemoriesRequestSchema, redactExistingRequestSchema, resetAccessCountsRequestSchema, retrieveMemoriesRequestSchema, statsRequestSchema, storeMemoryRequestSchema, summarizeSessionToMemoryRequestSchema, } from "./contracts.js";
|
|
11
|
+
import { countAllSessionEvents, insertSessionEvent } from "../storage/sessionEventsRepo.js";
|
|
12
|
+
import { batchStoreMemoryItemSchema, batchStoreMemoryRequestSchema, exportMemoriesRequestSchema, forgetMemoryRequestSchema, getMemoryRequestSchema, handleSessionEndRequestSchema, importMemoriesRequestSchema, pullMemoriesRequestSchema, ingestSessionEventsRequestSchema, LIST_MEMORIES_DEFAULT_LIMIT, listMemoriesRequestSchema, pruneMemoriesRequestSchema, redactExistingRequestSchema, resetAccessCountsRequestSchema, retrieveMemoriesRequestSchema, statsRequestSchema, storeMemoryRequestSchema, summarizeSessionToMemoryRequestSchema, } from "./contracts.js";
|
|
13
13
|
import { DomainError, toErrorEnvelope } from "./errors.js";
|
|
14
14
|
import { assertLocalOnlyPolicy, } from "./localOnlyPolicy.js";
|
|
15
15
|
import { createSessionLifecycleService } from "./sessionLifecycleService.js";
|
|
@@ -35,6 +35,28 @@ function resolveServiceUsername(explicit) {
|
|
|
35
35
|
return "";
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
+
// Maximum content length serialized into an MCP retrieve response. The full
|
|
39
|
+
// content remains in the DB; this only caps what is returned to the tool caller
|
|
40
|
+
// so a large result set cannot overflow the agent context (100 rows × 10k chars
|
|
41
|
+
// ≈ 1MB JSON).
|
|
42
|
+
const RETRIEVE_CONTENT_MAX_LENGTH = 2000;
|
|
43
|
+
/**
|
|
44
|
+
* Clamp an imported/pulled timestamp to server time. A record carrying a future
|
|
45
|
+
* createdAt/updatedAt would otherwise be immune to retention pruning (its age
|
|
46
|
+
* never crosses the cutoff), so any value past `serverNow` is pulled back to it.
|
|
47
|
+
*/
|
|
48
|
+
function clampDateToNow(date) {
|
|
49
|
+
if (!date)
|
|
50
|
+
return null;
|
|
51
|
+
const epochMs = Date.parse(date);
|
|
52
|
+
if (isNaN(epochMs))
|
|
53
|
+
return null; // invalid date → discard
|
|
54
|
+
const nowMs = Date.now();
|
|
55
|
+
// Parse to epoch (handles timezone offsets correctly), clamp future dates to
|
|
56
|
+
// now, and normalize to a canonical UTC ISO string (no timezone offset) so
|
|
57
|
+
// lexicographic comparison in the retention prune stays consistent.
|
|
58
|
+
return new Date(Math.min(epochMs, nowMs)).toISOString();
|
|
59
|
+
}
|
|
38
60
|
function toMemoryDto(record) {
|
|
39
61
|
return {
|
|
40
62
|
id: record.id,
|
|
@@ -42,10 +64,10 @@ function toMemoryDto(record) {
|
|
|
42
64
|
sessionId: record.session_id,
|
|
43
65
|
sourceAdapter: record.source_adapter,
|
|
44
66
|
kind: record.kind,
|
|
45
|
-
content: record.content,
|
|
46
|
-
normalizedContent: record.normalized_content,
|
|
67
|
+
content: record.content.slice(0, RETRIEVE_CONTENT_MAX_LENGTH),
|
|
68
|
+
normalizedContent: record.normalized_content?.slice(0, RETRIEVE_CONTENT_MAX_LENGTH) ?? null,
|
|
47
69
|
importance: record.importance,
|
|
48
|
-
embedding:
|
|
70
|
+
embedding: null,
|
|
49
71
|
embeddingDim: record.embedding_dim,
|
|
50
72
|
embeddingVersion: record.embedding_version,
|
|
51
73
|
author: record.author,
|
|
@@ -57,6 +79,21 @@ function toMemoryDto(record) {
|
|
|
57
79
|
updatedAt: record.updated_at,
|
|
58
80
|
};
|
|
59
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Export/sync DTO: preserves FULL content and normalized_content, unlike
|
|
84
|
+
* toMemoryDto which caps both at RETRIEVE_CONTENT_MAX_LENGTH (2000) to bound MCP
|
|
85
|
+
* tool responses against context overflow. Export and team-push must round-trip
|
|
86
|
+
* losslessly — importMemories/pullMemories re-embed from the exported `content`,
|
|
87
|
+
* so truncating here would permanently lose any memory body over 2000 chars
|
|
88
|
+
* (stored content can be up to MAX_CONTENT_LENGTH = 10000) on re-import.
|
|
89
|
+
*/
|
|
90
|
+
function toExportMemoryDto(record) {
|
|
91
|
+
return {
|
|
92
|
+
...toMemoryDto(record),
|
|
93
|
+
content: record.content,
|
|
94
|
+
normalizedContent: record.normalized_content,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
60
97
|
function toRetrievedMemoryDto(record) {
|
|
61
98
|
return {
|
|
62
99
|
id: record.id,
|
|
@@ -64,8 +101,10 @@ function toRetrievedMemoryDto(record) {
|
|
|
64
101
|
sessionId: record.session_id,
|
|
65
102
|
sourceAdapter: record.source_adapter,
|
|
66
103
|
kind: record.kind,
|
|
67
|
-
content
|
|
68
|
-
|
|
104
|
+
// Cap content for the MCP tool response to prevent context overflow; the
|
|
105
|
+
// full content stays in the DB and is reachable via getMemory.
|
|
106
|
+
content: record.content.slice(0, RETRIEVE_CONTENT_MAX_LENGTH),
|
|
107
|
+
normalizedContent: record.normalized_content?.slice(0, RETRIEVE_CONTENT_MAX_LENGTH) ?? null,
|
|
69
108
|
importance: record.importance,
|
|
70
109
|
embedding: null,
|
|
71
110
|
embeddingDim: record.embedding_dim,
|
|
@@ -81,20 +120,6 @@ function toRetrievedMemoryDto(record) {
|
|
|
81
120
|
score: record.score,
|
|
82
121
|
};
|
|
83
122
|
}
|
|
84
|
-
function getMemoryById(db, projectId, memoryId) {
|
|
85
|
-
const row = db
|
|
86
|
-
.prepare(`
|
|
87
|
-
SELECT
|
|
88
|
-
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
89
|
-
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
90
|
-
access_count, last_accessed, created_at, updated_at
|
|
91
|
-
FROM memories
|
|
92
|
-
WHERE project_id = ? AND id = ?
|
|
93
|
-
LIMIT 1
|
|
94
|
-
`)
|
|
95
|
-
.get(projectId, memoryId);
|
|
96
|
-
return row;
|
|
97
|
-
}
|
|
98
123
|
function parseRequest(schema, request) {
|
|
99
124
|
try {
|
|
100
125
|
return schema.parse(request);
|
|
@@ -144,32 +169,56 @@ export function createMemoryCoreService(deps) {
|
|
|
144
169
|
const methods = {
|
|
145
170
|
async ingestSessionEvents(request) {
|
|
146
171
|
const parsed = parseRequest(ingestSessionEventsRequestSchema, request);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
172
|
+
// Wrap the whole batch in a single transaction so a mid-loop failure rolls
|
|
173
|
+
// back every insert (no partial ingestion). Inserts use INSERT OR IGNORE
|
|
174
|
+
// on the (project_id, session_id, event_index) UNIQUE index, so the count
|
|
175
|
+
// reflects rows actually written and re-ingestion is a no-op.
|
|
176
|
+
// Redact each event's payload_json before persisting so secrets in tool
|
|
177
|
+
// inputs/outputs never reach storage — same write-path guarantee as
|
|
178
|
+
// storeMemory. Events carry no explicit redactionEnabled flag, so resolve
|
|
179
|
+
// it from the policy config.
|
|
180
|
+
const redactionEnabled = resolveRedactionEnabled(undefined);
|
|
181
|
+
const ingest = db.transaction(() => {
|
|
182
|
+
let written = 0;
|
|
183
|
+
for (const event of parsed.events) {
|
|
184
|
+
const redactedPayload = applyRedaction(event.payloadJson, {
|
|
185
|
+
redactionEnabled,
|
|
186
|
+
}).text;
|
|
187
|
+
written += insertSessionEvent(db, {
|
|
188
|
+
id: event.id,
|
|
189
|
+
project_id: parsed.projectId,
|
|
190
|
+
session_id: parsed.sessionId,
|
|
191
|
+
event_index: event.eventIndex,
|
|
192
|
+
event_type: event.eventType,
|
|
193
|
+
payload_json: redactedPayload,
|
|
194
|
+
created_at: event.createdAt,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return written;
|
|
198
|
+
});
|
|
199
|
+
const ingested = ingest();
|
|
158
200
|
return {
|
|
159
201
|
ok: true,
|
|
160
|
-
ingested
|
|
202
|
+
ingested,
|
|
161
203
|
};
|
|
162
204
|
},
|
|
163
205
|
async summarizeSessionToMemory(request) {
|
|
164
206
|
const parsed = parseRequest(summarizeSessionToMemoryRequestSchema, request);
|
|
165
|
-
|
|
207
|
+
// Redact before embedding/persisting so secrets in the summary text never
|
|
208
|
+
// reach storage and the embedding is computed on the redacted text — same
|
|
209
|
+
// write-path guarantee as storeMemory. The request carries no explicit
|
|
210
|
+
// redactionEnabled flag, so resolve it from the policy config.
|
|
211
|
+
const redaction = applyRedaction(parsed.summary, {
|
|
212
|
+
redactionEnabled: resolveRedactionEnabled(undefined),
|
|
213
|
+
});
|
|
214
|
+
const embedding = deterministicEmbed(redaction.text, dimension);
|
|
166
215
|
upsertSessionSummaryMemory(db, {
|
|
167
216
|
id: parsed.memoryId,
|
|
168
217
|
project_id: parsed.projectId,
|
|
169
218
|
session_id: parsed.sessionId,
|
|
170
219
|
source_adapter: parsed.sourceAdapter,
|
|
171
220
|
kind: "summary",
|
|
172
|
-
content:
|
|
221
|
+
content: redaction.text,
|
|
173
222
|
normalized_content: embedding.normalizedText,
|
|
174
223
|
importance: parsed.importance,
|
|
175
224
|
embedding: JSON.stringify(embedding.vector),
|
|
@@ -200,7 +249,7 @@ export function createMemoryCoreService(deps) {
|
|
|
200
249
|
// already accumulated SESSION_WRITE_SOFT_LIMIT memories so the agent
|
|
201
250
|
// gets feedback to stop storing excessively. The write still proceeds.
|
|
202
251
|
const warningCodes = [...redaction.warningCodes];
|
|
203
|
-
const sessionCount = countMemoriesBySession(db, parsed.sessionId);
|
|
252
|
+
const sessionCount = countMemoriesBySession(db, parsed.sessionId, parsed.projectId);
|
|
204
253
|
if (sessionCount >= SESSION_WRITE_SOFT_LIMIT) {
|
|
205
254
|
warningCodes.push("session_write_limit_warning");
|
|
206
255
|
}
|
|
@@ -221,13 +270,19 @@ export function createMemoryCoreService(deps) {
|
|
|
221
270
|
author: localAuthor,
|
|
222
271
|
origin_project_id: null,
|
|
223
272
|
});
|
|
224
|
-
const inserted =
|
|
273
|
+
const inserted = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
|
|
225
274
|
if (!inserted) {
|
|
226
275
|
throw new DomainError("INTERNAL", "Memory insert did not persist");
|
|
227
276
|
}
|
|
228
277
|
return {
|
|
229
278
|
ok: true,
|
|
230
|
-
|
|
279
|
+
// Single-record write echo-back: return the FULL stored body (uncapped),
|
|
280
|
+
// mirroring getMemory's single-record read. A store response carries one
|
|
281
|
+
// row bounded by MAX_CONTENT_LENGTH (10000), so it cannot overflow the
|
|
282
|
+
// agent context the way a multi-row list can, and the caller may want to
|
|
283
|
+
// verify the actual persisted (post-redaction) content. Contrast with
|
|
284
|
+
// batchStoreMemory below, which keeps the cap because it returns many rows.
|
|
285
|
+
memory: toExportMemoryDto(inserted),
|
|
231
286
|
warningCodes,
|
|
232
287
|
};
|
|
233
288
|
},
|
|
@@ -240,51 +295,61 @@ export function createMemoryCoreService(deps) {
|
|
|
240
295
|
queryText: parsed.query,
|
|
241
296
|
limit,
|
|
242
297
|
});
|
|
243
|
-
if (ranked.length > 0) {
|
|
298
|
+
if (ranked.length > 0 && parsed.mode !== "on-demand") {
|
|
299
|
+
// Only boost access counts for startup injection (mode='auto'), not for
|
|
300
|
+
// explicit on-demand retrieval, so a mid-session lookup does not inflate
|
|
301
|
+
// recall-frequency ranking.
|
|
244
302
|
incrementAccessCounts(db, parsed.projectId, ranked.map((m) => m.id));
|
|
245
303
|
}
|
|
304
|
+
// Honor a user-configured injectionCap when present; otherwise
|
|
305
|
+
// formatStartupInjection falls back to its built-in default cap.
|
|
306
|
+
const injectionCap = readPolicyConfig(policyConfigPath).injectionCap;
|
|
246
307
|
return {
|
|
247
308
|
ok: true,
|
|
248
309
|
memories: ranked.map(toRetrievedMemoryDto),
|
|
249
310
|
total: ranked.length,
|
|
250
311
|
startupInjection: formatStartupInjection(ranked, {
|
|
251
312
|
localUsername: localAuthor,
|
|
313
|
+
tokenCap: injectionCap,
|
|
252
314
|
}),
|
|
253
315
|
};
|
|
254
316
|
},
|
|
255
317
|
async listMemories(request) {
|
|
256
318
|
const parsed = parseRequest(listMemoriesRequestSchema, request);
|
|
257
|
-
const
|
|
319
|
+
const all = listMemoriesByProject(db, parsed.projectId);
|
|
320
|
+
// Rows arrive ordered by updated_at DESC, so slicing keeps the most
|
|
321
|
+
// recently touched memories. `total` reports the full count; a shorter
|
|
322
|
+
// `memories` array signals the caller that the list was truncated.
|
|
323
|
+
const limit = parsed.limit ?? LIST_MEMORIES_DEFAULT_LIMIT;
|
|
324
|
+
const memories = all.slice(0, limit);
|
|
258
325
|
return {
|
|
259
326
|
ok: true,
|
|
260
327
|
memories: memories.map(toMemoryDto),
|
|
261
|
-
total:
|
|
328
|
+
total: all.length,
|
|
262
329
|
};
|
|
263
330
|
},
|
|
264
331
|
async getMemory(request) {
|
|
265
332
|
const parsed = parseRequest(getMemoryRequestSchema, request);
|
|
266
|
-
const memory =
|
|
333
|
+
const memory = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
|
|
267
334
|
if (!memory) {
|
|
268
335
|
throw new DomainError("NOT_FOUND", `Memory not found: ${parsed.memoryId}`);
|
|
269
336
|
}
|
|
270
337
|
return {
|
|
271
338
|
ok: true,
|
|
272
|
-
memory:
|
|
339
|
+
memory: toExportMemoryDto(memory),
|
|
273
340
|
};
|
|
274
341
|
},
|
|
275
342
|
async forgetMemory(request) {
|
|
276
343
|
const parsed = parseRequest(forgetMemoryRequestSchema, request);
|
|
277
344
|
// Capture the memory's importance before deletion so we can record
|
|
278
345
|
// it in the feedback table as an analytics signal.
|
|
279
|
-
const existing =
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
.run(parsed.projectId, parsed.memoryId);
|
|
283
|
-
if (result.changes === 0) {
|
|
346
|
+
const existing = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
|
|
347
|
+
const deleted = deleteMemoryById(db, parsed.projectId, parsed.memoryId);
|
|
348
|
+
if (deleted === 0) {
|
|
284
349
|
throw new DomainError("NOT_FOUND", `Memory not found: ${parsed.memoryId}`);
|
|
285
350
|
}
|
|
286
351
|
// Record the explicit user deletion as feedback. The FK on
|
|
287
|
-
// memory_feedback no longer cascades (migration
|
|
352
|
+
// memory_feedback no longer cascades (migration 007), so this row
|
|
288
353
|
// survives the memory deletion and serves as an analytics signal.
|
|
289
354
|
insertMemoryFeedbackEvent(db, {
|
|
290
355
|
memory_id: parsed.memoryId,
|
|
@@ -301,45 +366,16 @@ export function createMemoryCoreService(deps) {
|
|
|
301
366
|
const memories = listMemoriesByProject(db, parsed.projectId);
|
|
302
367
|
return {
|
|
303
368
|
ok: true,
|
|
304
|
-
memories: memories.map(
|
|
369
|
+
memories: memories.map(toExportMemoryDto),
|
|
305
370
|
};
|
|
306
371
|
},
|
|
307
372
|
async importMemories(request) {
|
|
308
373
|
const parsed = parseRequest(importMemoriesRequestSchema, request);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
) VALUES (
|
|
315
|
-
@id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
|
|
316
|
-
@importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
|
|
317
|
-
COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
318
|
-
COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
319
|
-
)
|
|
320
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
321
|
-
project_id = excluded.project_id,
|
|
322
|
-
session_id = excluded.session_id,
|
|
323
|
-
source_adapter = excluded.source_adapter,
|
|
324
|
-
kind = excluded.kind,
|
|
325
|
-
content = excluded.content,
|
|
326
|
-
normalized_content = excluded.normalized_content,
|
|
327
|
-
importance = excluded.importance,
|
|
328
|
-
embedding = excluded.embedding,
|
|
329
|
-
embedding_dim = excluded.embedding_dim,
|
|
330
|
-
embedding_version = excluded.embedding_version,
|
|
331
|
-
author = excluded.author,
|
|
332
|
-
origin_project_id = excluded.origin_project_id,
|
|
333
|
-
created_at = excluded.created_at,
|
|
334
|
-
updated_at = excluded.updated_at
|
|
335
|
-
`);
|
|
336
|
-
// `id` is a globally-unique PRIMARY KEY (not scoped by
|
|
337
|
-
// project_id). The upsert above reassigns `project_id = excluded.project_id`
|
|
338
|
-
// on conflict, which would let an imported record silently overwrite and
|
|
339
|
-
// relocate another project's memory if its `id` happens to collide.
|
|
340
|
-
// Look up existing ownership per id and skip (rather than upsert) any
|
|
341
|
-
// record whose id already belongs to a *different* project.
|
|
342
|
-
const ownerStmt = db.prepare("SELECT project_id FROM memories WHERE id = ?");
|
|
374
|
+
// The upsert (upsertImportedMemory) reassigns project_id on ON CONFLICT(id).
|
|
375
|
+
// Because `id` is a globally-unique PRIMARY KEY (not scoped by project_id),
|
|
376
|
+
// a colliding id owned by a *different* project would otherwise be silently
|
|
377
|
+
// overwritten and relocated. getMemoryOwnerProjectId resolves ownership per
|
|
378
|
+
// id so we skip those rather than upsert them.
|
|
343
379
|
// Aggregate redaction warnings across all imported records. A
|
|
344
380
|
// Set de-duplicates the redaction_partial_failure code so the envelope
|
|
345
381
|
// stays compact regardless of how many records tripped the same rule.
|
|
@@ -347,52 +383,66 @@ export function createMemoryCoreService(deps) {
|
|
|
347
383
|
const effectiveRedactionEnabled = resolveRedactionEnabled(parsed.redactionEnabled);
|
|
348
384
|
let imported = 0;
|
|
349
385
|
let skippedCrossProject = 0;
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
386
|
+
let skippedExisting = 0;
|
|
387
|
+
// Wrap the whole batch in a single transaction so a mid-loop failure rolls
|
|
388
|
+
// back every upsert (no partial import).
|
|
389
|
+
const runImport = db.transaction(() => {
|
|
390
|
+
for (const memory of parsed.memories) {
|
|
391
|
+
const ownerProjectId = getMemoryOwnerProjectId(db, memory.id);
|
|
392
|
+
if (ownerProjectId !== undefined) {
|
|
393
|
+
if (ownerProjectId !== parsed.projectId) {
|
|
394
|
+
// Another project already owns this id: skip rather than overwrite
|
|
395
|
+
// and reassign ownership via ON CONFLICT(id).
|
|
396
|
+
skippedCrossProject += 1;
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
// This project already owns this id: skip rather than overwrite the
|
|
400
|
+
// existing memory's content/timestamps. Only brand-new ids import.
|
|
401
|
+
skippedExisting += 1;
|
|
402
|
+
}
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
// Redact each record before embedding/upsert so secrets never persist
|
|
406
|
+
// and the embedding reflects the redacted text.
|
|
407
|
+
const redaction = applyRedaction(memory.content, {
|
|
408
|
+
redactionEnabled: effectiveRedactionEnabled,
|
|
409
|
+
});
|
|
410
|
+
for (const code of redaction.warningCodes) {
|
|
411
|
+
warningCodeSet.add(code);
|
|
412
|
+
}
|
|
413
|
+
const embedding = deterministicEmbed(redaction.text, dimension);
|
|
414
|
+
upsertImportedMemory(db, {
|
|
415
|
+
id: memory.id,
|
|
416
|
+
project_id: parsed.projectId,
|
|
417
|
+
session_id: memory.sessionId,
|
|
418
|
+
source_adapter: memory.sourceAdapter,
|
|
419
|
+
kind: memory.kind,
|
|
420
|
+
content: redaction.text,
|
|
421
|
+
normalized_content: embedding.normalizedText,
|
|
422
|
+
importance: memory.importance,
|
|
423
|
+
embedding: JSON.stringify(embedding.vector),
|
|
424
|
+
embedding_dim: embedding.dimension,
|
|
425
|
+
embedding_version: embedding.embeddingVersion,
|
|
426
|
+
// Plain import (not a team pull): preserve an incoming author when
|
|
427
|
+
// the export carried one, else stamp the local username so the row
|
|
428
|
+
// is never left with an empty author. origin_project_id is carried
|
|
429
|
+
// through when present, else null for locally-originating rows.
|
|
430
|
+
author: memory.author && memory.author.trim() !== ""
|
|
431
|
+
? memory.author
|
|
432
|
+
: localAuthor,
|
|
433
|
+
origin_project_id: memory.originProjectId ?? null,
|
|
434
|
+
created_at: clampDateToNow(memory.createdAt) ?? undefined,
|
|
435
|
+
updated_at: clampDateToNow(memory.updatedAt) ?? undefined,
|
|
436
|
+
});
|
|
437
|
+
imported += 1;
|
|
365
438
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
id: memory.id,
|
|
369
|
-
project_id: parsed.projectId,
|
|
370
|
-
session_id: memory.sessionId,
|
|
371
|
-
source_adapter: memory.sourceAdapter,
|
|
372
|
-
kind: memory.kind,
|
|
373
|
-
content: redaction.text,
|
|
374
|
-
normalized_content: embedding.normalizedText,
|
|
375
|
-
importance: memory.importance,
|
|
376
|
-
embedding: JSON.stringify(embedding.vector),
|
|
377
|
-
embedding_dim: embedding.dimension,
|
|
378
|
-
embedding_version: embedding.embeddingVersion,
|
|
379
|
-
// Plain import (not a team pull): preserve an incoming author when the
|
|
380
|
-
// export carried one, else stamp the local username so the row is
|
|
381
|
-
// never left with an empty author. origin_project_id is carried
|
|
382
|
-
// through when present, else null for locally-originating rows.
|
|
383
|
-
author: memory.author && memory.author.trim() !== ""
|
|
384
|
-
? memory.author
|
|
385
|
-
: localAuthor,
|
|
386
|
-
origin_project_id: memory.originProjectId ?? null,
|
|
387
|
-
created_at: memory.createdAt,
|
|
388
|
-
updated_at: memory.updatedAt,
|
|
389
|
-
});
|
|
390
|
-
imported += 1;
|
|
391
|
-
}
|
|
439
|
+
});
|
|
440
|
+
runImport();
|
|
392
441
|
return {
|
|
393
442
|
ok: true,
|
|
394
443
|
imported,
|
|
395
444
|
skippedCrossProject,
|
|
445
|
+
skippedExisting,
|
|
396
446
|
warningCodes: [...warningCodeSet],
|
|
397
447
|
};
|
|
398
448
|
},
|
|
@@ -401,102 +451,74 @@ export function createMemoryCoreService(deps) {
|
|
|
401
451
|
// Structural twin of importMemories with three team-pull changes:
|
|
402
452
|
// - importance uses MAX(local, incoming) so a teammate can never lower a
|
|
403
453
|
// locally-boosted importance (last-write-wins on content but
|
|
404
|
-
// importance-preserving).
|
|
454
|
+
// importance-preserving). upsertPulledMemory carries that merge.
|
|
405
455
|
// - author/origin_project_id are stamped from the incoming record's
|
|
406
456
|
// provenance so pulled rows carry the teammate's identity and
|
|
407
457
|
// their source project_id.
|
|
408
458
|
// - cross-project id collisions are skipped, exactly as import.
|
|
409
|
-
const stmt = db.prepare(`
|
|
410
|
-
INSERT INTO memories (
|
|
411
|
-
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
412
|
-
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
413
|
-
created_at, updated_at
|
|
414
|
-
) VALUES (
|
|
415
|
-
@id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
|
|
416
|
-
@importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
|
|
417
|
-
COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
418
|
-
COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
419
|
-
)
|
|
420
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
421
|
-
project_id = excluded.project_id,
|
|
422
|
-
session_id = excluded.session_id,
|
|
423
|
-
source_adapter = excluded.source_adapter,
|
|
424
|
-
kind = excluded.kind,
|
|
425
|
-
content = excluded.content,
|
|
426
|
-
normalized_content = excluded.normalized_content,
|
|
427
|
-
-- Importance-preserving merge. better-sqlite3@12 bundles a
|
|
428
|
-
-- SQLite that accepts the two-arg scalar MAX() inside DO UPDATE; the
|
|
429
|
-
-- pull-merge importance-preserve test verifies both directions.
|
|
430
|
-
importance = MAX(memories.importance, excluded.importance),
|
|
431
|
-
embedding = excluded.embedding,
|
|
432
|
-
embedding_dim = excluded.embedding_dim,
|
|
433
|
-
embedding_version = excluded.embedding_version,
|
|
434
|
-
author = excluded.author,
|
|
435
|
-
origin_project_id = excluded.origin_project_id,
|
|
436
|
-
created_at = excluded.created_at,
|
|
437
|
-
updated_at = excluded.updated_at
|
|
438
|
-
`);
|
|
439
|
-
// Same cross-project ownership skip as importMemories. A colliding
|
|
440
|
-
// id owned by a different project is skipped, never overwritten/relocated.
|
|
441
|
-
const ownerStmt = db.prepare("SELECT project_id FROM memories WHERE id = ?");
|
|
442
459
|
const warningCodeSet = new Set();
|
|
443
460
|
const effectiveRedactionEnabled = resolveRedactionEnabled(parsed.redactionEnabled);
|
|
444
461
|
let pulledNew = 0;
|
|
445
462
|
let pulledUpdated = 0;
|
|
446
463
|
let skippedCrossProject = 0;
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
464
|
+
// Wrap the whole batch in a single transaction so a mid-loop failure rolls
|
|
465
|
+
// back every upsert (no partial pull).
|
|
466
|
+
const runPull = db.transaction(() => {
|
|
467
|
+
for (const memory of parsed.memories) {
|
|
468
|
+
const ownerProjectId = getMemoryOwnerProjectId(db, memory.id);
|
|
469
|
+
if (ownerProjectId !== undefined && ownerProjectId !== parsed.projectId) {
|
|
470
|
+
skippedCrossProject += 1;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
// An id already owned by THIS project is an update; otherwise a
|
|
474
|
+
// brand-new insert. Snapshotting per-id keeps the count correct even
|
|
475
|
+
// when the same id appears across multiple teammate files.
|
|
476
|
+
const isUpdate = ownerProjectId !== undefined;
|
|
477
|
+
// Re-run redaction on every pulled record regardless of the
|
|
478
|
+
// teammate's redaction setting (4th write path), then re-embed the
|
|
479
|
+
// redacted text so secrets never persist and the embedding matches.
|
|
480
|
+
const redaction = applyRedaction(memory.content, {
|
|
481
|
+
redactionEnabled: effectiveRedactionEnabled,
|
|
482
|
+
});
|
|
483
|
+
for (const code of redaction.warningCodes) {
|
|
484
|
+
warningCodeSet.add(code);
|
|
485
|
+
}
|
|
486
|
+
const embedding = deterministicEmbed(redaction.text, dimension);
|
|
487
|
+
upsertPulledMemory(db, {
|
|
488
|
+
id: memory.id,
|
|
489
|
+
// LOCAL project_id so merged rows are retrievable in the pulling
|
|
490
|
+
// user's project (Open Q4).
|
|
491
|
+
project_id: parsed.projectId,
|
|
492
|
+
session_id: memory.sessionId,
|
|
493
|
+
source_adapter: memory.sourceAdapter,
|
|
494
|
+
kind: memory.kind,
|
|
495
|
+
content: redaction.text,
|
|
496
|
+
normalized_content: embedding.normalizedText,
|
|
497
|
+
importance: memory.importance,
|
|
498
|
+
embedding: JSON.stringify(embedding.vector),
|
|
499
|
+
embedding_dim: embedding.dimension,
|
|
500
|
+
embedding_version: embedding.embeddingVersion,
|
|
501
|
+
// Stamp the teammate's provenance. author falls back to the
|
|
502
|
+
// local username only when the incoming record carries none.
|
|
503
|
+
author: memory.author && memory.author.trim() !== ""
|
|
504
|
+
? memory.author
|
|
505
|
+
: localAuthor,
|
|
506
|
+
// origin_project_id records the record's source-machine project_id:
|
|
507
|
+
// its explicit originProjectId if present, else the record's own
|
|
508
|
+
// incoming projectId (Open Q4).
|
|
509
|
+
origin_project_id: memory.originProjectId ?? memory.projectId,
|
|
510
|
+
created_at: clampDateToNow(memory.createdAt) ?? undefined,
|
|
511
|
+
updated_at: clampDateToNow(memory.updatedAt) ?? undefined,
|
|
512
|
+
});
|
|
513
|
+
if (isUpdate) {
|
|
514
|
+
pulledUpdated += 1;
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
pulledNew += 1;
|
|
518
|
+
}
|
|
498
519
|
}
|
|
499
|
-
}
|
|
520
|
+
});
|
|
521
|
+
runPull();
|
|
500
522
|
return {
|
|
501
523
|
ok: true,
|
|
502
524
|
pulledNew,
|
|
@@ -550,8 +572,12 @@ export function createMemoryCoreService(deps) {
|
|
|
550
572
|
// the limit isn't split into an unpaired surrogate.
|
|
551
573
|
previews.push(Array.from(redaction.text).slice(0, REDACT_PREVIEW_MAX_LENGTH).join(""));
|
|
552
574
|
if (parsed.apply) {
|
|
553
|
-
// Recompute the embedding
|
|
554
|
-
//
|
|
575
|
+
// Recompute the embedding on the redacted content so BOTH the stored
|
|
576
|
+
// normalized_content AND the embedding vector track the scrub. Without
|
|
577
|
+
// re-embedding, the vector would remain a hash of the pre-redaction
|
|
578
|
+
// (secret-bearing) text — inconsistent with normalized_content and
|
|
579
|
+
// still ranking against the un-redacted body in semantic retrieval,
|
|
580
|
+
// defeating the purpose of the scrub.
|
|
555
581
|
const embedding = deterministicEmbed(redaction.text, dimension);
|
|
556
582
|
// A single row that was deleted concurrently between the
|
|
557
583
|
// initial listMemoriesByProject snapshot and this update would
|
|
@@ -561,7 +587,11 @@ export function createMemoryCoreService(deps) {
|
|
|
561
587
|
// wrapped in a transaction). Catch per-row and report it as
|
|
562
588
|
// skipped instead.
|
|
563
589
|
try {
|
|
564
|
-
updateMemoryContent(db, parsed.projectId, memory.id, redaction.text, embedding.normalizedText
|
|
590
|
+
updateMemoryContent(db, parsed.projectId, memory.id, redaction.text, embedding.normalizedText, {
|
|
591
|
+
vector: embedding.vector,
|
|
592
|
+
dimension: embedding.dimension,
|
|
593
|
+
embeddingVersion: embedding.embeddingVersion,
|
|
594
|
+
});
|
|
565
595
|
updated += 1;
|
|
566
596
|
}
|
|
567
597
|
catch {
|
|
@@ -580,6 +610,14 @@ export function createMemoryCoreService(deps) {
|
|
|
580
610
|
},
|
|
581
611
|
async batchStoreMemory(request) {
|
|
582
612
|
const parsed = parseRequest(batchStoreMemoryRequestSchema, request);
|
|
613
|
+
const uniqueSessions = new Set(parsed.memories.map((m) => m.sessionId));
|
|
614
|
+
const sessionOverLimit = new Set();
|
|
615
|
+
for (const sid of uniqueSessions) {
|
|
616
|
+
const count = countMemoriesBySession(db, sid, parsed.projectId);
|
|
617
|
+
if (count >= SESSION_WRITE_SOFT_LIMIT) {
|
|
618
|
+
sessionOverLimit.add(sid);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
583
621
|
const results = [];
|
|
584
622
|
let stored = 0;
|
|
585
623
|
let failed = 0;
|
|
@@ -612,36 +650,69 @@ export function createMemoryCoreService(deps) {
|
|
|
612
650
|
if (validatedItems.length > 0) {
|
|
613
651
|
const runTransaction = db.transaction(() => {
|
|
614
652
|
for (const { item } of validatedItems) {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
653
|
+
// Each insert is guarded individually so a duplicate-id (or other
|
|
654
|
+
// constraint) collision fails only that item instead of aborting the
|
|
655
|
+
// whole batch. A SQLite constraint violation rolls back only the
|
|
656
|
+
// current statement, not the surrounding transaction, so the loop can
|
|
657
|
+
// continue and the transaction still commits the successful inserts.
|
|
658
|
+
try {
|
|
659
|
+
const redaction = applyRedaction(item.content, {
|
|
660
|
+
redactionEnabled: resolveRedactionEnabled(item.redactionEnabled),
|
|
661
|
+
});
|
|
662
|
+
const embedding = deterministicEmbed(redaction.text, dimension);
|
|
663
|
+
insertMemory(db, {
|
|
664
|
+
id: item.memoryId,
|
|
665
|
+
project_id: parsed.projectId,
|
|
666
|
+
session_id: item.sessionId,
|
|
667
|
+
source_adapter: item.sourceAdapter,
|
|
668
|
+
kind: item.kind,
|
|
669
|
+
content: redaction.text,
|
|
670
|
+
normalized_content: embedding.normalizedText,
|
|
671
|
+
importance: item.importance,
|
|
672
|
+
embedding: JSON.stringify(embedding.vector),
|
|
673
|
+
embedding_dim: embedding.dimension,
|
|
674
|
+
embedding_version: embedding.embeddingVersion,
|
|
675
|
+
author: localAuthor,
|
|
676
|
+
origin_project_id: null,
|
|
677
|
+
});
|
|
678
|
+
const inserted = getMemoryRecordById(db, parsed.projectId, item.memoryId);
|
|
679
|
+
if (!inserted) {
|
|
680
|
+
throw new DomainError("INTERNAL", `Memory insert did not persist: ${item.memoryId}`);
|
|
681
|
+
}
|
|
682
|
+
const itemWarningCodes = [...redaction.warningCodes];
|
|
683
|
+
if (sessionOverLimit.has(item.sessionId)) {
|
|
684
|
+
itemWarningCodes.push("session_write_limit_warning");
|
|
685
|
+
}
|
|
686
|
+
results.push({
|
|
687
|
+
memoryId: item.memoryId,
|
|
688
|
+
ok: true,
|
|
689
|
+
// Capped echo-back (unlike single-record storeMemory): a batch
|
|
690
|
+
// returns up to MAX_BATCH_SIZE rows, so returning full content per
|
|
691
|
+
// row could overflow the agent context (parallel to listMemories).
|
|
692
|
+
// The caller already holds each original body; full content stays
|
|
693
|
+
// in the DB and is reachable via getMemory.
|
|
694
|
+
memory: toMemoryDto(inserted),
|
|
695
|
+
warningCodes: itemWarningCodes,
|
|
696
|
+
});
|
|
697
|
+
stored += 1;
|
|
698
|
+
}
|
|
699
|
+
catch (err) {
|
|
700
|
+
const code = err.code ?? "";
|
|
701
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
702
|
+
const isConstraint = code.startsWith("SQLITE_CONSTRAINT") ||
|
|
703
|
+
/constraint failed/i.test(message);
|
|
704
|
+
if (!isConstraint) {
|
|
705
|
+
// Unexpected (non-constraint) failure — abort the whole
|
|
706
|
+
// transaction so we don't silently commit a corrupt partial batch.
|
|
707
|
+
throw err;
|
|
708
|
+
}
|
|
709
|
+
results.push({
|
|
710
|
+
memoryId: item.memoryId,
|
|
711
|
+
ok: false,
|
|
712
|
+
error: "duplicate id",
|
|
713
|
+
});
|
|
714
|
+
failed += 1;
|
|
637
715
|
}
|
|
638
|
-
results.push({
|
|
639
|
-
memoryId: item.memoryId,
|
|
640
|
-
ok: true,
|
|
641
|
-
memory: toMemoryDto(inserted),
|
|
642
|
-
warningCodes: redaction.warningCodes,
|
|
643
|
-
});
|
|
644
|
-
stored += 1;
|
|
645
716
|
}
|
|
646
717
|
});
|
|
647
718
|
runTransaction();
|
|
@@ -661,16 +732,12 @@ export function createMemoryCoreService(deps) {
|
|
|
661
732
|
},
|
|
662
733
|
async stats(request) {
|
|
663
734
|
const parsed = parseRequest(statsRequestSchema, request);
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
.get(parsed.projectId);
|
|
667
|
-
const sessionEventCount = db
|
|
668
|
-
.prepare("SELECT COUNT(*) AS count FROM session_events WHERE project_id = ?")
|
|
669
|
-
.get(parsed.projectId);
|
|
735
|
+
const totalMemories = countAllMemoriesByProject(db, parsed.projectId);
|
|
736
|
+
const totalSessionEvents = countAllSessionEvents(db, parsed.projectId);
|
|
670
737
|
return {
|
|
671
738
|
ok: true,
|
|
672
|
-
totalMemories
|
|
673
|
-
totalSessionEvents
|
|
739
|
+
totalMemories,
|
|
740
|
+
totalSessionEvents,
|
|
674
741
|
};
|
|
675
742
|
},
|
|
676
743
|
async resetAccessCounts(request) {
|