om-memory-system 5.5.0 → 5.6.0-next.216
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,349 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { extractScopeFromContainerTag } from "../memory-scope.js";
|
|
4
|
+
import { getProjectTagInfo } from "../tags.js";
|
|
5
|
+
import { tursoVectorSearch } from "../turso/vector-search.js";
|
|
6
|
+
import { formatTagsForEmbedding } from "../turso/vector-utils.js";
|
|
7
|
+
import { UserProfileManager } from "../user-profile/user-profile-manager.js";
|
|
8
|
+
import { SPACE_EXPORT_SCHEMA_VERSION, SpaceExportDocumentSchema, } from "../../shared/api/space-export-schemas.js";
|
|
9
|
+
import { createStoredSpace, removeStoredSpace } from "./registry-store.js";
|
|
10
|
+
import { openSpaceStore } from "./space-store.js";
|
|
11
|
+
/** The import size cap of the spec: a file larger than this is refused. */
|
|
12
|
+
export const IMPORT_MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
13
|
+
/** How many memories are embedded per round trip to the embedder. */
|
|
14
|
+
const EMBED_BATCH_SIZE = 32;
|
|
15
|
+
/** Matches `hasEnoughWorkflowSteps` in core/extraction.ts: three filled steps. */
|
|
16
|
+
const MIN_WORKFLOW_STEPS = 3;
|
|
17
|
+
function emptyCounts() {
|
|
18
|
+
return { projectMemories: 0, userMemories: 0, profileItems: 0, fileProjects: 0 };
|
|
19
|
+
}
|
|
20
|
+
function refusedImport(reason, warnings = []) {
|
|
21
|
+
return { ok: false, reason, counts: emptyCounts(), warnings };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The model and dimension warning of the spec: names the file's settings and
|
|
25
|
+
* the current ones, and never blocks. Undefined when both match.
|
|
26
|
+
*/
|
|
27
|
+
export function embeddingWarning(file, current) {
|
|
28
|
+
if (file.model === current.model && file.dimensions === current.dimensions)
|
|
29
|
+
return undefined;
|
|
30
|
+
return (`The file was embedded with ${file.model} at ${file.dimensions} dimensions; ` +
|
|
31
|
+
`this OMMS uses ${current.model} at ${current.dimensions}. ` +
|
|
32
|
+
`Import will embed every memory again with the current model.`);
|
|
33
|
+
}
|
|
34
|
+
function formatIssues(error) {
|
|
35
|
+
return error.issues
|
|
36
|
+
.slice(0, 5)
|
|
37
|
+
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
38
|
+
.join("; ");
|
|
39
|
+
}
|
|
40
|
+
/** How many profile items the manager will keep: a workflow needs three filled steps. */
|
|
41
|
+
function keptProfileItems(document) {
|
|
42
|
+
const profile = document.profile;
|
|
43
|
+
if (!profile)
|
|
44
|
+
return 0;
|
|
45
|
+
const keptWorkflows = profile.workflows.filter((item) => (item.steps ?? []).filter((step) => step.trim().length > 0).length >= MIN_WORKFLOW_STEPS).length;
|
|
46
|
+
return profile.preferences.length + profile.patterns.length + keptWorkflows;
|
|
47
|
+
}
|
|
48
|
+
function countDocument(document) {
|
|
49
|
+
const projectTags = new Set();
|
|
50
|
+
let projectMemories = 0;
|
|
51
|
+
let userMemories = 0;
|
|
52
|
+
for (const memory of document.memories) {
|
|
53
|
+
if (memory.scope === "project") {
|
|
54
|
+
projectMemories++;
|
|
55
|
+
if (memory.projectTag)
|
|
56
|
+
projectTags.add(memory.projectTag);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
userMemories++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
projectMemories,
|
|
64
|
+
userMemories,
|
|
65
|
+
profileItems: keptProfileItems(document),
|
|
66
|
+
fileProjects: projectTags.size,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Validates one parsed export document against the spec rules: the schema
|
|
71
|
+
* version, the schema itself, duplicate ids and the shape of every container
|
|
72
|
+
* tag. Pure, so the web check route can run it without creating a space.
|
|
73
|
+
*/
|
|
74
|
+
export function validateSpaceImportDocument(raw, embedding, userTag) {
|
|
75
|
+
if (raw === null || typeof raw !== "object") {
|
|
76
|
+
return { ok: false, reason: "The file is not a space export document." };
|
|
77
|
+
}
|
|
78
|
+
const envelope = raw;
|
|
79
|
+
if (typeof envelope.schemaVersion === "number" &&
|
|
80
|
+
envelope.schemaVersion > SPACE_EXPORT_SCHEMA_VERSION) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
reason: `The file uses schema version ${envelope.schemaVersion}, which this OMMS does not know. ` +
|
|
84
|
+
`Upgrade OMMS, then import the file again.`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const parsed = SpaceExportDocumentSchema.safeParse(raw);
|
|
88
|
+
if (!parsed.success) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
reason: `The file does not match the space export schema: ${formatIssues(parsed.error)}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const document = parsed.data;
|
|
95
|
+
const ids = new Set();
|
|
96
|
+
for (const memory of document.memories) {
|
|
97
|
+
if (ids.has(memory.id)) {
|
|
98
|
+
return { ok: false, reason: `The file holds a duplicate memory id: ${memory.id}` };
|
|
99
|
+
}
|
|
100
|
+
ids.add(memory.id);
|
|
101
|
+
}
|
|
102
|
+
// Every container tag must give a scope and hash, or the write would fail
|
|
103
|
+
// after the space exists. Checked here, so a bad file creates no space.
|
|
104
|
+
const tags = new Set([userTag]);
|
|
105
|
+
for (const memory of document.memories) {
|
|
106
|
+
if (memory.scope === "project" && memory.projectTag)
|
|
107
|
+
tags.add(memory.projectTag);
|
|
108
|
+
}
|
|
109
|
+
for (const tag of tags) {
|
|
110
|
+
try {
|
|
111
|
+
extractScopeFromContainerTag(tag);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const counts = countDocument(document);
|
|
118
|
+
const warning = embeddingWarning(document.embedding, embedding);
|
|
119
|
+
return { ok: true, document, counts, ...(warning ? { warning } : {}) };
|
|
120
|
+
}
|
|
121
|
+
/** Reads and parses the export file: refused when missing, oversized or not JSON. */
|
|
122
|
+
function readImportFile(filePath, maxFileBytes) {
|
|
123
|
+
let size;
|
|
124
|
+
try {
|
|
125
|
+
size = statSync(filePath).size;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return { ok: false, reason: `Cannot read the import file: ${filePath}` };
|
|
129
|
+
}
|
|
130
|
+
if (size > maxFileBytes) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: `The file is ${size} bytes, larger than the import limit of ${maxFileBytes} bytes.`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
return { ok: true, raw: JSON.parse(readFileSync(filePath, "utf-8")) };
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
reason: `The file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function requireDimensions(vector, dimensions, memoryId) {
|
|
147
|
+
if (vector.length !== dimensions) {
|
|
148
|
+
throw new Error(`The embedder returned ${vector.length} dimensions for memory ${memoryId}, ` +
|
|
149
|
+
`but the current settings use ${dimensions}.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Embeds every memory with the current model, in batches, then writes per scope. */
|
|
153
|
+
async function writeImportedMemories(store, document, options, retagTag) {
|
|
154
|
+
const groups = new Map();
|
|
155
|
+
let projectMemories = 0;
|
|
156
|
+
let userMemories = 0;
|
|
157
|
+
for (const memory of document.memories) {
|
|
158
|
+
const containerTag = memory.scope === "user" ? options.user.userTag : (retagTag ?? memory.projectTag);
|
|
159
|
+
const { scope, hash } = extractScopeFromContainerTag(containerTag);
|
|
160
|
+
const group = groups.get(`${scope}:${hash}`) ?? [];
|
|
161
|
+
group.push({ memory, containerTag, scope, hash });
|
|
162
|
+
groups.set(`${scope}:${hash}`, group);
|
|
163
|
+
if (memory.scope === "project")
|
|
164
|
+
projectMemories++;
|
|
165
|
+
else
|
|
166
|
+
userMemories++;
|
|
167
|
+
}
|
|
168
|
+
// The file holds no vectors, so every memory is embedded again (design D7).
|
|
169
|
+
const vectors = new Map();
|
|
170
|
+
for (let start = 0; start < document.memories.length; start += EMBED_BATCH_SIZE) {
|
|
171
|
+
const batch = document.memories.slice(start, start + EMBED_BATCH_SIZE);
|
|
172
|
+
const embedded = await Promise.all(batch.map(async (memory) => {
|
|
173
|
+
const vector = await options.embed(memory.content);
|
|
174
|
+
requireDimensions(vector, options.embedding.dimensions, memory.id);
|
|
175
|
+
let tagsVector;
|
|
176
|
+
if (memory.tags && memory.tags.length > 0) {
|
|
177
|
+
tagsVector = await options.embed(formatTagsForEmbedding(memory.tags));
|
|
178
|
+
requireDimensions(tagsVector, options.embedding.dimensions, memory.id);
|
|
179
|
+
}
|
|
180
|
+
return { vector, ...(tagsVector ? { tagsVector } : {}) };
|
|
181
|
+
}));
|
|
182
|
+
batch.forEach((memory, index) => vectors.set(memory.id, embedded[index]));
|
|
183
|
+
}
|
|
184
|
+
for (const group of groups.values()) {
|
|
185
|
+
const { scope, hash } = group[0];
|
|
186
|
+
await store.shards.withScopeWriteLock(scope, hash, async () => {
|
|
187
|
+
const shard = await store.shards.getWriteShard(scope, hash);
|
|
188
|
+
const db = await store.connections.getConnection(shard.dbPath);
|
|
189
|
+
for (const item of group) {
|
|
190
|
+
const embedded = vectors.get(item.memory.id);
|
|
191
|
+
await tursoVectorSearch.insertVector(db, {
|
|
192
|
+
id: item.memory.id,
|
|
193
|
+
content: item.memory.content,
|
|
194
|
+
vector: embedded.vector,
|
|
195
|
+
...(embedded.tagsVector ? { tagsVector: embedded.tagsVector } : {}),
|
|
196
|
+
containerTag: item.containerTag,
|
|
197
|
+
...(item.memory.tags && item.memory.tags.length > 0
|
|
198
|
+
? { tags: item.memory.tags.join(",") }
|
|
199
|
+
: {}),
|
|
200
|
+
...(item.memory.type ? { type: item.memory.type } : {}),
|
|
201
|
+
createdAt: item.memory.createdAt,
|
|
202
|
+
updatedAt: item.memory.updatedAt,
|
|
203
|
+
...(item.memory.metadata && Object.keys(item.memory.metadata).length > 0
|
|
204
|
+
? { metadata: JSON.stringify(item.memory.metadata) }
|
|
205
|
+
: {}),
|
|
206
|
+
});
|
|
207
|
+
if (item.memory.isPinned) {
|
|
208
|
+
await db.run(`UPDATE memories SET is_pinned = 1 WHERE id = ?`, [item.memory.id]);
|
|
209
|
+
}
|
|
210
|
+
await store.shards.incrementVectorCount(shard.id);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return { projectMemories, userMemories };
|
|
215
|
+
}
|
|
216
|
+
/** Maps one export item onto a preference or pattern; a missing category becomes "General". */
|
|
217
|
+
function toProfileItem(item) {
|
|
218
|
+
return {
|
|
219
|
+
category: item.category?.trim() || "General",
|
|
220
|
+
description: item.description,
|
|
221
|
+
confidence: item.confidence,
|
|
222
|
+
frequency: item.frequency,
|
|
223
|
+
evidence: item.evidence,
|
|
224
|
+
lastSeen: item.lastSeen,
|
|
225
|
+
...(item.firstSeen !== undefined ? { firstSeen: item.firstSeen } : {}),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Maps one export item onto a workflow; the manager drops too-short step lists. */
|
|
229
|
+
function toWorkflow(item) {
|
|
230
|
+
return {
|
|
231
|
+
description: item.description,
|
|
232
|
+
steps: item.steps ?? [],
|
|
233
|
+
confidence: item.confidence,
|
|
234
|
+
frequency: item.frequency,
|
|
235
|
+
evidence: item.evidence,
|
|
236
|
+
lastSeen: item.lastSeen,
|
|
237
|
+
...(item.firstSeen !== undefined ? { firstSeen: item.firstSeen } : {}),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Writes the file's profile through a manager rooted at the new folder, with
|
|
242
|
+
* the write store's connections, so one close releases every handle (design D1).
|
|
243
|
+
* Items keep every field the exporter kept; the manager drops workflows with
|
|
244
|
+
* too few steps, as it does on its own save path.
|
|
245
|
+
*/
|
|
246
|
+
async function writeImportedProfile(store, folder, document, user) {
|
|
247
|
+
const profile = document.profile;
|
|
248
|
+
if (!profile)
|
|
249
|
+
return 0;
|
|
250
|
+
const data = {
|
|
251
|
+
preferences: profile.preferences.map(toProfileItem),
|
|
252
|
+
patterns: profile.patterns.map(toProfileItem),
|
|
253
|
+
workflows: profile.workflows.map(toWorkflow),
|
|
254
|
+
...(profile.learningPaths ? { learning_paths: profile.learningPaths } : {}),
|
|
255
|
+
};
|
|
256
|
+
const manager = new UserProfileManager(folder, store.connections);
|
|
257
|
+
const userId = user.userEmail || user.userName || user.userTag;
|
|
258
|
+
await manager.createProfile(userId, user.displayName || userId, user.userName || userId, user.userEmail || userId, data, profile.totalPromptsAnalyzed);
|
|
259
|
+
return data.preferences.length + data.patterns.length + data.workflows.length;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Removes a half-created import space. The registry entry goes first: it is the
|
|
263
|
+
* source of truth for which spaces exist, the new space was never active, and
|
|
264
|
+
* an orphan folder is inert, while a registry entry without a folder would read
|
|
265
|
+
* as a broken space. The caller has already closed the store, so no handle
|
|
266
|
+
* holds the folder (Windows-safe removal).
|
|
267
|
+
*/
|
|
268
|
+
async function removeFailedSpace(files, folder, spaceId, cause) {
|
|
269
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
270
|
+
try {
|
|
271
|
+
await removeStoredSpace(files, spaceId);
|
|
272
|
+
rmSync(folder, { recursive: true, force: true });
|
|
273
|
+
}
|
|
274
|
+
catch (cleanupError) {
|
|
275
|
+
const extra = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
276
|
+
return (`Import failed: ${message}. Removing the new space failed too (${extra}). ` +
|
|
277
|
+
`Delete the space "${spaceId}" by hand.`);
|
|
278
|
+
}
|
|
279
|
+
return `Import failed and the new space "${spaceId}" was removed: ${message}`;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Imports one export file into a new, user-named space (design D7). Every
|
|
283
|
+
* refusal runs before any space exists; a failure after the space exists
|
|
284
|
+
* removes the space and its folder. The active space never changes.
|
|
285
|
+
*/
|
|
286
|
+
export async function importSpace(options) {
|
|
287
|
+
const maxFileBytes = options.maxFileBytes ?? IMPORT_MAX_FILE_BYTES;
|
|
288
|
+
let raw = options.document;
|
|
289
|
+
if (raw === undefined) {
|
|
290
|
+
if (!options.filePath) {
|
|
291
|
+
return refusedImport("No import file was given.");
|
|
292
|
+
}
|
|
293
|
+
const read = readImportFile(options.filePath, maxFileBytes);
|
|
294
|
+
if (!read.ok)
|
|
295
|
+
return refusedImport(read.reason);
|
|
296
|
+
raw = read.raw;
|
|
297
|
+
}
|
|
298
|
+
const validated = validateSpaceImportDocument(raw, options.embedding, options.user.userTag);
|
|
299
|
+
if (!validated.ok)
|
|
300
|
+
return refusedImport(validated.reason);
|
|
301
|
+
const warnings = [];
|
|
302
|
+
if (validated.warning)
|
|
303
|
+
warnings.push(validated.warning);
|
|
304
|
+
// The retag folder must exist before any space is created (spec).
|
|
305
|
+
let retagTag;
|
|
306
|
+
if (options.retagToProject !== undefined) {
|
|
307
|
+
if (!existsSync(options.retagToProject)) {
|
|
308
|
+
return refusedImport(`The project folder "${options.retagToProject}" does not exist, so no space was created.`);
|
|
309
|
+
}
|
|
310
|
+
retagTag = getProjectTagInfo(options.retagToProject).tag;
|
|
311
|
+
}
|
|
312
|
+
let entry;
|
|
313
|
+
try {
|
|
314
|
+
entry = await createStoredSpace(options.files, options.spaceName);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
return refusedImport(error instanceof Error ? error.message : String(error), warnings);
|
|
318
|
+
}
|
|
319
|
+
const folder = join(options.files.spacesRoot, entry.id);
|
|
320
|
+
const store = openSpaceStore(folder, "write");
|
|
321
|
+
try {
|
|
322
|
+
const written = await writeImportedMemories(store, validated.document, options, retagTag);
|
|
323
|
+
const profileItems = await writeImportedProfile(store, folder, validated.document, options.user);
|
|
324
|
+
if (retagTag && validated.counts.fileProjects > 1) {
|
|
325
|
+
warnings.push(`The ${validated.counts.fileProjects} projects in the file were merged onto one project tag.`);
|
|
326
|
+
}
|
|
327
|
+
if (written.projectMemories > 0 && !retagTag) {
|
|
328
|
+
warnings.push("Project tags are machine-local: a project-scope search finds these memories only " +
|
|
329
|
+
"under a matching project identity. An all-projects search always finds them.");
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
ok: true,
|
|
333
|
+
spaceId: entry.id,
|
|
334
|
+
spaceName: entry.name,
|
|
335
|
+
folder,
|
|
336
|
+
counts: { ...written, profileItems, fileProjects: validated.counts.fileProjects },
|
|
337
|
+
warnings,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
// Release the handles first, then remove the space (see removeFailedSpace).
|
|
342
|
+
await store.close();
|
|
343
|
+
const reason = await removeFailedSpace(options.files, folder, entry.id, error);
|
|
344
|
+
return refusedImport(reason, warnings);
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
await store.close();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -1,14 +1,62 @@
|
|
|
1
1
|
import { type SpaceFiles } from "./registry-store.js";
|
|
2
|
+
import type { SpaceTransferContext } from "./transfer-service.js";
|
|
3
|
+
import type { ExportSpaceResult } from "./export-space.js";
|
|
4
|
+
import type { ImportSpaceResult } from "./import-space.js";
|
|
2
5
|
/**
|
|
3
|
-
* The space operations an agent or a terminal may run: list, status, create, archive,
|
|
4
|
-
*
|
|
5
|
-
* because a person must confirm them
|
|
6
|
-
* and
|
|
6
|
+
* The space operations an agent or a terminal may run: list, status, create, archive,
|
|
7
|
+
* unarchive, use (switch), preview, copy, link and unlink. Delete, rename, backup and
|
|
8
|
+
* restore exist only in the web app, because a person must confirm them, and so does a
|
|
9
|
+
* move; export and import belong to the web app and the CLI, not the `memory` tool. The
|
|
10
|
+
* CLI `space` command, the `memory` tool `space` mode and the Claude Code plugin tool
|
|
11
|
+
* all call this one function, so the hosts stay equal.
|
|
7
12
|
*/
|
|
8
13
|
export interface SpaceOperationArgs {
|
|
9
14
|
action?: string;
|
|
10
|
-
/** A space name or id. */
|
|
15
|
+
/** A space name or id: the space acted on, the transfer source, or the link target. */
|
|
11
16
|
name?: string;
|
|
17
|
+
/** The transfer target (preview, copy): a space name or id. */
|
|
18
|
+
to?: string;
|
|
19
|
+
/** Inclusive ISO 8601 lower bound on the creation time (preview, copy, export). */
|
|
20
|
+
since?: string;
|
|
21
|
+
/** Inclusive ISO 8601 upper bound on the creation time (preview, copy, export). */
|
|
22
|
+
until?: string;
|
|
23
|
+
/** Only memories with this project tag, as a preview's groups show it (preview, copy). */
|
|
24
|
+
project?: string;
|
|
25
|
+
/** The space that owns the link (link, unlink); the active space when omitted. */
|
|
26
|
+
owner?: string;
|
|
27
|
+
/** Where the export file is written (export). */
|
|
28
|
+
outPath?: string;
|
|
29
|
+
/** The export file to read (import). */
|
|
30
|
+
filePath?: string;
|
|
31
|
+
/** Opt-in that adds the personal memories and the learned profile (export). */
|
|
32
|
+
includeProfile?: boolean;
|
|
33
|
+
/** Gives every imported project memory this folder's project tag (import). */
|
|
34
|
+
retagToProject?: string;
|
|
35
|
+
}
|
|
36
|
+
/** Folder roots and embedding settings the space-bound actions need, snapshotted by the caller. */
|
|
37
|
+
export type SpaceOperationSettings = Omit<SpaceTransferContext, "files">;
|
|
38
|
+
/** The export call the shared operation makes; the caller's bridge adds the CONFIG values. */
|
|
39
|
+
export interface SpaceExportRequest {
|
|
40
|
+
folder: string;
|
|
41
|
+
outPath: string;
|
|
42
|
+
since?: number;
|
|
43
|
+
until?: number;
|
|
44
|
+
includeProfile?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** The import call the shared operation makes; the caller's bridge adds the CONFIG values. */
|
|
47
|
+
export interface SpaceImportRequest {
|
|
48
|
+
filePath: string;
|
|
49
|
+
spaceName: string;
|
|
50
|
+
retagToProject?: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The export and import calls of a caller allowed to run them (the CLI). The `memory`
|
|
54
|
+
* tool passes no bridge, so its `space` mode refuses both actions; the CLI supplies
|
|
55
|
+
* one, so the operations are defined in this module once for every surface.
|
|
56
|
+
*/
|
|
57
|
+
export interface SpacePortability {
|
|
58
|
+
export: (request: SpaceExportRequest) => Promise<ExportSpaceResult>;
|
|
59
|
+
import: (request: SpaceImportRequest) => Promise<ImportSpaceResult>;
|
|
12
60
|
}
|
|
13
61
|
export interface SpaceOperationContext {
|
|
14
62
|
files: SpaceFiles;
|
|
@@ -19,6 +67,10 @@ export interface SpaceOperationContext {
|
|
|
19
67
|
customStoragePath: boolean;
|
|
20
68
|
problem?: string;
|
|
21
69
|
};
|
|
70
|
+
/** Needed by preview, copy and export; the caller snapshots it from CONFIG. */
|
|
71
|
+
settings?: SpaceOperationSettings;
|
|
72
|
+
/** The export and import bridge of a caller allowed to run them. */
|
|
73
|
+
portability?: SpacePortability;
|
|
22
74
|
}
|
|
23
75
|
/** Actions that need a person at the web app, so the CLI and the `memory` tool refuse them. */
|
|
24
76
|
export declare const WEB_ONLY_SPACE_ACTIONS: string[];
|
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import { listSpaces, SpaceError,
|
|
2
|
-
import { archiveStoredSpace, createStoredSpace, switchStoredSpace, unarchiveStoredSpace, } from "./registry-store.js";
|
|
1
|
+
import { findSpace, listSpaces, readRegistryFile, resolveSpaceFolder, spaceLinks, SpaceError, } from "./registry.js";
|
|
2
|
+
import { archiveStoredSpace, createStoredSpace, linkStoredSpace, switchStoredSpace, unarchiveStoredSpace, unlinkStoredSpace, } from "./registry-store.js";
|
|
3
3
|
/** Actions that need a person at the web app, so the CLI and the `memory` tool refuse them. */
|
|
4
4
|
export const WEB_ONLY_SPACE_ACTIONS = ["delete", "rename", "backup", "restore"];
|
|
5
|
-
export const SPACE_ACTIONS = [
|
|
5
|
+
export const SPACE_ACTIONS = [
|
|
6
|
+
"list",
|
|
7
|
+
"status",
|
|
8
|
+
"create",
|
|
9
|
+
"archive",
|
|
10
|
+
"unarchive",
|
|
11
|
+
"use",
|
|
12
|
+
"preview",
|
|
13
|
+
"copy",
|
|
14
|
+
"link",
|
|
15
|
+
"unlink",
|
|
16
|
+
];
|
|
6
17
|
const WEB_ONLY = WEB_ONLY_SPACE_ACTIONS;
|
|
7
18
|
const ACTIONS = SPACE_ACTIONS;
|
|
19
|
+
const MOVE_REFUSAL = "Only the web app can move memories between spaces.";
|
|
8
20
|
function summary(registry) {
|
|
9
21
|
return listSpaces(registry).map((space) => ({
|
|
10
22
|
id: space.id,
|
|
@@ -18,11 +30,145 @@ function activeOf(registry) {
|
|
|
18
30
|
const active = listSpaces(registry).find((space) => space.id === registry.activeSpaceId);
|
|
19
31
|
return { id: active.id, name: active.name };
|
|
20
32
|
}
|
|
33
|
+
/** An ISO 8601 date or date-time as epoch milliseconds, or the reason it is not one. */
|
|
34
|
+
function epochMsOf(value, label) {
|
|
35
|
+
const ms = Date.parse(value.trim());
|
|
36
|
+
return Number.isNaN(ms) ? `The ${label} date "${value}" is not a valid ISO 8601 date.` : ms;
|
|
37
|
+
}
|
|
38
|
+
/** Runs a preview or a copy through the transfer service (design D3). A move never reaches here. */
|
|
39
|
+
async function runTransfer(action, args, context, from, to) {
|
|
40
|
+
if (!context.settings) {
|
|
41
|
+
return {
|
|
42
|
+
success: false,
|
|
43
|
+
error: `The ${action} action needs the space settings; the caller did not supply them.`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const since = args.since === undefined ? 0 : epochMsOf(args.since, "since");
|
|
47
|
+
if (typeof since === "string")
|
|
48
|
+
return { success: false, error: since };
|
|
49
|
+
const until = args.until === undefined ? Number.MAX_SAFE_INTEGER : epochMsOf(args.until, "until");
|
|
50
|
+
if (typeof until === "string")
|
|
51
|
+
return { success: false, error: until };
|
|
52
|
+
const project = args.project?.trim();
|
|
53
|
+
const filter = { since, until, ...(project ? { projectTag: project } : {}) };
|
|
54
|
+
const { previewSpaceTransfer, applySpaceTransfer } = await import("./transfer-service.js");
|
|
55
|
+
const transfer = { files: context.files, ...context.settings };
|
|
56
|
+
if (action === "preview") {
|
|
57
|
+
const outcome = await previewSpaceTransfer({ from, to, filter }, transfer);
|
|
58
|
+
if (!outcome.ok)
|
|
59
|
+
return { success: false, error: outcome.error };
|
|
60
|
+
return { success: true, preview: outcome.preview };
|
|
61
|
+
}
|
|
62
|
+
const outcome = await applySpaceTransfer({ from, to, filter, mode: "copy" }, transfer);
|
|
63
|
+
if (!outcome.ok)
|
|
64
|
+
return { success: false, error: outcome.error };
|
|
65
|
+
const result = outcome.result;
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
return {
|
|
68
|
+
success: false,
|
|
69
|
+
error: `The copy stopped at the ${result.error?.step ?? "write"} step: ${result.error?.message ?? "unknown failure"}`,
|
|
70
|
+
copied: result.copied,
|
|
71
|
+
skipped: result.skipped,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
success: true,
|
|
76
|
+
copied: result.copied,
|
|
77
|
+
skipped: result.skipped,
|
|
78
|
+
embeddedAgain: result.embeddedAgain,
|
|
79
|
+
message: `Copied ${result.copied} memories (${result.skipped} exact duplicates skipped).`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Links or unlinks two stored spaces (design D5). The Default space owns no links. */
|
|
83
|
+
async function runLink(action, args, context, target) {
|
|
84
|
+
const owner = args.owner?.trim() || context.current.name;
|
|
85
|
+
const change = action === "link" ? linkStoredSpace : unlinkStoredSpace;
|
|
86
|
+
const registry = await change(context.files, owner, target);
|
|
87
|
+
const ownerName = findSpace(registry, owner)?.name ?? owner;
|
|
88
|
+
const targetName = findSpace(registry, target)?.name ?? target;
|
|
89
|
+
return {
|
|
90
|
+
success: true,
|
|
91
|
+
message: action === "link"
|
|
92
|
+
? `Space "${ownerName}" now links "${targetName}".`
|
|
93
|
+
: `Space "${ownerName}" no longer links "${targetName}".`,
|
|
94
|
+
links: spaceLinks(registry, ownerName),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Export and import: refused without a bridge (the `memory` tool), run through the CLI's one. */
|
|
98
|
+
async function runPortabilityAction(action, args, context) {
|
|
99
|
+
const portability = context.portability;
|
|
100
|
+
if (!portability) {
|
|
101
|
+
return {
|
|
102
|
+
success: false,
|
|
103
|
+
error: `The memory tool cannot ${action} a space. Use the web app or the CLI.`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (action === "export") {
|
|
107
|
+
const name = args.name?.trim();
|
|
108
|
+
if (!name)
|
|
109
|
+
return { success: false, error: "The export action needs a space name." };
|
|
110
|
+
const outPath = args.outPath?.trim();
|
|
111
|
+
if (!outPath)
|
|
112
|
+
return { success: false, error: "The export action needs an output path." };
|
|
113
|
+
if (!context.settings) {
|
|
114
|
+
return {
|
|
115
|
+
success: false,
|
|
116
|
+
error: "The export action needs the space settings; the caller did not supply them.",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const since = args.since === undefined ? undefined : epochMsOf(args.since, "since");
|
|
120
|
+
if (typeof since === "string")
|
|
121
|
+
return { success: false, error: since };
|
|
122
|
+
const until = args.until === undefined ? undefined : epochMsOf(args.until, "until");
|
|
123
|
+
if (typeof until === "string")
|
|
124
|
+
return { success: false, error: until };
|
|
125
|
+
const read = readRegistryFile(context.files.registryPath);
|
|
126
|
+
const space = findSpace(read.registry, name);
|
|
127
|
+
if (!space)
|
|
128
|
+
return { success: false, error: `No space named "${name}".` };
|
|
129
|
+
const result = await portability.export({
|
|
130
|
+
folder: resolveSpaceFolder(space.id, context.settings.paths),
|
|
131
|
+
outPath,
|
|
132
|
+
...(since === undefined ? {} : { since }),
|
|
133
|
+
...(until === undefined ? {} : { until }),
|
|
134
|
+
...(args.includeProfile ? { includeProfile: true } : {}),
|
|
135
|
+
});
|
|
136
|
+
return { success: true, export: result };
|
|
137
|
+
}
|
|
138
|
+
const filePath = args.filePath?.trim();
|
|
139
|
+
if (!filePath)
|
|
140
|
+
return { success: false, error: "The import action needs an export file." };
|
|
141
|
+
const spaceName = args.name?.trim();
|
|
142
|
+
if (!spaceName) {
|
|
143
|
+
return { success: false, error: "The import action needs a name for the new space." };
|
|
144
|
+
}
|
|
145
|
+
const retagToProject = args.retagToProject?.trim();
|
|
146
|
+
const result = await portability.import({
|
|
147
|
+
filePath,
|
|
148
|
+
spaceName,
|
|
149
|
+
...(retagToProject ? { retagToProject } : {}),
|
|
150
|
+
});
|
|
151
|
+
if (!result.ok) {
|
|
152
|
+
return { success: false, error: result.reason, warnings: result.warnings };
|
|
153
|
+
}
|
|
154
|
+
const memories = result.counts.projectMemories + result.counts.userMemories;
|
|
155
|
+
return {
|
|
156
|
+
success: true,
|
|
157
|
+
message: `Imported ${memories} memories into the new space "${result.spaceName}".`,
|
|
158
|
+
import: { spaceId: result.spaceId, counts: result.counts, warnings: result.warnings },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
21
161
|
export async function executeSpaceOperation(args, context) {
|
|
22
162
|
const action = (args.action ?? "list").toLowerCase();
|
|
23
163
|
if (WEB_ONLY.includes(action)) {
|
|
24
164
|
return { success: false, error: `Only the web app can ${action} a space.` };
|
|
25
165
|
}
|
|
166
|
+
if (action === "move") {
|
|
167
|
+
return { success: false, error: MOVE_REFUSAL };
|
|
168
|
+
}
|
|
169
|
+
if (action === "export" || action === "import") {
|
|
170
|
+
return runPortabilityAction(action, args, context);
|
|
171
|
+
}
|
|
26
172
|
if (!ACTIONS.includes(action)) {
|
|
27
173
|
return {
|
|
28
174
|
success: false,
|
|
@@ -30,8 +176,12 @@ export async function executeSpaceOperation(args, context) {
|
|
|
30
176
|
};
|
|
31
177
|
}
|
|
32
178
|
const name = args.name?.trim();
|
|
33
|
-
if (["create", "archive", "unarchive", "use"].includes(action)
|
|
34
|
-
|
|
179
|
+
if (["create", "archive", "unarchive", "use", "preview", "copy", "link", "unlink"].includes(action)) {
|
|
180
|
+
if (!name)
|
|
181
|
+
return { success: false, error: `The ${action} action needs a space name.` };
|
|
182
|
+
}
|
|
183
|
+
if ((action === "preview" || action === "copy") && !args.to?.trim()) {
|
|
184
|
+
return { success: false, error: `The ${action} action needs a target space.` };
|
|
35
185
|
}
|
|
36
186
|
try {
|
|
37
187
|
const read = readRegistryFile(context.files.registryPath);
|
|
@@ -59,6 +209,12 @@ export async function executeSpaceOperation(args, context) {
|
|
|
59
209
|
message: `Created space "${space.name}". Use the "use" action to make it active.`,
|
|
60
210
|
};
|
|
61
211
|
}
|
|
212
|
+
if (action === "preview" || action === "copy") {
|
|
213
|
+
return await runTransfer(action, args, context, name, args.to.trim());
|
|
214
|
+
}
|
|
215
|
+
if (action === "link" || action === "unlink") {
|
|
216
|
+
return await runLink(action, args, context, name);
|
|
217
|
+
}
|
|
62
218
|
const change = {
|
|
63
219
|
archive: archiveStoredSpace,
|
|
64
220
|
unarchive: unarchiveStoredSpace,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SpaceReadStore } from "./space-store.js";
|
|
2
|
+
/**
|
|
3
|
+
* The process-wide cached read store of a space folder (design D2). A linked space
|
|
4
|
+
* is searched often and its folder rarely changes, so the store is opened once and
|
|
5
|
+
* reused. A reset (a space switch) or an explicit close drops it; the next call
|
|
6
|
+
* reopens the folder.
|
|
7
|
+
*/
|
|
8
|
+
export declare function getCachedSpaceReadStore(folder: string): SpaceReadStore;
|
|
9
|
+
/** Closes and forgets the cached read store of one folder, for example before a delete. */
|
|
10
|
+
export declare function closeCachedSpaceReadStore(folder: string): Promise<void>;
|
|
11
|
+
/** Closes every cached read store. Registered with `resettable`, so a space switch runs it. */
|
|
12
|
+
export declare function closeAllCachedSpaceReadStores(): Promise<void>;
|
|
13
|
+
export declare const spaceReadStoreCache: Map<string, SpaceReadStore>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { resettable } from "./store-handles.js";
|
|
3
|
+
import { openSpaceStore } from "./space-store.js";
|
|
4
|
+
const cache = new Map();
|
|
5
|
+
/**
|
|
6
|
+
* The process-wide cached read store of a space folder (design D2). A linked space
|
|
7
|
+
* is searched often and its folder rarely changes, so the store is opened once and
|
|
8
|
+
* reused. A reset (a space switch) or an explicit close drops it; the next call
|
|
9
|
+
* reopens the folder.
|
|
10
|
+
*/
|
|
11
|
+
export function getCachedSpaceReadStore(folder) {
|
|
12
|
+
const key = resolve(folder);
|
|
13
|
+
let store = cache.get(key);
|
|
14
|
+
if (!store) {
|
|
15
|
+
store = openSpaceStore(key, "read");
|
|
16
|
+
cache.set(key, store);
|
|
17
|
+
}
|
|
18
|
+
return store;
|
|
19
|
+
}
|
|
20
|
+
/** Closes and forgets the cached read store of one folder, for example before a delete. */
|
|
21
|
+
export async function closeCachedSpaceReadStore(folder) {
|
|
22
|
+
const key = resolve(folder);
|
|
23
|
+
const store = cache.get(key);
|
|
24
|
+
cache.delete(key);
|
|
25
|
+
if (store)
|
|
26
|
+
await store.close();
|
|
27
|
+
}
|
|
28
|
+
/** Closes every cached read store. Registered with `resettable`, so a space switch runs it. */
|
|
29
|
+
export async function closeAllCachedSpaceReadStores() {
|
|
30
|
+
const stores = [...cache.values()];
|
|
31
|
+
cache.clear();
|
|
32
|
+
await Promise.all(stores.map((store) => store.close()));
|
|
33
|
+
}
|
|
34
|
+
export const spaceReadStoreCache = resettable("space-read-stores", cache, () => closeAllCachedSpaceReadStores());
|