om-memory-system 5.5.0 → 5.7.0-next.218
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/adapters/pi/extension.js +5 -0
- package/dist/cli/space-command.d.ts +6 -5
- package/dist/cli/space-command.js +154 -13
- package/dist/cli/space-transfer-command.d.ts +25 -0
- package/dist/cli/space-transfer-command.js +79 -0
- package/dist/core/memory-operations.d.ts +12 -2
- package/dist/core/memory-operations.js +14 -4
- package/dist/core/recall-summary.js +7 -5
- package/dist/core/retrieval.js +5 -2
- package/dist/core/space-tool.d.ts +9 -4
- package/dist/core/space-tool.js +35 -3
- package/dist/services/active-space-access.d.ts +6 -0
- package/dist/services/active-space-access.js +8 -0
- package/dist/services/chat/chat-runtime.js +1 -1
- package/dist/services/client.d.ts +6 -16
- package/dist/services/client.js +54 -62
- package/dist/services/context.d.ts +2 -0
- package/dist/services/context.js +12 -1
- package/dist/services/linked-search.d.ts +108 -0
- package/dist/services/linked-search.js +219 -0
- package/dist/services/space-routes.d.ts +2 -0
- package/dist/services/space-routes.js +4 -3
- package/dist/services/space-transfer-routes.d.ts +10 -0
- package/dist/services/space-transfer-routes.js +344 -0
- package/dist/services/spaces/active-space.js +14 -7
- package/dist/services/spaces/delete.d.ts +2 -0
- package/dist/services/spaces/delete.js +4 -0
- package/dist/services/spaces/export-space.d.ts +60 -0
- package/dist/services/spaces/export-space.js +367 -0
- package/dist/services/spaces/identifier-scrub.d.ts +26 -0
- package/dist/services/spaces/identifier-scrub.js +40 -0
- package/dist/services/spaces/import-space.d.ts +85 -0
- package/dist/services/spaces/import-space.js +349 -0
- package/dist/services/spaces/operations.d.ts +57 -5
- package/dist/services/spaces/operations.js +161 -5
- package/dist/services/spaces/read-store-cache.d.ts +13 -0
- package/dist/services/spaces/read-store-cache.js +34 -0
- package/dist/services/spaces/registry-store.d.ts +4 -0
- package/dist/services/spaces/registry-store.js +15 -1
- package/dist/services/spaces/registry.d.ts +32 -0
- package/dist/services/spaces/registry.js +81 -7
- package/dist/services/spaces/space-store.d.ts +50 -0
- package/dist/services/spaces/space-store.js +72 -0
- package/dist/services/spaces/transfer-service.d.ts +41 -0
- package/dist/services/spaces/transfer-service.js +408 -0
- package/dist/services/spaces/transfer-types.d.ts +163 -0
- package/dist/services/spaces/transfer-types.js +10 -0
- package/dist/services/spaces/transfer.d.ts +31 -0
- package/dist/services/spaces/transfer.js +276 -0
- package/dist/services/turso/connection-manager.d.ts +4 -2
- package/dist/services/turso/connection-manager.js +11 -5
- package/dist/services/turso/cross-process-write-lock.d.ts +1 -1
- package/dist/services/turso/cross-process-write-lock.js +5 -5
- package/dist/services/turso/operation-lock.d.ts +2 -2
- package/dist/services/turso/operation-lock.js +5 -5
- package/dist/services/turso/shard-manager.d.ts +8 -0
- package/dist/services/turso/shard-manager.js +23 -22
- package/dist/services/turso/vector-search.d.ts +6 -2
- package/dist/services/turso/vector-search.js +8 -4
- package/dist/services/user-profile/user-profile-manager.d.ts +5 -1
- package/dist/services/user-profile/user-profile-manager.js +13 -10
- package/dist/services/web-route-modules.d.ts +5 -1
- package/dist/services/web-route-modules.js +4 -0
- package/dist/services/web-server.js +1 -1
- package/dist/shared/api/space-export-schemas.d.ts +159 -0
- package/dist/shared/api/space-export-schemas.js +72 -0
- package/dist/v2/adapter.d.ts +15 -0
- package/dist/v2/adapter.js +5 -0
- package/dist/web/assets/index-2BNRLGqe.css +2 -0
- package/dist/web/assets/index-C6WnDE6h.js +170 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/skills/omms-memory/SKILL.md +16 -14
- package/dist/web/assets/index-Dcvz3EXt.js +0 -170
- package/dist/web/assets/index-b8k5Tuei.css +0 -2
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { isFullyPrivate } from "../privacy.js";
|
|
5
|
+
import { scrubIdentifiers } from "./identifier-scrub.js";
|
|
6
|
+
import { openSpaceStore } from "./space-store.js";
|
|
7
|
+
import { SPACE_EXPORT_FORMAT, SPACE_EXPORT_SCHEMA_VERSION, SpaceExportDocumentSchema, } from "../../shared/api/space-export-schemas.js";
|
|
8
|
+
/**
|
|
9
|
+
* The profile database of a space folder. Kept as a literal because importing
|
|
10
|
+
* the profile manager for its constant would run the manager's constructor at
|
|
11
|
+
* import time; see `USER_PROFILES_DB_NAME` in user-profile-manager.ts.
|
|
12
|
+
*/
|
|
13
|
+
const PROFILE_DB_NAME = "user-profiles.db";
|
|
14
|
+
/** Identity columns of a memory row: dropped whole, never exported. */
|
|
15
|
+
const IDENTITY_COLUMNS = [
|
|
16
|
+
"display_name",
|
|
17
|
+
"user_name",
|
|
18
|
+
"user_email",
|
|
19
|
+
"project_path",
|
|
20
|
+
"project_name",
|
|
21
|
+
"git_repo_url",
|
|
22
|
+
];
|
|
23
|
+
/** Every column the export reads. The vector columns are absent by design. */
|
|
24
|
+
const MEMORY_COLUMNS = "id, content, container_tag, tags, type, created_at, updated_at, metadata, " +
|
|
25
|
+
"display_name, user_name, user_email, project_path, project_name, git_repo_url, is_pinned";
|
|
26
|
+
/** The version of this OMMS build, read from package.json; "unknown" when unreadable. */
|
|
27
|
+
export function getOmmsVersion() {
|
|
28
|
+
try {
|
|
29
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../../package.json");
|
|
30
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
31
|
+
return pkg.version ?? "unknown";
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return "unknown";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** The free-text limit the spec requires every export result to state. */
|
|
38
|
+
const FREE_TEXT_WARNING = "Free-text names in memory text are not detected. Check the file before you share it.";
|
|
39
|
+
/** Scrub one string and add its replacement counts to the totals. */
|
|
40
|
+
function scrub(text, counts) {
|
|
41
|
+
const result = scrubIdentifiers(text);
|
|
42
|
+
counts.pathsReplaced += result.counts.pathsReplaced;
|
|
43
|
+
counts.emailsReplaced += result.counts.emailsReplaced;
|
|
44
|
+
return result.text;
|
|
45
|
+
}
|
|
46
|
+
/** True when a whole metadata value is itself a folder or file path. */
|
|
47
|
+
function isPathValue(value) {
|
|
48
|
+
return (value.startsWith("/") ||
|
|
49
|
+
value.startsWith("~/") ||
|
|
50
|
+
value === "~" ||
|
|
51
|
+
/^[a-zA-Z]:[\\/]/.test(value) ||
|
|
52
|
+
value.startsWith("\\\\"));
|
|
53
|
+
}
|
|
54
|
+
/** The identity values of one row: any metadata value equal to one is dropped. */
|
|
55
|
+
function identityValues(row) {
|
|
56
|
+
const values = new Set();
|
|
57
|
+
for (const column of IDENTITY_COLUMNS) {
|
|
58
|
+
const value = row[column];
|
|
59
|
+
if (typeof value === "string" && value.trim())
|
|
60
|
+
values.add(value.trim());
|
|
61
|
+
}
|
|
62
|
+
return values;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Clean one metadata value. A string that is a path or equals a row identity
|
|
66
|
+
* value returns `undefined`, which drops its key; every other string is
|
|
67
|
+
* scrubbed. Objects and arrays are cleaned recursively.
|
|
68
|
+
*/
|
|
69
|
+
function cleanMetadataValue(value, identities, counts) {
|
|
70
|
+
if (typeof value === "string") {
|
|
71
|
+
const trimmed = value.trim();
|
|
72
|
+
if (identities.has(trimmed) || isPathValue(trimmed))
|
|
73
|
+
return undefined;
|
|
74
|
+
return scrub(value, counts);
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
if (value.length === 0)
|
|
78
|
+
return value;
|
|
79
|
+
const items = value
|
|
80
|
+
.map((item) => cleanMetadataValue(item, identities, counts))
|
|
81
|
+
.filter((item) => item !== undefined);
|
|
82
|
+
return items.length > 0 ? items : undefined;
|
|
83
|
+
}
|
|
84
|
+
if (value !== null && typeof value === "object") {
|
|
85
|
+
const cleaned = {};
|
|
86
|
+
for (const [key, child] of Object.entries(value)) {
|
|
87
|
+
const result = cleanMetadataValue(child, identities, counts);
|
|
88
|
+
if (result !== undefined)
|
|
89
|
+
cleaned[key] = result;
|
|
90
|
+
}
|
|
91
|
+
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
/** Clean the metadata JSON of one row; `undefined` when nothing survives. */
|
|
96
|
+
function cleanMetadata(raw, row, counts) {
|
|
97
|
+
if (!raw || typeof raw !== "string")
|
|
98
|
+
return undefined;
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(raw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
107
|
+
return undefined;
|
|
108
|
+
const cleaned = cleanMetadataValue(parsed, identityValues(row), counts);
|
|
109
|
+
if (cleaned === undefined || typeof cleaned !== "object")
|
|
110
|
+
return undefined;
|
|
111
|
+
return cleaned;
|
|
112
|
+
}
|
|
113
|
+
/** Split and scrub the comma-joined tags column; `undefined` when nothing survives. */
|
|
114
|
+
function cleanTags(raw, counts) {
|
|
115
|
+
if (!raw || typeof raw !== "string")
|
|
116
|
+
return undefined;
|
|
117
|
+
const tags = raw
|
|
118
|
+
.split(",")
|
|
119
|
+
.map((tag) => scrub(tag.trim(), counts))
|
|
120
|
+
.filter(Boolean);
|
|
121
|
+
return tags.length > 0 ? tags : undefined;
|
|
122
|
+
}
|
|
123
|
+
/** Read every memory of one scope that passes the date range. */
|
|
124
|
+
async function readScopeMemories(store, scope, options, counts) {
|
|
125
|
+
const memories = [];
|
|
126
|
+
for (const shard of await store.listShards(scope, "")) {
|
|
127
|
+
if (!existsSync(shard.dbPath))
|
|
128
|
+
continue;
|
|
129
|
+
const db = await store.openShard(shard.dbPath);
|
|
130
|
+
const rows = await db.all(`SELECT ${MEMORY_COLUMNS} FROM memories`);
|
|
131
|
+
for (const row of rows) {
|
|
132
|
+
const content = String(row.content ?? "");
|
|
133
|
+
if (isFullyPrivate(content)) {
|
|
134
|
+
counts.privateSkipped++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const createdAt = Number(row.created_at);
|
|
138
|
+
if (options.since !== undefined && createdAt < options.since)
|
|
139
|
+
continue;
|
|
140
|
+
if (options.until !== undefined && createdAt > options.until)
|
|
141
|
+
continue;
|
|
142
|
+
const scrubbedContent = scrub(content, counts);
|
|
143
|
+
if (!scrubbedContent.trim()) {
|
|
144
|
+
counts.privateSkipped++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const metadata = cleanMetadata(row.metadata, row, counts);
|
|
148
|
+
const tags = cleanTags(row.tags, counts);
|
|
149
|
+
memories.push({
|
|
150
|
+
id: String(row.id),
|
|
151
|
+
content: scrubbedContent,
|
|
152
|
+
scope,
|
|
153
|
+
...(scope === "project" ? { projectTag: String(row.container_tag) } : {}),
|
|
154
|
+
...(row.type ? { type: String(row.type) } : {}),
|
|
155
|
+
...(tags ? { tags } : {}),
|
|
156
|
+
createdAt,
|
|
157
|
+
updatedAt: Number(row.updated_at ?? createdAt),
|
|
158
|
+
isPinned: Number(row.is_pinned ?? 0) === 1,
|
|
159
|
+
...(metadata ? { metadata } : {}),
|
|
160
|
+
});
|
|
161
|
+
if (scope === "project")
|
|
162
|
+
counts.projectMemories++;
|
|
163
|
+
else
|
|
164
|
+
counts.userMemories++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return memories;
|
|
168
|
+
}
|
|
169
|
+
/** Map one learned item onto the export shape: no centroid, anchor or sampling fields. */
|
|
170
|
+
function mapProfileItem(item, counts, withSteps) {
|
|
171
|
+
if (item === null || typeof item !== "object")
|
|
172
|
+
return undefined;
|
|
173
|
+
const source = item;
|
|
174
|
+
const description = scrub(String(source.description ?? ""), counts);
|
|
175
|
+
if (!description.trim())
|
|
176
|
+
return undefined;
|
|
177
|
+
return {
|
|
178
|
+
...(source.category !== undefined ? { category: scrub(String(source.category), counts) } : {}),
|
|
179
|
+
description,
|
|
180
|
+
...(withSteps && Array.isArray(source.steps)
|
|
181
|
+
? { steps: source.steps.map((step) => scrub(String(step), counts)) }
|
|
182
|
+
: {}),
|
|
183
|
+
confidence: Number(source.confidence ?? 0),
|
|
184
|
+
frequency: Number(source.frequency ?? 0),
|
|
185
|
+
evidence: Array.isArray(source.evidence)
|
|
186
|
+
? source.evidence.map((entry) => scrub(String(entry), counts))
|
|
187
|
+
: [],
|
|
188
|
+
lastSeen: Number(source.lastSeen ?? 0),
|
|
189
|
+
...(source.firstSeen !== undefined ? { firstSeen: Number(source.firstSeen) } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** Map every learned item of one kind, dropping anything without a description. */
|
|
193
|
+
function mapProfileItems(items, counts, withSteps) {
|
|
194
|
+
if (!Array.isArray(items))
|
|
195
|
+
return [];
|
|
196
|
+
return items
|
|
197
|
+
.map((item) => mapProfileItem(item, counts, withSteps))
|
|
198
|
+
.filter((item) => item !== undefined);
|
|
199
|
+
}
|
|
200
|
+
/** Map one learning path: three scrubbed strings, nothing else. */
|
|
201
|
+
function mapLearningPath(path, counts) {
|
|
202
|
+
if (path === null || typeof path !== "object")
|
|
203
|
+
return undefined;
|
|
204
|
+
const source = path;
|
|
205
|
+
const topic = scrub(String(source.topic ?? ""), counts);
|
|
206
|
+
const description = scrub(String(source.description ?? ""), counts);
|
|
207
|
+
if (!topic.trim() || !description.trim())
|
|
208
|
+
return undefined;
|
|
209
|
+
return {
|
|
210
|
+
topic,
|
|
211
|
+
chain: Array.isArray(source.chain)
|
|
212
|
+
? source.chain.map((step) => scrub(String(step), counts))
|
|
213
|
+
: [],
|
|
214
|
+
description,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Read the newest active profile of the space folder, read-only. The identity
|
|
219
|
+
* columns of the profile row (user id, names, email) are never read, so they
|
|
220
|
+
* cannot reach the file.
|
|
221
|
+
*/
|
|
222
|
+
async function readProfile(store, counts) {
|
|
223
|
+
const dbPath = join(store.folder, PROFILE_DB_NAME);
|
|
224
|
+
if (!existsSync(dbPath))
|
|
225
|
+
return undefined;
|
|
226
|
+
const db = await store.openShard(dbPath);
|
|
227
|
+
const table = await db.get(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'user_profiles'`);
|
|
228
|
+
if (!table)
|
|
229
|
+
return undefined;
|
|
230
|
+
const row = await db.get(`SELECT profile_data, version, last_analyzed_at, total_prompts_analyzed
|
|
231
|
+
FROM user_profiles WHERE is_active = 1 ORDER BY last_analyzed_at DESC LIMIT 1`);
|
|
232
|
+
if (!row)
|
|
233
|
+
return undefined;
|
|
234
|
+
let data;
|
|
235
|
+
try {
|
|
236
|
+
data = JSON.parse(String(row.profile_data ?? "{}"));
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
const source = (data ?? {});
|
|
242
|
+
const preferences = mapProfileItems(source.preferences, counts, false);
|
|
243
|
+
const patterns = mapProfileItems(source.patterns, counts, false);
|
|
244
|
+
const workflows = mapProfileItems(source.workflows, counts, true);
|
|
245
|
+
counts.profileItems = preferences.length + patterns.length + workflows.length;
|
|
246
|
+
const learningPaths = (Array.isArray(source.learning_paths) ? source.learning_paths : [])
|
|
247
|
+
.map((path) => mapLearningPath(path, counts))
|
|
248
|
+
.filter((path) => path !== undefined);
|
|
249
|
+
return {
|
|
250
|
+
preferences,
|
|
251
|
+
patterns,
|
|
252
|
+
workflows,
|
|
253
|
+
...(learningPaths.length > 0 ? { learningPaths } : {}),
|
|
254
|
+
version: Number(row.version ?? 1),
|
|
255
|
+
lastAnalyzedAt: Number(row.last_analyzed_at ?? 0),
|
|
256
|
+
totalPromptsAnalyzed: Number(row.total_prompts_analyzed ?? 0),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/** Home-folder shapes of the scrubber, re-checked on the finished file. */
|
|
260
|
+
const SCAN_POSIX_HOME = /(?<![\w~])\/(?:Users|home)\//;
|
|
261
|
+
const SCAN_WINDOWS_HOME = /(?<![\w~])[a-z]:[\\/]users[\\/]/i;
|
|
262
|
+
/** Wider than the scrubber's email rule: catches quoted local parts and the like. */
|
|
263
|
+
const SCAN_EMAIL = /\S*@\S+\.\S+/;
|
|
264
|
+
function escapeRegExp(text) {
|
|
265
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The final safety scan (design D7): fail the export when a home-folder path
|
|
269
|
+
* or an email address survived the per-string scrubbing. System paths and
|
|
270
|
+
* URLs are deliberate non-matches, because memories often quote them.
|
|
271
|
+
*/
|
|
272
|
+
export function scanExportContent(serialised, homePath) {
|
|
273
|
+
const findings = [];
|
|
274
|
+
const home = homePath.replace(/[\\/]+$/, "");
|
|
275
|
+
if (home && new RegExp(`${escapeRegExp(home)}(?![\\w])`).test(serialised)) {
|
|
276
|
+
findings.push("the home folder path of the exporting user");
|
|
277
|
+
}
|
|
278
|
+
if (SCAN_POSIX_HOME.test(serialised))
|
|
279
|
+
findings.push("a POSIX home-folder path");
|
|
280
|
+
if (SCAN_WINDOWS_HOME.test(serialised))
|
|
281
|
+
findings.push("a Windows home-folder path");
|
|
282
|
+
if (SCAN_EMAIL.test(serialised))
|
|
283
|
+
findings.push("an email address");
|
|
284
|
+
if (findings.length > 0) {
|
|
285
|
+
throw new Error(`Space export identifier scan failed: ${findings.join("; ")} survived the scrubber, so no file was written.`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** Write atomically: a temporary file first, then one rename into place. */
|
|
289
|
+
function writeAtomic(outPath, serialised) {
|
|
290
|
+
const target = resolve(outPath);
|
|
291
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
292
|
+
const tmpPath = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
293
|
+
try {
|
|
294
|
+
writeFileSync(tmpPath, serialised, "utf-8");
|
|
295
|
+
renameSync(tmpPath, target);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (existsSync(tmpPath)) {
|
|
299
|
+
try {
|
|
300
|
+
unlinkSync(tmpPath);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// Best-effort cleanup; the failure below is the real error.
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Export one space to a JSON file (design D7). Reads the space through a
|
|
311
|
+
* read-only store, so the space is never changed. Throws when the final
|
|
312
|
+
* identifier scan fails; returns `written: false` for an empty selection.
|
|
313
|
+
*/
|
|
314
|
+
export async function exportSpace(options) {
|
|
315
|
+
const counts = {
|
|
316
|
+
projectMemories: 0,
|
|
317
|
+
userMemories: 0,
|
|
318
|
+
privateSkipped: 0,
|
|
319
|
+
profileItems: 0,
|
|
320
|
+
pathsReplaced: 0,
|
|
321
|
+
emailsReplaced: 0,
|
|
322
|
+
};
|
|
323
|
+
const warnings = [FREE_TEXT_WARNING];
|
|
324
|
+
const store = openSpaceStore(options.folder, "read");
|
|
325
|
+
let memories;
|
|
326
|
+
let profile;
|
|
327
|
+
try {
|
|
328
|
+
memories = await readScopeMemories(store, "project", options, counts);
|
|
329
|
+
if (options.includeProfile) {
|
|
330
|
+
memories.push(...(await readScopeMemories(store, "user", options, counts)));
|
|
331
|
+
profile = await readProfile(store, counts);
|
|
332
|
+
if (!profile)
|
|
333
|
+
warnings.push("The space holds no learned profile.");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
await store.close();
|
|
338
|
+
}
|
|
339
|
+
if (memories.length === 0) {
|
|
340
|
+
return {
|
|
341
|
+
written: false,
|
|
342
|
+
reason: "Nothing to export: the selection holds no memory.",
|
|
343
|
+
counts,
|
|
344
|
+
warnings,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
memories.sort((a, b) => b.createdAt - a.createdAt);
|
|
348
|
+
const document = {
|
|
349
|
+
format: SPACE_EXPORT_FORMAT,
|
|
350
|
+
schemaVersion: SPACE_EXPORT_SCHEMA_VERSION,
|
|
351
|
+
exportedAt: new Date().toISOString(),
|
|
352
|
+
ommsVersion: options.ommsVersion,
|
|
353
|
+
embedding: {
|
|
354
|
+
model: options.embeddingModel,
|
|
355
|
+
dimensions: options.embeddingDimensions,
|
|
356
|
+
},
|
|
357
|
+
memories,
|
|
358
|
+
...(profile ? { profile } : {}),
|
|
359
|
+
};
|
|
360
|
+
// Validate our own output before it reaches the disk, then run the final
|
|
361
|
+
// identifier scan on exactly the bytes that would be written.
|
|
362
|
+
SpaceExportDocumentSchema.parse(document);
|
|
363
|
+
const serialised = JSON.stringify(document, null, 2);
|
|
364
|
+
scanExportContent(serialised, options.homePath);
|
|
365
|
+
writeAtomic(options.outPath, serialised);
|
|
366
|
+
return { written: true, outputPath: resolve(options.outPath), counts, warnings };
|
|
367
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replacement counts for one scrubbed string, so an export can report how
|
|
3
|
+
* much it changed.
|
|
4
|
+
*/
|
|
5
|
+
export interface ScrubCounts {
|
|
6
|
+
/** Home-folder paths rewritten to the `~` form. */
|
|
7
|
+
readonly pathsReplaced: number;
|
|
8
|
+
/** Email addresses rewritten to `<email>`. */
|
|
9
|
+
readonly emailsReplaced: number;
|
|
10
|
+
}
|
|
11
|
+
/** The scrubbed string plus what the scrubber replaced in it. */
|
|
12
|
+
export interface ScrubResult {
|
|
13
|
+
readonly text: string;
|
|
14
|
+
readonly counts: ScrubCounts;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Replace the parts of a string that identify a person: home-folder paths
|
|
18
|
+
* become `~` plus the rest of the path, email addresses become `<email>`,
|
|
19
|
+
* and `<private>` regions go first (via `stripPrivateContent`), so anything
|
|
20
|
+
* inside them is neither kept nor counted.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately narrow: system paths (`/usr/local`) and URLs stay, because
|
|
23
|
+
* memories often quote them. The exporter scans the finished file and fails
|
|
24
|
+
* the export when an identifier survives anyway.
|
|
25
|
+
*/
|
|
26
|
+
export declare function scrubIdentifiers(text: string): ScrubResult;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { stripPrivateContent } from "../privacy.js";
|
|
2
|
+
// Characters that end a path token in free text. A path cut at one of them
|
|
3
|
+
// still loses its user name, which is the part that identifies a person.
|
|
4
|
+
const STOP = "\\s\"'`<>()[\\]{},;:!?";
|
|
5
|
+
// `/Users/<name>/...` (macOS) and `/home/<name>/...` (Linux). The lookbehind
|
|
6
|
+
// keeps the path part of a URL (`example.com/home/jo`) out of the match.
|
|
7
|
+
const POSIX_HOME = new RegExp(`(?<!\\w)\\/(?:Users|home)\\/([^/${STOP}]+)([^${STOP}]*)`, "g");
|
|
8
|
+
// `<drive>:\Users\<name>\...` (Windows), any drive letter, either separator.
|
|
9
|
+
const WINDOWS_HOME = new RegExp(`(?<!\\w)[a-z]:[\\\\/]users[\\\\/]([^\\\\/${STOP}]+)([^${STOP}]*)`, "gi");
|
|
10
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
11
|
+
/**
|
|
12
|
+
* Replace the parts of a string that identify a person: home-folder paths
|
|
13
|
+
* become `~` plus the rest of the path, email addresses become `<email>`,
|
|
14
|
+
* and `<private>` regions go first (via `stripPrivateContent`), so anything
|
|
15
|
+
* inside them is neither kept nor counted.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately narrow: system paths (`/usr/local`) and URLs stay, because
|
|
18
|
+
* memories often quote them. The exporter scans the finished file and fails
|
|
19
|
+
* the export when an identifier survives anyway.
|
|
20
|
+
*/
|
|
21
|
+
export function scrubIdentifiers(text) {
|
|
22
|
+
const withoutPrivate = stripPrivateContent(text);
|
|
23
|
+
// Windows first: its forward-slash form ends in `/Users/...`, which the
|
|
24
|
+
// POSIX pass would otherwise rewrite and leave a stray `C:~` behind.
|
|
25
|
+
let pathsReplaced = 0;
|
|
26
|
+
const toTilde = (_match, _name, rest) => {
|
|
27
|
+
pathsReplaced++;
|
|
28
|
+
return `~${rest}`;
|
|
29
|
+
};
|
|
30
|
+
const afterPaths = withoutPrivate.replace(WINDOWS_HOME, toTilde).replace(POSIX_HOME, toTilde);
|
|
31
|
+
let emailsReplaced = 0;
|
|
32
|
+
const scrubbed = afterPaths.replace(EMAIL, () => {
|
|
33
|
+
emailsReplaced++;
|
|
34
|
+
return "<email>";
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
text: scrubbed,
|
|
38
|
+
counts: { pathsReplaced, emailsReplaced },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type SpaceExportDocument } from "../../shared/api/space-export-schemas.js";
|
|
2
|
+
import { type SpaceFiles } from "./registry-store.js";
|
|
3
|
+
/** The import size cap of the spec: a file larger than this is refused. */
|
|
4
|
+
export declare const IMPORT_MAX_FILE_BYTES: number;
|
|
5
|
+
/** The embedding settings of the importing machine; `CONFIG` satisfies this. */
|
|
6
|
+
export interface ImportEmbeddingSettings {
|
|
7
|
+
model: string;
|
|
8
|
+
dimensions: number;
|
|
9
|
+
}
|
|
10
|
+
/** The importing user: user-scope memories take `userTag`. */
|
|
11
|
+
export interface ImportingUser {
|
|
12
|
+
/** The importing user's container tag, `omms_user_<hash>`. */
|
|
13
|
+
userTag: string;
|
|
14
|
+
displayName?: string;
|
|
15
|
+
userName?: string;
|
|
16
|
+
userEmail?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Embeds one text with the current model; `EmbeddingService.embed` satisfies this. */
|
|
19
|
+
export type ImportEmbedder = (text: string) => Promise<Float32Array>;
|
|
20
|
+
/** Everything the importer needs; nothing is read from CONFIG here. */
|
|
21
|
+
export interface ImportSpaceOptions {
|
|
22
|
+
/** Path of the export file. Give this or `document`. */
|
|
23
|
+
filePath?: string;
|
|
24
|
+
/** A parsed export document, as the web upload check route passes it. */
|
|
25
|
+
document?: unknown;
|
|
26
|
+
/** The name of the new space. A taken name is refused. */
|
|
27
|
+
spaceName: string;
|
|
28
|
+
/** Registry and folders of the OMMS store, normally `spaceFiles(ommsHomeDir())`. */
|
|
29
|
+
files: SpaceFiles;
|
|
30
|
+
/** Optional project folder: every project memory takes that folder's tag. */
|
|
31
|
+
retagToProject?: string;
|
|
32
|
+
embedding: ImportEmbeddingSettings;
|
|
33
|
+
embed: ImportEmbedder;
|
|
34
|
+
user: ImportingUser;
|
|
35
|
+
/** Size cap for `filePath`; defaults to {@link IMPORT_MAX_FILE_BYTES}. */
|
|
36
|
+
maxFileBytes?: number;
|
|
37
|
+
}
|
|
38
|
+
/** Totals of one import, for the CLI and web dialogs to report. */
|
|
39
|
+
export interface ImportSpaceCounts {
|
|
40
|
+
projectMemories: number;
|
|
41
|
+
userMemories: number;
|
|
42
|
+
/** Profile items written through the rooted profile manager. */
|
|
43
|
+
profileItems: number;
|
|
44
|
+
/** Distinct project tags in the file: what a `retagToProject` merges into one. */
|
|
45
|
+
fileProjects: number;
|
|
46
|
+
}
|
|
47
|
+
export interface ImportSpaceResult {
|
|
48
|
+
ok: boolean;
|
|
49
|
+
/** Why the import was refused or failed; set only when `ok` is false. */
|
|
50
|
+
reason?: string;
|
|
51
|
+
/** Id of the created space; set only when `ok` is true. */
|
|
52
|
+
spaceId?: string;
|
|
53
|
+
spaceName?: string;
|
|
54
|
+
/** The created space folder; set only when `ok` is true. */
|
|
55
|
+
folder?: string;
|
|
56
|
+
counts: ImportSpaceCounts;
|
|
57
|
+
/** Human-facing notes, never blocking. The model warning comes first. */
|
|
58
|
+
warnings: string[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The model and dimension warning of the spec: names the file's settings and
|
|
62
|
+
* the current ones, and never blocks. Undefined when both match.
|
|
63
|
+
*/
|
|
64
|
+
export declare function embeddingWarning(file: ImportEmbeddingSettings, current: ImportEmbeddingSettings): string | undefined;
|
|
65
|
+
export type ValidatedImport = {
|
|
66
|
+
ok: false;
|
|
67
|
+
reason: string;
|
|
68
|
+
} | {
|
|
69
|
+
ok: true;
|
|
70
|
+
document: SpaceExportDocument;
|
|
71
|
+
counts: ImportSpaceCounts;
|
|
72
|
+
warning?: string;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Validates one parsed export document against the spec rules: the schema
|
|
76
|
+
* version, the schema itself, duplicate ids and the shape of every container
|
|
77
|
+
* tag. Pure, so the web check route can run it without creating a space.
|
|
78
|
+
*/
|
|
79
|
+
export declare function validateSpaceImportDocument(raw: unknown, embedding: ImportEmbeddingSettings, userTag: string): ValidatedImport;
|
|
80
|
+
/**
|
|
81
|
+
* Imports one export file into a new, user-named space (design D7). Every
|
|
82
|
+
* refusal runs before any space exists; a failure after the space exists
|
|
83
|
+
* removes the space and its folder. The active space never changes.
|
|
84
|
+
*/
|
|
85
|
+
export declare function importSpace(options: ImportSpaceOptions): Promise<ImportSpaceResult>;
|