sessionmem 1.0.5 → 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.
- package/LICENSE +21 -21
- package/README.md +372 -365
- package/dist/adapters/capabilities/fallbackTools.js +33 -18
- package/dist/adapters/claudeMdInjector.js +164 -0
- package/dist/adapters/factory.js +68 -9
- package/dist/adapters/generic.js +221 -15
- 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 +54 -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/adapters/tools/ping.js +4 -1
- package/dist/cli/commands/config.js +10 -1
- package/dist/cli/commands/import.js +6 -1
- package/dist/cli/commands/install.js +63 -5
- package/dist/cli/commands/ping.js +42 -8
- package/dist/cli/commands/reEmbed.js +48 -0
- package/dist/cli/commands/run.js +18 -2
- package/dist/cli/commands/savings.js +91 -0
- 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 +37 -1
- package/dist/cli/context.js +14 -18
- package/dist/cli/index.js +30 -4
- package/dist/cli/output.js +11 -3
- package/dist/cli/projectId.js +69 -0
- package/dist/core/api/contracts.js +182 -45
- package/dist/core/api/errors.js +4 -7
- package/dist/core/api/memoryCoreService.js +409 -240
- package/dist/core/api/sessionLifecycleService.js +20 -2
- package/dist/core/config/policyConfig.js +53 -6
- package/dist/core/injection/formatStartupInjection.js +55 -10
- package/dist/core/injection/tokenBudget.js +8 -0
- package/dist/core/retrieve/importance.js +4 -3
- package/dist/core/retrieve/recencyBands.js +6 -10
- package/dist/core/retrieve/retrieveMemories.js +19 -4
- package/dist/core/retrieve/score.js +11 -1
- package/dist/core/schema/migrations/005_team_provenance.sql +14 -9
- package/dist/core/schema/migrations/006_access_pattern_boosting.sql +10 -0
- package/dist/core/schema/migrations/007_feedback_manual_delete.sql +23 -0
- package/dist/core/schema/migrations/008_fts5_search.sql +37 -0
- package/dist/core/schema/migrations/009_session_events_unique.sql +24 -0
- package/dist/core/schema/runMigrations.js +64 -2
- package/dist/core/storage/db.js +6 -0
- package/dist/core/storage/memoryFeedbackRepo.js +14 -4
- package/dist/core/storage/memoryRepo.js +292 -121
- package/dist/core/storage/memorySearchRepo.js +125 -13
- package/dist/core/storage/sessionEventsRepo.js +33 -10
- package/dist/core/storage/summarizationFailuresRepo.js +36 -26
- package/dist/core/storage/tokenSavingsRepo.js +20 -0
- package/dist/core/summarize/cloudSummarizer.js +34 -5
- package/dist/core/summarize/localSummarizer.js +1 -10
- package/dist/core/summarize/redaction.js +45 -8
- package/package.json +50 -48
|
@@ -2,12 +2,14 @@ import { userInfo } from "node:os";
|
|
|
2
2
|
import { ZodError } from "zod";
|
|
3
3
|
import { deterministicEmbed } from "../embed/deterministicEmbed.js";
|
|
4
4
|
import { retrieveMemories } from "../retrieve/retrieveMemories.js";
|
|
5
|
+
import { computeEffectiveImportance } from "../retrieve/score.js";
|
|
5
6
|
import { formatStartupInjection } from "../injection/formatStartupInjection.js";
|
|
6
7
|
import { applyRedaction } from "../summarize/redaction.js";
|
|
7
|
-
import { countMemoriesOlderThan, deleteMemoriesOlderThan, insertMemory, listMemoriesByProject,
|
|
8
|
-
import { configFilePath, readPolicyConfig, resolvePolicySettings, } from "../config/policyConfig.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { countAllMemoriesByProject, countMemoriesBySession, countMemoriesOlderThan, deleteMemoriesOlderThan, deleteMemoryById, getMemoryOwnerProjectId, getMemoryRecordById, incrementAccessCounts, insertMemory, listMemoriesByProject, resetAccessCounts, updateMemoryContent, upsertImportedMemory, upsertPulledMemory, upsertSessionSummaryMemory, } from "../storage/memoryRepo.js";
|
|
9
|
+
import { SESSION_WRITE_SOFT_LIMIT, configFilePath, DEEP_MODE_RETRIEVAL_CAP, readPolicyConfig, resolvePolicySettings, } from "../config/policyConfig.js";
|
|
10
|
+
import { insertMemoryFeedbackEvent } from "../storage/memoryFeedbackRepo.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";
|
|
11
13
|
import { DomainError, toErrorEnvelope } from "./errors.js";
|
|
12
14
|
import { assertLocalOnlyPolicy, } from "./localOnlyPolicy.js";
|
|
13
15
|
import { createSessionLifecycleService } from "./sessionLifecycleService.js";
|
|
@@ -33,6 +35,28 @@ function resolveServiceUsername(explicit) {
|
|
|
33
35
|
return "";
|
|
34
36
|
}
|
|
35
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
|
+
}
|
|
36
60
|
function toMemoryDto(record) {
|
|
37
61
|
return {
|
|
38
62
|
id: record.id,
|
|
@@ -40,18 +64,36 @@ function toMemoryDto(record) {
|
|
|
40
64
|
sessionId: record.session_id,
|
|
41
65
|
sourceAdapter: record.source_adapter,
|
|
42
66
|
kind: record.kind,
|
|
43
|
-
content: record.content,
|
|
44
|
-
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,
|
|
45
69
|
importance: record.importance,
|
|
46
|
-
embedding:
|
|
70
|
+
embedding: null,
|
|
47
71
|
embeddingDim: record.embedding_dim,
|
|
48
72
|
embeddingVersion: record.embedding_version,
|
|
49
73
|
author: record.author,
|
|
50
74
|
originProjectId: record.origin_project_id,
|
|
75
|
+
accessCount: record.access_count,
|
|
76
|
+
lastAccessed: record.last_accessed,
|
|
77
|
+
effectiveImportance: computeEffectiveImportance(record.importance, record.access_count),
|
|
51
78
|
createdAt: record.created_at,
|
|
52
79
|
updatedAt: record.updated_at,
|
|
53
80
|
};
|
|
54
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
|
+
}
|
|
55
97
|
function toRetrievedMemoryDto(record) {
|
|
56
98
|
return {
|
|
57
99
|
id: record.id,
|
|
@@ -59,34 +101,25 @@ function toRetrievedMemoryDto(record) {
|
|
|
59
101
|
sessionId: record.session_id,
|
|
60
102
|
sourceAdapter: record.source_adapter,
|
|
61
103
|
kind: record.kind,
|
|
62
|
-
content
|
|
63
|
-
|
|
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,
|
|
64
108
|
importance: record.importance,
|
|
65
109
|
embedding: null,
|
|
66
110
|
embeddingDim: record.embedding_dim,
|
|
67
111
|
embeddingVersion: record.embedding_version,
|
|
68
112
|
author: record.author,
|
|
69
113
|
originProjectId: record.origin_project_id,
|
|
114
|
+
accessCount: record.access_count,
|
|
115
|
+
lastAccessed: null,
|
|
116
|
+
effectiveImportance: computeEffectiveImportance(record.importance, record.access_count),
|
|
70
117
|
createdAt: record.created_at,
|
|
71
118
|
updatedAt: record.updated_at,
|
|
72
119
|
semantic: record.semantic,
|
|
73
120
|
score: record.score,
|
|
74
121
|
};
|
|
75
122
|
}
|
|
76
|
-
function getMemoryById(db, projectId, memoryId) {
|
|
77
|
-
const row = db
|
|
78
|
-
.prepare(`
|
|
79
|
-
SELECT
|
|
80
|
-
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
81
|
-
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
82
|
-
created_at, updated_at
|
|
83
|
-
FROM memories
|
|
84
|
-
WHERE project_id = ? AND id = ?
|
|
85
|
-
LIMIT 1
|
|
86
|
-
`)
|
|
87
|
-
.get(projectId, memoryId);
|
|
88
|
-
return row;
|
|
89
|
-
}
|
|
90
123
|
function parseRequest(schema, request) {
|
|
91
124
|
try {
|
|
92
125
|
return schema.parse(request);
|
|
@@ -136,32 +169,56 @@ export function createMemoryCoreService(deps) {
|
|
|
136
169
|
const methods = {
|
|
137
170
|
async ingestSessionEvents(request) {
|
|
138
171
|
const parsed = parseRequest(ingestSessionEventsRequestSchema, request);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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();
|
|
150
200
|
return {
|
|
151
201
|
ok: true,
|
|
152
|
-
ingested
|
|
202
|
+
ingested,
|
|
153
203
|
};
|
|
154
204
|
},
|
|
155
205
|
async summarizeSessionToMemory(request) {
|
|
156
206
|
const parsed = parseRequest(summarizeSessionToMemoryRequestSchema, request);
|
|
157
|
-
|
|
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);
|
|
158
215
|
upsertSessionSummaryMemory(db, {
|
|
159
216
|
id: parsed.memoryId,
|
|
160
217
|
project_id: parsed.projectId,
|
|
161
218
|
session_id: parsed.sessionId,
|
|
162
219
|
source_adapter: parsed.sourceAdapter,
|
|
163
220
|
kind: "summary",
|
|
164
|
-
content:
|
|
221
|
+
content: redaction.text,
|
|
165
222
|
normalized_content: embedding.normalizedText,
|
|
166
223
|
importance: parsed.importance,
|
|
167
224
|
embedding: JSON.stringify(embedding.vector),
|
|
@@ -188,6 +245,14 @@ export function createMemoryCoreService(deps) {
|
|
|
188
245
|
redactionEnabled: resolveRedactionEnabled(parsed.redactionEnabled),
|
|
189
246
|
});
|
|
190
247
|
const embedding = deterministicEmbed(redaction.text, dimension);
|
|
248
|
+
// Per-session write soft limit: warn the caller when the session has
|
|
249
|
+
// already accumulated SESSION_WRITE_SOFT_LIMIT memories so the agent
|
|
250
|
+
// gets feedback to stop storing excessively. The write still proceeds.
|
|
251
|
+
const warningCodes = [...redaction.warningCodes];
|
|
252
|
+
const sessionCount = countMemoriesBySession(db, parsed.sessionId, parsed.projectId);
|
|
253
|
+
if (sessionCount >= SESSION_WRITE_SOFT_LIMIT) {
|
|
254
|
+
warningCodes.push("session_write_limit_warning");
|
|
255
|
+
}
|
|
191
256
|
insertMemory(db, {
|
|
192
257
|
id: parsed.memoryId,
|
|
193
258
|
project_id: parsed.projectId,
|
|
@@ -205,80 +270,93 @@ export function createMemoryCoreService(deps) {
|
|
|
205
270
|
author: localAuthor,
|
|
206
271
|
origin_project_id: null,
|
|
207
272
|
});
|
|
208
|
-
const inserted =
|
|
273
|
+
const inserted = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
|
|
209
274
|
if (!inserted) {
|
|
210
275
|
throw new DomainError("INTERNAL", "Memory insert did not persist");
|
|
211
276
|
}
|
|
212
277
|
return {
|
|
213
278
|
ok: true,
|
|
214
|
-
|
|
215
|
-
|
|
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),
|
|
286
|
+
warningCodes,
|
|
216
287
|
};
|
|
217
288
|
},
|
|
218
289
|
async retrieveMemories(request) {
|
|
219
290
|
const parsed = parseRequest(retrieveMemoriesRequestSchema, request);
|
|
220
|
-
const limit = parsed.depth === "deep" ? Math.min(parsed.limit * 2,
|
|
291
|
+
const limit = parsed.depth === "deep" ? Math.min(parsed.limit * 2, DEEP_MODE_RETRIEVAL_CAP) : parsed.limit;
|
|
221
292
|
const ranked = retrieveMemories({
|
|
222
293
|
db,
|
|
223
294
|
projectId: parsed.projectId,
|
|
224
295
|
queryText: parsed.query,
|
|
225
296
|
limit,
|
|
226
297
|
});
|
|
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.
|
|
302
|
+
incrementAccessCounts(db, parsed.projectId, ranked.map((m) => m.id));
|
|
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;
|
|
227
307
|
return {
|
|
228
308
|
ok: true,
|
|
229
309
|
memories: ranked.map(toRetrievedMemoryDto),
|
|
230
310
|
total: ranked.length,
|
|
231
|
-
// Render the startup-injection block here so the `author:`
|
|
232
|
-
// prefix annotation for teammate-authored memories reaches CLI/MCP
|
|
233
|
-
// callers via the production retrieval path.
|
|
234
311
|
startupInjection: formatStartupInjection(ranked, {
|
|
235
312
|
localUsername: localAuthor,
|
|
313
|
+
tokenCap: injectionCap,
|
|
236
314
|
}),
|
|
237
315
|
};
|
|
238
316
|
},
|
|
239
|
-
async recordMemoryUsed(request) {
|
|
240
|
-
const parsed = parseRequest(recordMemoryUsedRequestSchema, request);
|
|
241
|
-
const result = recordUse(db, {
|
|
242
|
-
project_id: parsed.projectId,
|
|
243
|
-
memory_id: parsed.memoryId,
|
|
244
|
-
feedback_type: parsed.feedbackType,
|
|
245
|
-
used_at: parsed.usedAt,
|
|
246
|
-
});
|
|
247
|
-
return {
|
|
248
|
-
ok: true,
|
|
249
|
-
memoryId: result.memory_id,
|
|
250
|
-
previousImportance: result.previous_importance,
|
|
251
|
-
newImportance: result.new_importance,
|
|
252
|
-
};
|
|
253
|
-
},
|
|
254
317
|
async listMemories(request) {
|
|
255
318
|
const parsed = parseRequest(listMemoriesRequestSchema, request);
|
|
256
|
-
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);
|
|
257
325
|
return {
|
|
258
326
|
ok: true,
|
|
259
327
|
memories: memories.map(toMemoryDto),
|
|
260
|
-
total:
|
|
328
|
+
total: all.length,
|
|
261
329
|
};
|
|
262
330
|
},
|
|
263
331
|
async getMemory(request) {
|
|
264
332
|
const parsed = parseRequest(getMemoryRequestSchema, request);
|
|
265
|
-
const memory =
|
|
333
|
+
const memory = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
|
|
266
334
|
if (!memory) {
|
|
267
335
|
throw new DomainError("NOT_FOUND", `Memory not found: ${parsed.memoryId}`);
|
|
268
336
|
}
|
|
269
337
|
return {
|
|
270
338
|
ok: true,
|
|
271
|
-
memory:
|
|
339
|
+
memory: toExportMemoryDto(memory),
|
|
272
340
|
};
|
|
273
341
|
},
|
|
274
342
|
async forgetMemory(request) {
|
|
275
343
|
const parsed = parseRequest(forgetMemoryRequestSchema, request);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
344
|
+
// Capture the memory's importance before deletion so we can record
|
|
345
|
+
// it in the feedback table as an analytics signal.
|
|
346
|
+
const existing = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
|
|
347
|
+
const deleted = deleteMemoryById(db, parsed.projectId, parsed.memoryId);
|
|
348
|
+
if (deleted === 0) {
|
|
280
349
|
throw new DomainError("NOT_FOUND", `Memory not found: ${parsed.memoryId}`);
|
|
281
350
|
}
|
|
351
|
+
// Record the explicit user deletion as feedback. The FK on
|
|
352
|
+
// memory_feedback no longer cascades (migration 007), so this row
|
|
353
|
+
// survives the memory deletion and serves as an analytics signal.
|
|
354
|
+
insertMemoryFeedbackEvent(db, {
|
|
355
|
+
memory_id: parsed.memoryId,
|
|
356
|
+
feedback_type: "manual_delete",
|
|
357
|
+
previous_importance: existing?.importance ?? 0,
|
|
358
|
+
new_importance: 0,
|
|
359
|
+
});
|
|
282
360
|
return {
|
|
283
361
|
ok: true,
|
|
284
362
|
};
|
|
@@ -288,45 +366,16 @@ export function createMemoryCoreService(deps) {
|
|
|
288
366
|
const memories = listMemoriesByProject(db, parsed.projectId);
|
|
289
367
|
return {
|
|
290
368
|
ok: true,
|
|
291
|
-
memories: memories.map(
|
|
369
|
+
memories: memories.map(toExportMemoryDto),
|
|
292
370
|
};
|
|
293
371
|
},
|
|
294
372
|
async importMemories(request) {
|
|
295
373
|
const parsed = parseRequest(importMemoriesRequestSchema, request);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
) VALUES (
|
|
302
|
-
@id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
|
|
303
|
-
@importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
|
|
304
|
-
COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
305
|
-
COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
306
|
-
)
|
|
307
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
308
|
-
project_id = excluded.project_id,
|
|
309
|
-
session_id = excluded.session_id,
|
|
310
|
-
source_adapter = excluded.source_adapter,
|
|
311
|
-
kind = excluded.kind,
|
|
312
|
-
content = excluded.content,
|
|
313
|
-
normalized_content = excluded.normalized_content,
|
|
314
|
-
importance = excluded.importance,
|
|
315
|
-
embedding = excluded.embedding,
|
|
316
|
-
embedding_dim = excluded.embedding_dim,
|
|
317
|
-
embedding_version = excluded.embedding_version,
|
|
318
|
-
author = excluded.author,
|
|
319
|
-
origin_project_id = excluded.origin_project_id,
|
|
320
|
-
created_at = excluded.created_at,
|
|
321
|
-
updated_at = excluded.updated_at
|
|
322
|
-
`);
|
|
323
|
-
// `id` is a globally-unique PRIMARY KEY (not scoped by
|
|
324
|
-
// project_id). The upsert above reassigns `project_id = excluded.project_id`
|
|
325
|
-
// on conflict, which would let an imported record silently overwrite and
|
|
326
|
-
// relocate another project's memory if its `id` happens to collide.
|
|
327
|
-
// Look up existing ownership per id and skip (rather than upsert) any
|
|
328
|
-
// record whose id already belongs to a *different* project.
|
|
329
|
-
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.
|
|
330
379
|
// Aggregate redaction warnings across all imported records. A
|
|
331
380
|
// Set de-duplicates the redaction_partial_failure code so the envelope
|
|
332
381
|
// stays compact regardless of how many records tripped the same rule.
|
|
@@ -334,52 +383,66 @@ export function createMemoryCoreService(deps) {
|
|
|
334
383
|
const effectiveRedactionEnabled = resolveRedactionEnabled(parsed.redactionEnabled);
|
|
335
384
|
let imported = 0;
|
|
336
385
|
let skippedCrossProject = 0;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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;
|
|
352
438
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
id: memory.id,
|
|
356
|
-
project_id: parsed.projectId,
|
|
357
|
-
session_id: memory.sessionId,
|
|
358
|
-
source_adapter: memory.sourceAdapter,
|
|
359
|
-
kind: memory.kind,
|
|
360
|
-
content: redaction.text,
|
|
361
|
-
normalized_content: embedding.normalizedText,
|
|
362
|
-
importance: memory.importance,
|
|
363
|
-
embedding: JSON.stringify(embedding.vector),
|
|
364
|
-
embedding_dim: embedding.dimension,
|
|
365
|
-
embedding_version: embedding.embeddingVersion,
|
|
366
|
-
// Plain import (not a team pull): preserve an incoming author when the
|
|
367
|
-
// export carried one, else stamp the local username so the row is
|
|
368
|
-
// never left with an empty author. origin_project_id is carried
|
|
369
|
-
// through when present, else null for locally-originating rows.
|
|
370
|
-
author: memory.author && memory.author.trim() !== ""
|
|
371
|
-
? memory.author
|
|
372
|
-
: localAuthor,
|
|
373
|
-
origin_project_id: memory.originProjectId ?? null,
|
|
374
|
-
created_at: memory.createdAt,
|
|
375
|
-
updated_at: memory.updatedAt,
|
|
376
|
-
});
|
|
377
|
-
imported += 1;
|
|
378
|
-
}
|
|
439
|
+
});
|
|
440
|
+
runImport();
|
|
379
441
|
return {
|
|
380
442
|
ok: true,
|
|
381
443
|
imported,
|
|
382
444
|
skippedCrossProject,
|
|
445
|
+
skippedExisting,
|
|
383
446
|
warningCodes: [...warningCodeSet],
|
|
384
447
|
};
|
|
385
448
|
},
|
|
@@ -388,102 +451,74 @@ export function createMemoryCoreService(deps) {
|
|
|
388
451
|
// Structural twin of importMemories with three team-pull changes:
|
|
389
452
|
// - importance uses MAX(local, incoming) so a teammate can never lower a
|
|
390
453
|
// locally-boosted importance (last-write-wins on content but
|
|
391
|
-
// importance-preserving).
|
|
454
|
+
// importance-preserving). upsertPulledMemory carries that merge.
|
|
392
455
|
// - author/origin_project_id are stamped from the incoming record's
|
|
393
456
|
// provenance so pulled rows carry the teammate's identity and
|
|
394
457
|
// their source project_id.
|
|
395
458
|
// - cross-project id collisions are skipped, exactly as import.
|
|
396
|
-
const stmt = db.prepare(`
|
|
397
|
-
INSERT INTO memories (
|
|
398
|
-
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
399
|
-
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
400
|
-
created_at, updated_at
|
|
401
|
-
) VALUES (
|
|
402
|
-
@id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
|
|
403
|
-
@importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
|
|
404
|
-
COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
405
|
-
COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
406
|
-
)
|
|
407
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
408
|
-
project_id = excluded.project_id,
|
|
409
|
-
session_id = excluded.session_id,
|
|
410
|
-
source_adapter = excluded.source_adapter,
|
|
411
|
-
kind = excluded.kind,
|
|
412
|
-
content = excluded.content,
|
|
413
|
-
normalized_content = excluded.normalized_content,
|
|
414
|
-
-- Importance-preserving merge. better-sqlite3@12 bundles a
|
|
415
|
-
-- SQLite that accepts the two-arg scalar MAX() inside DO UPDATE; the
|
|
416
|
-
-- pull-merge importance-preserve test verifies both directions.
|
|
417
|
-
importance = MAX(memories.importance, excluded.importance),
|
|
418
|
-
embedding = excluded.embedding,
|
|
419
|
-
embedding_dim = excluded.embedding_dim,
|
|
420
|
-
embedding_version = excluded.embedding_version,
|
|
421
|
-
author = excluded.author,
|
|
422
|
-
origin_project_id = excluded.origin_project_id,
|
|
423
|
-
created_at = excluded.created_at,
|
|
424
|
-
updated_at = excluded.updated_at
|
|
425
|
-
`);
|
|
426
|
-
// Same cross-project ownership skip as importMemories. A colliding
|
|
427
|
-
// id owned by a different project is skipped, never overwritten/relocated.
|
|
428
|
-
const ownerStmt = db.prepare("SELECT project_id FROM memories WHERE id = ?");
|
|
429
459
|
const warningCodeSet = new Set();
|
|
430
460
|
const effectiveRedactionEnabled = resolveRedactionEnabled(parsed.redactionEnabled);
|
|
431
461
|
let pulledNew = 0;
|
|
432
462
|
let pulledUpdated = 0;
|
|
433
463
|
let skippedCrossProject = 0;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
485
519
|
}
|
|
486
|
-
}
|
|
520
|
+
});
|
|
521
|
+
runPull();
|
|
487
522
|
return {
|
|
488
523
|
ok: true,
|
|
489
524
|
pulledNew,
|
|
@@ -537,8 +572,12 @@ export function createMemoryCoreService(deps) {
|
|
|
537
572
|
// the limit isn't split into an unpaired surrogate.
|
|
538
573
|
previews.push(Array.from(redaction.text).slice(0, REDACT_PREVIEW_MAX_LENGTH).join(""));
|
|
539
574
|
if (parsed.apply) {
|
|
540
|
-
// Recompute the embedding
|
|
541
|
-
//
|
|
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.
|
|
542
581
|
const embedding = deterministicEmbed(redaction.text, dimension);
|
|
543
582
|
// A single row that was deleted concurrently between the
|
|
544
583
|
// initial listMemoriesByProject snapshot and this update would
|
|
@@ -548,7 +587,11 @@ export function createMemoryCoreService(deps) {
|
|
|
548
587
|
// wrapped in a transaction). Catch per-row and report it as
|
|
549
588
|
// skipped instead.
|
|
550
589
|
try {
|
|
551
|
-
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
|
+
});
|
|
552
595
|
updated += 1;
|
|
553
596
|
}
|
|
554
597
|
catch {
|
|
@@ -565,18 +608,144 @@ export function createMemoryCoreService(deps) {
|
|
|
565
608
|
previews,
|
|
566
609
|
};
|
|
567
610
|
},
|
|
611
|
+
async batchStoreMemory(request) {
|
|
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
|
+
}
|
|
621
|
+
const results = [];
|
|
622
|
+
let stored = 0;
|
|
623
|
+
let failed = 0;
|
|
624
|
+
// Validate each item individually before entering the transaction so
|
|
625
|
+
// validation errors are reported per-item without aborting the whole batch.
|
|
626
|
+
const validatedItems = [];
|
|
627
|
+
for (let i = 0; i < parsed.memories.length; i++) {
|
|
628
|
+
const raw = parsed.memories[i];
|
|
629
|
+
try {
|
|
630
|
+
// The array items were already parsed by batchStoreMemoryRequestSchema,
|
|
631
|
+
// but we re-validate with the item schema so per-item errors are
|
|
632
|
+
// captured individually (e.g. if a caller bypasses the outer schema).
|
|
633
|
+
const item = batchStoreMemoryItemSchema.parse(raw);
|
|
634
|
+
validatedItems.push({ index: i, item });
|
|
635
|
+
}
|
|
636
|
+
catch (err) {
|
|
637
|
+
results.push({
|
|
638
|
+
memoryId: raw.memoryId ?? `<index-${i}>`,
|
|
639
|
+
ok: false,
|
|
640
|
+
error: err instanceof ZodError
|
|
641
|
+
? err.issues.map((issue) => issue.message).join("; ")
|
|
642
|
+
: String(err),
|
|
643
|
+
});
|
|
644
|
+
failed += 1;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// Wrap all valid inserts in a single SQLite transaction for atomicity
|
|
648
|
+
// and performance (better-sqlite3 transactions avoid per-statement
|
|
649
|
+
// fsync, making batch inserts significantly faster).
|
|
650
|
+
if (validatedItems.length > 0) {
|
|
651
|
+
const runTransaction = db.transaction(() => {
|
|
652
|
+
for (const { item } of validatedItems) {
|
|
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;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
runTransaction();
|
|
719
|
+
}
|
|
720
|
+
// Sort results back into original input order: validated items were
|
|
721
|
+
// processed in order but failed items were pushed first. Re-sort by
|
|
722
|
+
// the memoryId to maintain a predictable output. Since memoryIds are
|
|
723
|
+
// unique, use the input array order as the canonical sort key.
|
|
724
|
+
const inputOrder = new Map(parsed.memories.map((m, i) => [m.memoryId, i]));
|
|
725
|
+
results.sort((a, b) => (inputOrder.get(a.memoryId) ?? 0) - (inputOrder.get(b.memoryId) ?? 0));
|
|
726
|
+
return {
|
|
727
|
+
ok: true,
|
|
728
|
+
results,
|
|
729
|
+
stored,
|
|
730
|
+
failed,
|
|
731
|
+
};
|
|
732
|
+
},
|
|
568
733
|
async stats(request) {
|
|
569
734
|
const parsed = parseRequest(statsRequestSchema, request);
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
735
|
+
const totalMemories = countAllMemoriesByProject(db, parsed.projectId);
|
|
736
|
+
const totalSessionEvents = countAllSessionEvents(db, parsed.projectId);
|
|
737
|
+
return {
|
|
738
|
+
ok: true,
|
|
739
|
+
totalMemories,
|
|
740
|
+
totalSessionEvents,
|
|
741
|
+
};
|
|
742
|
+
},
|
|
743
|
+
async resetAccessCounts(request) {
|
|
744
|
+
const parsed = parseRequest(resetAccessCountsRequestSchema, request);
|
|
745
|
+
const affected = resetAccessCounts(db, parsed.projectId);
|
|
576
746
|
return {
|
|
577
747
|
ok: true,
|
|
578
|
-
|
|
579
|
-
totalSessionEvents: sessionEventCount.count,
|
|
748
|
+
affected,
|
|
580
749
|
};
|
|
581
750
|
},
|
|
582
751
|
};
|