sessionmem 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/dist/adapters/capabilities/fallbackTools.js +36 -0
- package/dist/adapters/contract/hostAdapterContract.js +1 -0
- package/dist/adapters/factory.js +40 -0
- package/dist/adapters/generic.js +128 -0
- package/dist/adapters/global/antigravity.js +22 -0
- package/dist/adapters/global/claudeCode.js +22 -0
- package/dist/adapters/global/codex.js +22 -0
- package/dist/adapters/global/qcoder.js +22 -0
- package/dist/adapters/ide/cline.js +20 -0
- package/dist/adapters/ide/cursor.js +28 -0
- package/dist/adapters/ide/installer.js +57 -0
- package/dist/adapters/ide/windsurf.js +28 -0
- package/dist/adapters/tools/ping.js +15 -0
- package/dist/cli/commands/config.js +79 -0
- package/dist/cli/commands/export.js +28 -0
- package/dist/cli/commands/forget.js +28 -0
- package/dist/cli/commands/import.js +112 -0
- package/dist/cli/commands/install.js +57 -0
- package/dist/cli/commands/list.js +13 -0
- package/dist/cli/commands/ping.js +12 -0
- package/dist/cli/commands/redactScan.js +40 -0
- package/dist/cli/commands/retention.js +54 -0
- package/dist/cli/commands/run.js +26 -0
- package/dist/cli/commands/search.js +29 -0
- package/dist/cli/commands/show.js +15 -0
- package/dist/cli/commands/stats.js +46 -0
- package/dist/cli/commands/sync.js +118 -0
- package/dist/cli/commands/team.js +96 -0
- package/dist/cli/commands/uninstall.js +30 -0
- package/dist/cli/context.js +69 -0
- package/dist/cli/index.js +147 -0
- package/dist/cli/output.js +37 -0
- package/dist/core/api/contracts.js +263 -0
- package/dist/core/api/errors.js +29 -0
- package/dist/core/api/localOnlyPolicy.js +29 -0
- package/dist/core/api/memoryCoreService.js +595 -0
- package/dist/core/api/sessionLifecycleService.js +289 -0
- package/dist/core/config/policyConfig.js +131 -0
- package/dist/core/embed/deterministicEmbed.js +31 -0
- package/dist/core/embed/embeddingVersion.js +1 -0
- package/dist/core/embed/reembedPolicy.js +9 -0
- package/dist/core/embed/textNormalize.js +12 -0
- package/dist/core/injection/formatStartupInjection.js +97 -0
- package/dist/core/injection/tokenBudget.js +38 -0
- package/dist/core/retrieve/decay.js +15 -0
- package/dist/core/retrieve/importance.js +6 -0
- package/dist/core/retrieve/recencyBands.js +18 -0
- package/dist/core/retrieve/retrieveMemories.js +83 -0
- package/dist/core/retrieve/score.js +25 -0
- package/dist/core/schema/migrations/001_initial.sql +25 -0
- package/dist/core/schema/migrations/002_indexes.sql +18 -0
- package/dist/core/schema/migrations/003_summarization_failures.sql +14 -0
- package/dist/core/schema/migrations/004_memory_feedback.sql +12 -0
- package/dist/core/schema/migrations/005_team_provenance.sql +9 -0
- package/dist/core/schema/runMigrations.js +38 -0
- package/dist/core/session.js +4 -0
- package/dist/core/storage/db.js +8 -0
- package/dist/core/storage/memoryFeedbackRepo.js +16 -0
- package/dist/core/storage/memoryRepo.js +179 -0
- package/dist/core/storage/memorySearchRepo.js +30 -0
- package/dist/core/storage/sessionEventsRepo.js +20 -0
- package/dist/core/storage/summarizationFailuresRepo.js +39 -0
- package/dist/core/storage/types.js +1 -0
- package/dist/core/summarize/cloudSummarizer.js +19 -0
- package/dist/core/summarize/localSummarizer.js +31 -0
- package/dist/core/summarize/redaction.js +48 -0
- package/dist/core/summarize/strategySelector.js +7 -0
- package/dist/core/summarize/summaryShape.js +49 -0
- package/package.json +48 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { userInfo } from "node:os";
|
|
2
|
+
import { ZodError } from "zod";
|
|
3
|
+
import { deterministicEmbed } from "../embed/deterministicEmbed.js";
|
|
4
|
+
import { retrieveMemories } from "../retrieve/retrieveMemories.js";
|
|
5
|
+
import { formatStartupInjection } from "../injection/formatStartupInjection.js";
|
|
6
|
+
import { applyRedaction } from "../summarize/redaction.js";
|
|
7
|
+
import { countMemoriesOlderThan, deleteMemoriesOlderThan, insertMemory, listMemoriesByProject, recordUse, updateMemoryContent, upsertSessionSummaryMemory, } from "../storage/memoryRepo.js";
|
|
8
|
+
import { configFilePath, readPolicyConfig, resolvePolicySettings, } from "../config/policyConfig.js";
|
|
9
|
+
import { insertSessionEvent } from "../storage/sessionEventsRepo.js";
|
|
10
|
+
import { exportMemoriesRequestSchema, forgetMemoryRequestSchema, getMemoryRequestSchema, handleSessionEndRequestSchema, importMemoriesRequestSchema, pullMemoriesRequestSchema, ingestSessionEventsRequestSchema, listMemoriesRequestSchema, pruneMemoriesRequestSchema, recordMemoryUsedRequestSchema, redactExistingRequestSchema, retrieveMemoriesRequestSchema, statsRequestSchema, storeMemoryRequestSchema, summarizeSessionToMemoryRequestSchema, } from "./contracts.js";
|
|
11
|
+
import { DomainError, toErrorEnvelope } from "./errors.js";
|
|
12
|
+
import { assertLocalOnlyPolicy, } from "./localOnlyPolicy.js";
|
|
13
|
+
import { createSessionLifecycleService } from "./sessionLifecycleService.js";
|
|
14
|
+
const DEFAULT_EMBEDDING_DIMENSION = 32;
|
|
15
|
+
// Maximum length of a redactExisting preview entry. Previews are built from the
|
|
16
|
+
// REDACTED text (never the raw secret) and truncated so a long
|
|
17
|
+
// memory body cannot leak surrounding context in bulk.
|
|
18
|
+
const REDACT_PREVIEW_MAX_LENGTH = 120;
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the local OS username for author stamping, sanitized to a
|
|
21
|
+
* filename-safe token and falling back to "" when unavailable. Mirrors
|
|
22
|
+
* cli/context.localUsername but without a "user" fallback so the service can be
|
|
23
|
+
* driven with an explicit username dep in tests.
|
|
24
|
+
*/
|
|
25
|
+
function resolveServiceUsername(explicit) {
|
|
26
|
+
if (explicit !== undefined) {
|
|
27
|
+
return explicit;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return (userInfo().username ?? "").replace(/[^A-Za-z0-9._-]/g, "_");
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function toMemoryDto(record) {
|
|
37
|
+
return {
|
|
38
|
+
id: record.id,
|
|
39
|
+
projectId: record.project_id,
|
|
40
|
+
sessionId: record.session_id,
|
|
41
|
+
sourceAdapter: record.source_adapter,
|
|
42
|
+
kind: record.kind,
|
|
43
|
+
content: record.content,
|
|
44
|
+
normalizedContent: record.normalized_content,
|
|
45
|
+
importance: record.importance,
|
|
46
|
+
embedding: record.embedding,
|
|
47
|
+
embeddingDim: record.embedding_dim,
|
|
48
|
+
embeddingVersion: record.embedding_version,
|
|
49
|
+
author: record.author,
|
|
50
|
+
originProjectId: record.origin_project_id,
|
|
51
|
+
createdAt: record.created_at,
|
|
52
|
+
updatedAt: record.updated_at,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function toRetrievedMemoryDto(record) {
|
|
56
|
+
return {
|
|
57
|
+
id: record.id,
|
|
58
|
+
projectId: record.project_id,
|
|
59
|
+
sessionId: record.session_id,
|
|
60
|
+
sourceAdapter: record.source_adapter,
|
|
61
|
+
kind: record.kind,
|
|
62
|
+
content: record.content,
|
|
63
|
+
normalizedContent: record.normalized_content,
|
|
64
|
+
importance: record.importance,
|
|
65
|
+
embedding: null,
|
|
66
|
+
embeddingDim: record.embedding_dim,
|
|
67
|
+
embeddingVersion: record.embedding_version,
|
|
68
|
+
author: record.author,
|
|
69
|
+
originProjectId: record.origin_project_id,
|
|
70
|
+
createdAt: record.created_at,
|
|
71
|
+
updatedAt: record.updated_at,
|
|
72
|
+
semantic: record.semantic,
|
|
73
|
+
score: record.score,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
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
|
+
function parseRequest(schema, request) {
|
|
91
|
+
try {
|
|
92
|
+
return schema.parse(request);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof ZodError) {
|
|
96
|
+
throw new DomainError("VALIDATION", "Invalid request payload", error.issues);
|
|
97
|
+
}
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function toErrorResponse(error) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: toErrorEnvelope(error),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export function createMemoryCoreService(deps) {
|
|
108
|
+
assertLocalOnlyPolicy(deps.policyConfig ?? { localOnly: true });
|
|
109
|
+
const dimension = deps.embeddingDimension ?? DEFAULT_EMBEDDING_DIMENSION;
|
|
110
|
+
const { db } = deps;
|
|
111
|
+
// Resolve the local author identity once per service instance.
|
|
112
|
+
const localAuthor = resolveServiceUsername(deps.username);
|
|
113
|
+
const policyConfigPath = deps.policyConfigPath ?? configFilePath();
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the effective redactionEnabled flag using precedence
|
|
116
|
+
* override (explicit per-request value) > config.json > default,
|
|
117
|
+
* mirroring resolveRetentionDays in sessionLifecycleService. An explicit
|
|
118
|
+
* `false` or `true` on the request always wins; omission falls back to
|
|
119
|
+
* `~/.sessionmem/config.json`'s redactionEnabled.
|
|
120
|
+
*/
|
|
121
|
+
function resolveRedactionEnabled(explicit) {
|
|
122
|
+
return resolvePolicySettings({
|
|
123
|
+
override: explicit !== undefined ? { redactionEnabled: explicit } : undefined,
|
|
124
|
+
config: readPolicyConfig(policyConfigPath),
|
|
125
|
+
}).redactionEnabled;
|
|
126
|
+
}
|
|
127
|
+
const lifecycleService = createSessionLifecycleService({
|
|
128
|
+
db,
|
|
129
|
+
embeddingDimension: dimension,
|
|
130
|
+
username: localAuthor,
|
|
131
|
+
policyConfigPath: deps.policyConfigPath,
|
|
132
|
+
retentionDaysOverride: deps.retentionDaysOverride,
|
|
133
|
+
now: deps.now,
|
|
134
|
+
deleteOldMemories: deps.deleteOldMemories,
|
|
135
|
+
});
|
|
136
|
+
const methods = {
|
|
137
|
+
async ingestSessionEvents(request) {
|
|
138
|
+
const parsed = parseRequest(ingestSessionEventsRequestSchema, request);
|
|
139
|
+
for (const event of parsed.events) {
|
|
140
|
+
insertSessionEvent(db, {
|
|
141
|
+
id: event.id,
|
|
142
|
+
project_id: parsed.projectId,
|
|
143
|
+
session_id: parsed.sessionId,
|
|
144
|
+
event_index: event.eventIndex,
|
|
145
|
+
event_type: event.eventType,
|
|
146
|
+
payload_json: event.payloadJson,
|
|
147
|
+
created_at: event.createdAt,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
ingested: parsed.events.length,
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
async summarizeSessionToMemory(request) {
|
|
156
|
+
const parsed = parseRequest(summarizeSessionToMemoryRequestSchema, request);
|
|
157
|
+
const embedding = deterministicEmbed(parsed.summary, dimension);
|
|
158
|
+
upsertSessionSummaryMemory(db, {
|
|
159
|
+
id: parsed.memoryId,
|
|
160
|
+
project_id: parsed.projectId,
|
|
161
|
+
session_id: parsed.sessionId,
|
|
162
|
+
source_adapter: parsed.sourceAdapter,
|
|
163
|
+
kind: "summary",
|
|
164
|
+
content: parsed.summary,
|
|
165
|
+
normalized_content: embedding.normalizedText,
|
|
166
|
+
importance: parsed.importance,
|
|
167
|
+
embedding: JSON.stringify(embedding.vector),
|
|
168
|
+
embedding_dim: embedding.dimension,
|
|
169
|
+
embedding_version: embedding.embeddingVersion,
|
|
170
|
+
author: localAuthor,
|
|
171
|
+
origin_project_id: null,
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
memoryId: parsed.memoryId,
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
async handleSessionEnd(request) {
|
|
179
|
+
const parsed = parseRequest(handleSessionEndRequestSchema, request);
|
|
180
|
+
return lifecycleService.handleSessionEnd(parsed);
|
|
181
|
+
},
|
|
182
|
+
async storeMemory(request) {
|
|
183
|
+
const parsed = parseRequest(storeMemoryRequestSchema, request);
|
|
184
|
+
// Redact before embedding/persisting so secrets never reach storage and
|
|
185
|
+
// the embedding is computed on the redacted text. warningCodes
|
|
186
|
+
// reuse the existing redaction_partial_failure mechanism.
|
|
187
|
+
const redaction = applyRedaction(parsed.content, {
|
|
188
|
+
redactionEnabled: resolveRedactionEnabled(parsed.redactionEnabled),
|
|
189
|
+
});
|
|
190
|
+
const embedding = deterministicEmbed(redaction.text, dimension);
|
|
191
|
+
insertMemory(db, {
|
|
192
|
+
id: parsed.memoryId,
|
|
193
|
+
project_id: parsed.projectId,
|
|
194
|
+
session_id: parsed.sessionId,
|
|
195
|
+
source_adapter: parsed.sourceAdapter,
|
|
196
|
+
kind: parsed.kind,
|
|
197
|
+
content: redaction.text,
|
|
198
|
+
normalized_content: embedding.normalizedText,
|
|
199
|
+
importance: parsed.importance,
|
|
200
|
+
embedding: JSON.stringify(embedding.vector),
|
|
201
|
+
embedding_dim: embedding.dimension,
|
|
202
|
+
embedding_version: embedding.embeddingVersion,
|
|
203
|
+
// Locally-authored row: stamp the local username; origin is null
|
|
204
|
+
// because this row did not come from another project's store.
|
|
205
|
+
author: localAuthor,
|
|
206
|
+
origin_project_id: null,
|
|
207
|
+
});
|
|
208
|
+
const inserted = getMemoryById(db, parsed.projectId, parsed.memoryId);
|
|
209
|
+
if (!inserted) {
|
|
210
|
+
throw new DomainError("INTERNAL", "Memory insert did not persist");
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
memory: toMemoryDto(inserted),
|
|
215
|
+
warningCodes: redaction.warningCodes,
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
async retrieveMemories(request) {
|
|
219
|
+
const parsed = parseRequest(retrieveMemoriesRequestSchema, request);
|
|
220
|
+
const limit = parsed.depth === "deep" ? Math.min(parsed.limit * 2, 100) : parsed.limit;
|
|
221
|
+
const ranked = retrieveMemories({
|
|
222
|
+
db,
|
|
223
|
+
projectId: parsed.projectId,
|
|
224
|
+
queryText: parsed.query,
|
|
225
|
+
limit,
|
|
226
|
+
});
|
|
227
|
+
return {
|
|
228
|
+
ok: true,
|
|
229
|
+
memories: ranked.map(toRetrievedMemoryDto),
|
|
230
|
+
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
|
+
startupInjection: formatStartupInjection(ranked, {
|
|
235
|
+
localUsername: localAuthor,
|
|
236
|
+
}),
|
|
237
|
+
};
|
|
238
|
+
},
|
|
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
|
+
async listMemories(request) {
|
|
255
|
+
const parsed = parseRequest(listMemoriesRequestSchema, request);
|
|
256
|
+
const memories = listMemoriesByProject(db, parsed.projectId);
|
|
257
|
+
return {
|
|
258
|
+
ok: true,
|
|
259
|
+
memories: memories.map(toMemoryDto),
|
|
260
|
+
total: memories.length,
|
|
261
|
+
};
|
|
262
|
+
},
|
|
263
|
+
async getMemory(request) {
|
|
264
|
+
const parsed = parseRequest(getMemoryRequestSchema, request);
|
|
265
|
+
const memory = getMemoryById(db, parsed.projectId, parsed.memoryId);
|
|
266
|
+
if (!memory) {
|
|
267
|
+
throw new DomainError("NOT_FOUND", `Memory not found: ${parsed.memoryId}`);
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
memory: toMemoryDto(memory),
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
async forgetMemory(request) {
|
|
275
|
+
const parsed = parseRequest(forgetMemoryRequestSchema, request);
|
|
276
|
+
const result = db
|
|
277
|
+
.prepare("DELETE FROM memories WHERE project_id = ? AND id = ?")
|
|
278
|
+
.run(parsed.projectId, parsed.memoryId);
|
|
279
|
+
if (result.changes === 0) {
|
|
280
|
+
throw new DomainError("NOT_FOUND", `Memory not found: ${parsed.memoryId}`);
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
ok: true,
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
async exportMemories(request) {
|
|
287
|
+
const parsed = parseRequest(exportMemoriesRequestSchema, request);
|
|
288
|
+
const memories = listMemoriesByProject(db, parsed.projectId);
|
|
289
|
+
return {
|
|
290
|
+
ok: true,
|
|
291
|
+
memories: memories.map(toMemoryDto),
|
|
292
|
+
};
|
|
293
|
+
},
|
|
294
|
+
async importMemories(request) {
|
|
295
|
+
const parsed = parseRequest(importMemoriesRequestSchema, request);
|
|
296
|
+
const stmt = db.prepare(`
|
|
297
|
+
INSERT INTO memories (
|
|
298
|
+
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
299
|
+
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
300
|
+
created_at, updated_at
|
|
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 = ?");
|
|
330
|
+
// Aggregate redaction warnings across all imported records. A
|
|
331
|
+
// Set de-duplicates the redaction_partial_failure code so the envelope
|
|
332
|
+
// stays compact regardless of how many records tripped the same rule.
|
|
333
|
+
const warningCodeSet = new Set();
|
|
334
|
+
const effectiveRedactionEnabled = resolveRedactionEnabled(parsed.redactionEnabled);
|
|
335
|
+
let imported = 0;
|
|
336
|
+
let skippedCrossProject = 0;
|
|
337
|
+
for (const memory of parsed.memories) {
|
|
338
|
+
const owner = ownerStmt.get(memory.id);
|
|
339
|
+
if (owner && owner.project_id !== parsed.projectId) {
|
|
340
|
+
// Another project already owns this id: skip rather than overwrite
|
|
341
|
+
// and reassign ownership via ON CONFLICT(id).
|
|
342
|
+
skippedCrossProject += 1;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
// Redact each record before embedding/upsert so secrets never persist
|
|
346
|
+
// and the embedding reflects the redacted text.
|
|
347
|
+
const redaction = applyRedaction(memory.content, {
|
|
348
|
+
redactionEnabled: effectiveRedactionEnabled,
|
|
349
|
+
});
|
|
350
|
+
for (const code of redaction.warningCodes) {
|
|
351
|
+
warningCodeSet.add(code);
|
|
352
|
+
}
|
|
353
|
+
const embedding = deterministicEmbed(redaction.text, dimension);
|
|
354
|
+
stmt.run({
|
|
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
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
ok: true,
|
|
381
|
+
imported,
|
|
382
|
+
skippedCrossProject,
|
|
383
|
+
warningCodes: [...warningCodeSet],
|
|
384
|
+
};
|
|
385
|
+
},
|
|
386
|
+
async pullMemories(request) {
|
|
387
|
+
const parsed = parseRequest(pullMemoriesRequestSchema, request);
|
|
388
|
+
// Structural twin of importMemories with three team-pull changes:
|
|
389
|
+
// - importance uses MAX(local, incoming) so a teammate can never lower a
|
|
390
|
+
// locally-boosted importance (last-write-wins on content but
|
|
391
|
+
// importance-preserving).
|
|
392
|
+
// - author/origin_project_id are stamped from the incoming record's
|
|
393
|
+
// provenance so pulled rows carry the teammate's identity and
|
|
394
|
+
// their source project_id.
|
|
395
|
+
// - 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
|
+
const warningCodeSet = new Set();
|
|
430
|
+
const effectiveRedactionEnabled = resolveRedactionEnabled(parsed.redactionEnabled);
|
|
431
|
+
let pulledNew = 0;
|
|
432
|
+
let pulledUpdated = 0;
|
|
433
|
+
let skippedCrossProject = 0;
|
|
434
|
+
for (const memory of parsed.memories) {
|
|
435
|
+
const owner = ownerStmt.get(memory.id);
|
|
436
|
+
if (owner && owner.project_id !== parsed.projectId) {
|
|
437
|
+
skippedCrossProject += 1;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
// An id already owned by THIS project is an update; otherwise a
|
|
441
|
+
// brand-new insert. Snapshotting per-id via ownerStmt keeps the count
|
|
442
|
+
// correct even when the same id appears across multiple teammate files.
|
|
443
|
+
const isUpdate = owner !== undefined;
|
|
444
|
+
// Re-run redaction on every pulled record regardless of the
|
|
445
|
+
// teammate's redaction setting (4th write path), then re-embed the
|
|
446
|
+
// redacted text so secrets never persist and the embedding matches.
|
|
447
|
+
const redaction = applyRedaction(memory.content, {
|
|
448
|
+
redactionEnabled: effectiveRedactionEnabled,
|
|
449
|
+
});
|
|
450
|
+
for (const code of redaction.warningCodes) {
|
|
451
|
+
warningCodeSet.add(code);
|
|
452
|
+
}
|
|
453
|
+
const embedding = deterministicEmbed(redaction.text, dimension);
|
|
454
|
+
stmt.run({
|
|
455
|
+
id: memory.id,
|
|
456
|
+
// LOCAL project_id so merged rows are retrievable in the pulling
|
|
457
|
+
// user's project (Open Q4).
|
|
458
|
+
project_id: parsed.projectId,
|
|
459
|
+
session_id: memory.sessionId,
|
|
460
|
+
source_adapter: memory.sourceAdapter,
|
|
461
|
+
kind: memory.kind,
|
|
462
|
+
content: redaction.text,
|
|
463
|
+
normalized_content: embedding.normalizedText,
|
|
464
|
+
importance: memory.importance,
|
|
465
|
+
embedding: JSON.stringify(embedding.vector),
|
|
466
|
+
embedding_dim: embedding.dimension,
|
|
467
|
+
embedding_version: embedding.embeddingVersion,
|
|
468
|
+
// Stamp the teammate's provenance. author falls back to the
|
|
469
|
+
// local username only when the incoming record carries none.
|
|
470
|
+
author: memory.author && memory.author.trim() !== ""
|
|
471
|
+
? memory.author
|
|
472
|
+
: localAuthor,
|
|
473
|
+
// origin_project_id records the record's source-machine project_id:
|
|
474
|
+
// its explicit originProjectId if present, else the record's own
|
|
475
|
+
// incoming projectId (Open Q4).
|
|
476
|
+
origin_project_id: memory.originProjectId ?? memory.projectId,
|
|
477
|
+
created_at: memory.createdAt,
|
|
478
|
+
updated_at: memory.updatedAt,
|
|
479
|
+
});
|
|
480
|
+
if (isUpdate) {
|
|
481
|
+
pulledUpdated += 1;
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
pulledNew += 1;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
ok: true,
|
|
489
|
+
pulledNew,
|
|
490
|
+
pulledUpdated,
|
|
491
|
+
skippedCrossProject,
|
|
492
|
+
warningCodes: [...warningCodeSet],
|
|
493
|
+
};
|
|
494
|
+
},
|
|
495
|
+
async pruneMemories(request) {
|
|
496
|
+
const parsed = parseRequest(pruneMemoriesRequestSchema, request);
|
|
497
|
+
// retentionDays <= 0 disables pruning entirely. A non-positive
|
|
498
|
+
// window must never translate into a future cutoff that could delete
|
|
499
|
+
// everything.
|
|
500
|
+
if (parsed.retentionDays <= 0) {
|
|
501
|
+
return { ok: true, deleted: 0, eligible: 0 };
|
|
502
|
+
}
|
|
503
|
+
const cutoffMs = Date.now() - parsed.retentionDays * 24 * 60 * 60 * 1000;
|
|
504
|
+
// ISO-8601 UTC with millisecond precision matches the stored created_at
|
|
505
|
+
// format (strftime('%Y-%m-%dT%H:%M:%fZ')), enabling lexicographic compare.
|
|
506
|
+
const cutoffIso = new Date(cutoffMs).toISOString();
|
|
507
|
+
const eligible = countMemoriesOlderThan(db, parsed.projectId, cutoffIso);
|
|
508
|
+
if (parsed.dryRun) {
|
|
509
|
+
return { ok: true, deleted: 0, eligible };
|
|
510
|
+
}
|
|
511
|
+
const deleted = deleteMemoriesOlderThan(db, parsed.projectId, cutoffIso);
|
|
512
|
+
return { ok: true, deleted, eligible };
|
|
513
|
+
},
|
|
514
|
+
async redactExisting(request) {
|
|
515
|
+
const parsed = parseRequest(redactExistingRequestSchema, request);
|
|
516
|
+
// One-time scrub of pre-existing rows. Dry-run by default:
|
|
517
|
+
// apply=false reports matches and previews but writes nothing.
|
|
518
|
+
const memories = listMemoriesByProject(db, parsed.projectId);
|
|
519
|
+
let matched = 0;
|
|
520
|
+
let updated = 0;
|
|
521
|
+
let skipped = 0;
|
|
522
|
+
const previews = [];
|
|
523
|
+
for (const memory of memories) {
|
|
524
|
+
const redaction = applyRedaction(memory.content, {
|
|
525
|
+
redactionEnabled: true,
|
|
526
|
+
});
|
|
527
|
+
// A "match" is a row whose content changes under the rule set.
|
|
528
|
+
if (redaction.text === memory.content) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
matched += 1;
|
|
532
|
+
// Preview is built from the REDACTED text and length-bounded so no raw
|
|
533
|
+
// secret is echoed and no large body leaks in bulk.
|
|
534
|
+
// Truncate on Unicode code-point boundaries (Array.from
|
|
535
|
+
// iterates by code point) rather than String.slice's UTF-16 code-unit
|
|
536
|
+
// boundaries, so a multi-byte character (emoji, non-BMP) straddling
|
|
537
|
+
// the limit isn't split into an unpaired surrogate.
|
|
538
|
+
previews.push(Array.from(redaction.text).slice(0, REDACT_PREVIEW_MAX_LENGTH).join(""));
|
|
539
|
+
if (parsed.apply) {
|
|
540
|
+
// Recompute the embedding-normalized text on the redacted content so
|
|
541
|
+
// the stored normalized_content stays consistent with the scrub.
|
|
542
|
+
const embedding = deterministicEmbed(redaction.text, dimension);
|
|
543
|
+
// A single row that was deleted concurrently between the
|
|
544
|
+
// initial listMemoriesByProject snapshot and this update would
|
|
545
|
+
// otherwise throw and abort the whole scrub, discarding the
|
|
546
|
+
// scanned/matched/updated counts and previews accumulated so far
|
|
547
|
+
// (and any prior updates already committed, since the loop is not
|
|
548
|
+
// wrapped in a transaction). Catch per-row and report it as
|
|
549
|
+
// skipped instead.
|
|
550
|
+
try {
|
|
551
|
+
updateMemoryContent(db, parsed.projectId, memory.id, redaction.text, embedding.normalizedText);
|
|
552
|
+
updated += 1;
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
skipped += 1;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
ok: true,
|
|
561
|
+
scanned: memories.length,
|
|
562
|
+
matched,
|
|
563
|
+
updated,
|
|
564
|
+
skipped,
|
|
565
|
+
previews,
|
|
566
|
+
};
|
|
567
|
+
},
|
|
568
|
+
async stats(request) {
|
|
569
|
+
const parsed = parseRequest(statsRequestSchema, request);
|
|
570
|
+
const memoryCount = db
|
|
571
|
+
.prepare("SELECT COUNT(*) AS count FROM memories WHERE project_id = ?")
|
|
572
|
+
.get(parsed.projectId);
|
|
573
|
+
const sessionEventCount = db
|
|
574
|
+
.prepare("SELECT COUNT(*) AS count FROM session_events WHERE project_id = ?")
|
|
575
|
+
.get(parsed.projectId);
|
|
576
|
+
return {
|
|
577
|
+
ok: true,
|
|
578
|
+
totalMemories: memoryCount.count,
|
|
579
|
+
totalSessionEvents: sessionEventCount.count,
|
|
580
|
+
};
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
async function call(method, request) {
|
|
584
|
+
try {
|
|
585
|
+
return await methods[method](request);
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
return toErrorResponse(error);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
...methods,
|
|
593
|
+
call,
|
|
594
|
+
};
|
|
595
|
+
}
|