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.
Files changed (76) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/pi/extension.js +5 -0
  3. package/dist/cli/space-command.d.ts +6 -5
  4. package/dist/cli/space-command.js +154 -13
  5. package/dist/cli/space-transfer-command.d.ts +25 -0
  6. package/dist/cli/space-transfer-command.js +79 -0
  7. package/dist/core/memory-operations.d.ts +12 -2
  8. package/dist/core/memory-operations.js +14 -4
  9. package/dist/core/recall-summary.js +7 -5
  10. package/dist/core/retrieval.js +5 -2
  11. package/dist/core/space-tool.d.ts +9 -4
  12. package/dist/core/space-tool.js +35 -3
  13. package/dist/services/active-space-access.d.ts +6 -0
  14. package/dist/services/active-space-access.js +8 -0
  15. package/dist/services/chat/chat-runtime.js +1 -1
  16. package/dist/services/client.d.ts +6 -16
  17. package/dist/services/client.js +54 -62
  18. package/dist/services/context.d.ts +2 -0
  19. package/dist/services/context.js +12 -1
  20. package/dist/services/linked-search.d.ts +108 -0
  21. package/dist/services/linked-search.js +219 -0
  22. package/dist/services/space-routes.d.ts +2 -0
  23. package/dist/services/space-routes.js +4 -3
  24. package/dist/services/space-transfer-routes.d.ts +10 -0
  25. package/dist/services/space-transfer-routes.js +344 -0
  26. package/dist/services/spaces/active-space.js +14 -7
  27. package/dist/services/spaces/delete.d.ts +2 -0
  28. package/dist/services/spaces/delete.js +4 -0
  29. package/dist/services/spaces/export-space.d.ts +60 -0
  30. package/dist/services/spaces/export-space.js +367 -0
  31. package/dist/services/spaces/identifier-scrub.d.ts +26 -0
  32. package/dist/services/spaces/identifier-scrub.js +40 -0
  33. package/dist/services/spaces/import-space.d.ts +85 -0
  34. package/dist/services/spaces/import-space.js +349 -0
  35. package/dist/services/spaces/operations.d.ts +57 -5
  36. package/dist/services/spaces/operations.js +161 -5
  37. package/dist/services/spaces/read-store-cache.d.ts +13 -0
  38. package/dist/services/spaces/read-store-cache.js +34 -0
  39. package/dist/services/spaces/registry-store.d.ts +4 -0
  40. package/dist/services/spaces/registry-store.js +15 -1
  41. package/dist/services/spaces/registry.d.ts +32 -0
  42. package/dist/services/spaces/registry.js +81 -7
  43. package/dist/services/spaces/space-store.d.ts +50 -0
  44. package/dist/services/spaces/space-store.js +72 -0
  45. package/dist/services/spaces/transfer-service.d.ts +41 -0
  46. package/dist/services/spaces/transfer-service.js +408 -0
  47. package/dist/services/spaces/transfer-types.d.ts +163 -0
  48. package/dist/services/spaces/transfer-types.js +10 -0
  49. package/dist/services/spaces/transfer.d.ts +31 -0
  50. package/dist/services/spaces/transfer.js +276 -0
  51. package/dist/services/turso/connection-manager.d.ts +4 -2
  52. package/dist/services/turso/connection-manager.js +11 -5
  53. package/dist/services/turso/cross-process-write-lock.d.ts +1 -1
  54. package/dist/services/turso/cross-process-write-lock.js +5 -5
  55. package/dist/services/turso/operation-lock.d.ts +2 -2
  56. package/dist/services/turso/operation-lock.js +5 -5
  57. package/dist/services/turso/shard-manager.d.ts +8 -0
  58. package/dist/services/turso/shard-manager.js +23 -22
  59. package/dist/services/turso/vector-search.d.ts +6 -2
  60. package/dist/services/turso/vector-search.js +8 -4
  61. package/dist/services/user-profile/user-profile-manager.d.ts +5 -1
  62. package/dist/services/user-profile/user-profile-manager.js +13 -10
  63. package/dist/services/web-route-modules.d.ts +5 -1
  64. package/dist/services/web-route-modules.js +4 -0
  65. package/dist/services/web-server.js +1 -1
  66. package/dist/shared/api/space-export-schemas.d.ts +159 -0
  67. package/dist/shared/api/space-export-schemas.js +72 -0
  68. package/dist/v2/adapter.d.ts +15 -0
  69. package/dist/v2/adapter.js +5 -0
  70. package/dist/web/assets/index-2BNRLGqe.css +2 -0
  71. package/dist/web/assets/index-C6WnDE6h.js +170 -0
  72. package/dist/web/index.html +2 -2
  73. package/package.json +1 -1
  74. package/skills/omms-memory/SKILL.md +16 -14
  75. package/dist/web/assets/index-Dcvz3EXt.js +0 -170
  76. 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, unarchive
4
- * and use (switch). Delete, rename, backup and restore exist only in the web app (design D11),
5
- * because a person must confirm them. The CLI `space` command, the `memory` tool `space` mode
6
- * and the Claude Code plugin tool all call this one function, so the three hosts stay equal.
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, readRegistryFile } from "./registry.js";
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 = ["list", "status", "create", "archive", "unarchive", "use"];
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) && !name) {
34
- return { success: false, error: `The ${action} action needs a space name.` };
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());