om-memory-system 5.4.1 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +18 -13
  2. package/dist/adapters/pi/extension.js +3 -0
  3. package/dist/cli/index.js +5 -0
  4. package/dist/cli/memory-command.js +22 -1
  5. package/dist/cli/space-command.d.ts +14 -0
  6. package/dist/cli/space-command.js +48 -0
  7. package/dist/config.d.ts +3 -0
  8. package/dist/config.js +16 -19
  9. package/dist/core/memory-operations.d.ts +5 -1
  10. package/dist/core/memory-operations.js +9 -0
  11. package/dist/core/space-tool.d.ts +6 -0
  12. package/dist/core/space-tool.js +14 -0
  13. package/dist/services/ai/session/ai-session-manager.js +2 -2
  14. package/dist/services/context.js +9 -3
  15. package/dist/services/embedding.js +2 -1
  16. package/dist/services/host-appearance.d.ts +18 -7
  17. package/dist/services/host-appearance.js +44 -34
  18. package/dist/services/legacy-migration.d.ts +3 -1
  19. package/dist/services/legacy-migration.js +1 -1
  20. package/dist/services/memory-host-routes.d.ts +1 -0
  21. package/dist/services/memory-host-routes.js +4 -2
  22. package/dist/services/space-routes.d.ts +16 -0
  23. package/dist/services/space-routes.js +222 -0
  24. package/dist/services/spaces/active-space.d.ts +16 -0
  25. package/dist/services/spaces/active-space.js +32 -0
  26. package/dist/services/spaces/backup.d.ts +52 -0
  27. package/dist/services/spaces/backup.js +125 -0
  28. package/dist/services/spaces/context-line.d.ts +10 -0
  29. package/dist/services/spaces/context-line.js +13 -0
  30. package/dist/services/spaces/delete.d.ts +22 -0
  31. package/dist/services/spaces/delete.js +40 -0
  32. package/dist/services/spaces/folder-size.d.ts +2 -0
  33. package/dist/services/spaces/folder-size.js +9 -0
  34. package/dist/services/spaces/operations.d.ts +26 -0
  35. package/dist/services/spaces/operations.js +82 -0
  36. package/dist/services/spaces/registry-store.d.ts +32 -0
  37. package/dist/services/spaces/registry-store.js +134 -0
  38. package/dist/services/spaces/registry.d.ts +70 -0
  39. package/dist/services/spaces/registry.js +227 -0
  40. package/dist/services/spaces/restore.d.ts +20 -0
  41. package/dist/services/spaces/restore.js +80 -0
  42. package/dist/services/spaces/storage-resolution.d.ts +17 -0
  43. package/dist/services/spaces/storage-resolution.js +29 -0
  44. package/dist/services/spaces/store-handles.d.ts +23 -0
  45. package/dist/services/spaces/store-handles.js +40 -0
  46. package/dist/services/tag-prefix-migration.js +3 -3
  47. package/dist/services/turso/connection-manager.d.ts +2 -0
  48. package/dist/services/turso/connection-manager.js +3 -1
  49. package/dist/services/turso/ready.js +2 -0
  50. package/dist/services/turso/shard-manager.js +2 -2
  51. package/dist/services/user-profile/user-profile-manager.js +7 -7
  52. package/dist/services/user-prompt/user-prompt-manager.js +2 -2
  53. package/dist/services/web-route-modules.d.ts +1 -0
  54. package/dist/services/web-route-modules.js +5 -0
  55. package/dist/services/web-server.js +2 -1
  56. package/dist/v2/adapter.d.ts +56 -0
  57. package/dist/v2/adapter.js +4 -1
  58. package/dist/web/assets/index-Dcvz3EXt.js +170 -0
  59. package/dist/web/assets/index-b8k5Tuei.css +2 -0
  60. package/dist/web/index.html +2 -2
  61. package/package.json +1 -1
  62. package/skills/omms-memory/SKILL.md +14 -12
  63. package/dist/web/assets/index-7p-aYnWZ.css +0 -2
  64. package/dist/web/assets/index-CaFFlcl1.js +0 -170
@@ -1,6 +1,8 @@
1
1
  import { join } from "node:path";
2
2
  import { HostAppearanceError, createHostAppearance } from "./host-appearance.js";
3
3
  const instances = new Map();
4
+ /** Host icons are global: a memory space switch must not hide them (ADR-041). */
5
+ const globalFolder = (config) => config.skillStoragePath ?? config.storagePath;
4
6
  function appearanceFor(storagePath) {
5
7
  const directory = join(storagePath, "host-appearance");
6
8
  let appearance = instances.get(directory);
@@ -27,12 +29,12 @@ export async function routeMemoryHosts(path, method, url, readBody, config) {
27
29
  return { status: 200, body: await checkWriteHealth(url.searchParams.get("fresh") === "1") };
28
30
  }
29
31
  if (path === "/api/host-appearance" && method === "GET") {
30
- return { status: 200, body: { hosts: await appearanceFor(config.storagePath).read() } };
32
+ return { status: 200, body: { hosts: await appearanceFor(globalFolder(config)).read() } };
31
33
  }
32
34
  const host = /^\/api\/host-appearance\/([^/]+)$/.exec(path)?.[1];
33
35
  if (!host)
34
36
  return undefined;
35
- const appearance = appearanceFor(config.storagePath);
37
+ const appearance = appearanceFor(globalFolder(config));
36
38
  try {
37
39
  if (method === "PUT") {
38
40
  const body = await readBody();
@@ -0,0 +1,16 @@
1
+ import type { RouteResult } from "./chat/chat-routes.js";
2
+ /**
3
+ * `/api/spaces` routes (design D10). The web app is the only surface that can delete, rename, back
4
+ * up or restore a space. Every change goes through the registry writer, then this process
5
+ * refreshes its config so the web app itself serves the new space on the next request.
6
+ */
7
+ type Body = Record<string, unknown>;
8
+ /** What the status call reports: the space this process uses now. */
9
+ export declare function spaceStatus(directory: string): {
10
+ problem?: string | undefined;
11
+ id: string;
12
+ name: string;
13
+ customStoragePath: boolean;
14
+ };
15
+ export declare function routeSpaces(path: string, method: string, readBody: () => Promise<Body>, cwd: string): Promise<RouteResult | undefined>;
16
+ export {};
@@ -0,0 +1,222 @@
1
+ import { basename } from "node:path";
2
+ import * as config from "../config.js";
3
+ import { HostAppearanceError, createAppearance } from "./host-appearance.js";
4
+ import { log } from "./logger.js";
5
+ import { packageVersion } from "./package-version.js";
6
+ import { backupSpace } from "./spaces/backup.js";
7
+ import { deleteSpace } from "./spaces/delete.js";
8
+ import { folderSize } from "./spaces/folder-size.js";
9
+ import { DEFAULT_SPACE_ID, SpaceError, findSpace, listSpaces, isValidSpaceId, readRegistryFile, removeSpace, resolveSpaceFolder, } from "./spaces/registry.js";
10
+ import { archiveStoredSpace, createStoredSpace, renameStoredSpace, spaceFiles, switchStoredSpace, unarchiveStoredSpace, } from "./spaces/registry-store.js";
11
+ import { listBackups, restoreBackup } from "./spaces/restore.js";
12
+ import { ommsHomeDir } from "./spaces/storage-resolution.js";
13
+ const ok = (data) => ({ status: 200, body: { success: true, data } });
14
+ const fail = (status, code, error) => ({
15
+ status,
16
+ body: { success: false, code, error },
17
+ });
18
+ const STATUS_BY_CODE = {
19
+ "invalid-name": 400,
20
+ "invalid-id": 400,
21
+ reserved: 400,
22
+ unknown: 404,
23
+ "name-taken": 409,
24
+ "id-taken": 409,
25
+ protected: 409,
26
+ active: 409,
27
+ archived: 409,
28
+ "not-archived": 409,
29
+ unreadable: 500,
30
+ "lock-timeout": 503,
31
+ };
32
+ /** What the status call reports: the space this process uses now. */
33
+ export function spaceStatus(directory) {
34
+ config.refreshConfigIfChanged(directory);
35
+ const { id, name, customStoragePath, problem } = config.getActiveSpace();
36
+ return { id, name, customStoragePath, ...(problem ? { problem } : {}) };
37
+ }
38
+ /** Why an action is blocked for a space, as a code the page translates. Absent when allowed. */
39
+ function disabledReasons(space, activeId) {
40
+ const isDefault = space.id === DEFAULT_SPACE_ID;
41
+ const isActive = space.id === activeId;
42
+ const reason = (blocked, code) => (blocked ? code : undefined);
43
+ const disabled = {
44
+ rename: reason(isDefault, "default"),
45
+ archive: isDefault ? "default" : reason(isActive, "active"),
46
+ delete: isDefault ? "default" : reason(isActive, "active"),
47
+ switch: space.archived ? "archived" : reason(isActive, "active"),
48
+ };
49
+ return Object.fromEntries(Object.entries(disabled).filter(([, code]) => code !== undefined));
50
+ }
51
+ /** The icon store of the spaces. A space id is checked first, so a path cannot leave the folder. */
52
+ function appearanceOf(files) {
53
+ return createAppearance(files.appearanceRoot, isValidSpaceId, "Unknown space");
54
+ }
55
+ function describeSpaces(files, registry, globalFolder, looks) {
56
+ return listSpaces(registry).map((space) => ({
57
+ look: looks[space.id] ?? { kind: "default" },
58
+ id: space.id,
59
+ name: space.name,
60
+ createdAt: space.createdAt || null,
61
+ archived: space.archived,
62
+ archivedAt: space.archivedAt ?? null,
63
+ active: space.id === registry.activeSpaceId,
64
+ sizeBytes: folderSize(resolveSpaceFolder(space.id, {
65
+ globalStoragePath: globalFolder,
66
+ spacesRoot: files.spacesRoot,
67
+ })),
68
+ disabled: disabledReasons(space, registry.activeSpaceId),
69
+ }));
70
+ }
71
+ function describeBackup(path, summary) {
72
+ return {
73
+ folder: basename(path),
74
+ path,
75
+ spaceId: summary.spaceId,
76
+ spaceName: summary.spaceName,
77
+ createdAt: summary.createdAt,
78
+ sizeBytes: summary.sizeBytes,
79
+ fileCount: summary.fileCount,
80
+ };
81
+ }
82
+ async function snapshot(files, cwd) {
83
+ config.refreshConfigIfChanged(cwd);
84
+ const { registry, problem } = readRegistryFile(files.registryPath);
85
+ const { storagePath, skillStoragePath } = config.CONFIG;
86
+ // Default's folder is the global one, even while a space or a project folder is in use.
87
+ const globalFolder = skillStoragePath ?? storagePath;
88
+ const looks = await appearanceOf(files).read(listSpaces(registry).map((space) => space.id));
89
+ return {
90
+ activeSpaceId: registry.activeSpaceId,
91
+ current: spaceStatus(cwd),
92
+ spaces: describeSpaces(files, registry, globalFolder, looks),
93
+ backups: listBackups(files.backupsRoot).map((summary) => describeBackup(summary.path, summary)),
94
+ ...(problem ? { problem } : {}),
95
+ };
96
+ }
97
+ function nameOf(body) {
98
+ return typeof body.name === "string" ? body.name : "";
99
+ }
100
+ export async function routeSpaces(path, method, readBody, cwd) {
101
+ if (path !== "/api/spaces" && !path.startsWith("/api/spaces/"))
102
+ return undefined;
103
+ const files = spaceFiles(ommsHomeDir());
104
+ try {
105
+ return await dispatch(path, method, readBody, cwd, files);
106
+ }
107
+ catch (error) {
108
+ if (error instanceof SpaceError) {
109
+ return fail(STATUS_BY_CODE[error.code] ?? 400, error.code, error.message);
110
+ }
111
+ if (error instanceof HostAppearanceError)
112
+ return fail(error.status, "invalid", error.message);
113
+ log("spaces route: error", { method, name: error instanceof Error ? error.name : typeof error }, "error");
114
+ return fail(500, "error", error instanceof Error ? error.message : "Spaces request failed");
115
+ }
116
+ }
117
+ async function dispatch(path, method, readBody, cwd, files) {
118
+ if (path === "/api/spaces" && method === "GET")
119
+ return ok(await snapshot(files, cwd));
120
+ if (path === "/api/spaces" && method === "POST") {
121
+ const space = await createStoredSpace(files, nameOf(await readBody()));
122
+ return ok({ space: { id: space.id, name: space.name } });
123
+ }
124
+ if (path === "/api/spaces/backups/restore" && method === "POST") {
125
+ const wanted = (await readBody()).backup;
126
+ const backup = listBackups(files.backupsRoot).find((item) => typeof wanted === "string" && basename(item.path) === wanted);
127
+ if (!backup)
128
+ return fail(404, "unknown-backup", "That backup is not in the backups folder.");
129
+ const space = await restoreBackup(files, backup.path);
130
+ return ok({ space: { id: space.id, name: space.name } });
131
+ }
132
+ const icon = /^\/api\/spaces\/([^/]+)\/appearance$/.exec(path);
133
+ if (icon && (method === "PUT" || method === "DELETE")) {
134
+ const space = requireSpace(files, decodeURIComponent(icon[1]));
135
+ const appearance = appearanceOf(files);
136
+ if (method === "DELETE")
137
+ await appearance.reset(space.id);
138
+ else {
139
+ const body = await readBody();
140
+ if (body.kind === "icon")
141
+ await appearance.setIcon(space.id, body.icon);
142
+ else if (body.kind === "image")
143
+ await appearance.setImage(space.id, body.png);
144
+ else
145
+ return fail(400, "invalid", "kind must be icon or image");
146
+ }
147
+ return ok({ look: (await appearance.read([space.id]))[space.id] });
148
+ }
149
+ const match = /^\/api\/spaces\/([^/]+)(?:\/(switch|rename|archive|unarchive|backup))?$/.exec(path);
150
+ if (!match)
151
+ return undefined;
152
+ const id = decodeURIComponent(match[1]);
153
+ const action = match[2];
154
+ if (action && method === "POST") {
155
+ if (action === "switch")
156
+ await switchStoredSpace(files, id);
157
+ else if (action === "archive")
158
+ await archiveStoredSpace(files, id);
159
+ else if (action === "unarchive")
160
+ await unarchiveStoredSpace(files, id);
161
+ else if (action === "rename")
162
+ await renameStoredSpace(files, id, nameOf(await readBody()));
163
+ else
164
+ return ok(await backUp(files, id));
165
+ config.refreshConfigIfChanged(cwd);
166
+ return ok({ activeSpaceId: readRegistryFile(files.registryPath).registry.activeSpaceId });
167
+ }
168
+ if (!action && method === "DELETE")
169
+ return remove(files, id, await readBody());
170
+ return undefined;
171
+ }
172
+ function globalFolder() {
173
+ return config.CONFIG.skillStoragePath ?? config.CONFIG.storagePath;
174
+ }
175
+ async function backUp(files, id) {
176
+ const space = requireSpace(files, id);
177
+ const { backupPath, manifest } = await backupSpace({
178
+ space,
179
+ spaceFolder: resolveSpaceFolder(space.id, {
180
+ globalStoragePath: globalFolder(),
181
+ spacesRoot: files.spacesRoot,
182
+ }),
183
+ backupsRoot: files.backupsRoot,
184
+ ommsVersion: packageVersion(),
185
+ });
186
+ return {
187
+ backup: describeBackup(backupPath, {
188
+ path: backupPath,
189
+ spaceId: space.id,
190
+ spaceName: space.name,
191
+ createdAt: manifest.createdAt,
192
+ fileCount: manifest.fileCount,
193
+ sizeBytes: manifest.files.reduce((total, file) => total + file.size, 0),
194
+ }),
195
+ };
196
+ }
197
+ function requireSpace(files, id) {
198
+ const space = findSpace(readRegistryFile(files.registryPath).registry, id);
199
+ if (!space)
200
+ throw new SpaceError("unknown", `No space named "${id}".`);
201
+ return space;
202
+ }
203
+ async function remove(files, id, body) {
204
+ const space = requireSpace(files, id);
205
+ // Refuses Default and the active space before the typed name is compared.
206
+ removeSpace(readRegistryFile(files.registryPath).registry, space.id);
207
+ if (body.confirmName !== space.name) {
208
+ return fail(400, "confirm-mismatch", "Type the space name to confirm the delete.");
209
+ }
210
+ const result = await deleteSpace(files, space.id, { ommsVersion: packageVersion() });
211
+ // The icon belongs to the space. A failed clean-up leaves a harmless file.
212
+ await appearanceOf(files)
213
+ .reset(space.id)
214
+ .catch(() => undefined);
215
+ const summary = listBackups(files.backupsRoot).find((item) => item.path === result.backupPath);
216
+ return ok({
217
+ backup: summary
218
+ ? describeBackup(result.backupPath, summary)
219
+ : { folder: basename(result.backupPath), path: result.backupPath },
220
+ ...(result.leftoverFolder ? { leftoverFolder: result.leftoverFolder } : {}),
221
+ });
222
+ }
@@ -0,0 +1,16 @@
1
+ /** Which space the process uses, and the storage folder that choice gives. */
2
+ export interface ActiveSpace {
3
+ id: string;
4
+ name: string;
5
+ /** The space folder. Unset when the Default space or a project folder applies. */
6
+ storagePath?: string;
7
+ /** A project config sets its own `storagePath`, which wins over the active space. */
8
+ customStoragePath: boolean;
9
+ /** Why OMMS used Default although the registry named another space. */
10
+ problem?: string;
11
+ }
12
+ /**
13
+ * Picks the active space for config loading (design D2). Reads paths from arguments
14
+ * and never imports CONFIG. A problem always falls back to the Default space.
15
+ */
16
+ export declare function resolveActiveSpace(ommsDir: string, globalStoragePath: string, projectSetsStoragePath: boolean): ActiveSpace;
@@ -0,0 +1,32 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { DEFAULT_SPACE_ID, DEFAULT_SPACE_NAME, findSpace, readRegistryFile, resolveSpaceFolder, } from "./registry.js";
4
+ /**
5
+ * Picks the active space for config loading (design D2). Reads paths from arguments
6
+ * and never imports CONFIG. A problem always falls back to the Default space.
7
+ */
8
+ export function resolveActiveSpace(ommsDir, globalStoragePath, projectSetsStoragePath) {
9
+ const fallback = (problem) => ({
10
+ id: DEFAULT_SPACE_ID,
11
+ name: DEFAULT_SPACE_NAME,
12
+ customStoragePath: projectSetsStoragePath,
13
+ problem,
14
+ });
15
+ const { registry, problem } = readRegistryFile(join(ommsDir, "spaces.json"));
16
+ if (problem)
17
+ return fallback(problem);
18
+ const space = findSpace(registry, registry.activeSpaceId);
19
+ if (!space || space.id === DEFAULT_SPACE_ID)
20
+ return fallback();
21
+ if (projectSetsStoragePath) {
22
+ return { id: space.id, name: space.name, customStoragePath: true };
23
+ }
24
+ const folder = resolveSpaceFolder(space.id, {
25
+ globalStoragePath,
26
+ spacesRoot: join(ommsDir, "spaces"),
27
+ });
28
+ if (!existsSync(folder)) {
29
+ return fallback(`The folder of space "${space.name}" is missing; using the Default space.`);
30
+ }
31
+ return { id: space.id, name: space.name, storagePath: folder, customStoragePath: false };
32
+ }
@@ -0,0 +1,52 @@
1
+ import { type BackupManifestEntry } from "../legacy-migration.js";
2
+ /**
3
+ * Space backups (design D6). A backup is a folder `space-<id>-<timestamp>` under the backups
4
+ * root. It holds a copy of every store file of the space and a `manifest.json` with the SHA-256
5
+ * of each copy. SQLite files are copied with `VACUUM INTO`, which gives one consistent snapshot
6
+ * while other connections write. Other files are copied byte for byte.
7
+ */
8
+ export declare const MANIFEST_FILE = "manifest.json";
9
+ export declare const SPACE_BACKUP_KIND = "space-backup";
10
+ export declare const SPACE_BACKUP_PREFIX = "space-";
11
+ export interface BackupManifest {
12
+ createdAt: string;
13
+ kind: string;
14
+ fileCount: number;
15
+ files: BackupManifestEntry[];
16
+ }
17
+ export interface SpaceBackupManifest extends BackupManifest {
18
+ kind: typeof SPACE_BACKUP_KIND;
19
+ spaceId: string;
20
+ spaceName: string;
21
+ ommsVersion: string;
22
+ }
23
+ export interface BackupDeps {
24
+ /** Writes a consistent copy of the SQLite file `source` to `destination`. */
25
+ vacuumInto(source: string, destination: string): Promise<void>;
26
+ copyFile(source: string, destination: string): void;
27
+ }
28
+ /** Writes `manifest.json` in a backup folder. */
29
+ export declare function writeBackupManifest(backupRoot: string, manifest: object): void;
30
+ /** Reads `manifest.json` from a backup folder. Throws a plain message when it is missing or invalid. */
31
+ export declare function readBackupManifest(backupRoot: string): SpaceBackupManifest;
32
+ /** Checks every file of a backup against its manifest. The error names the first damaged file. */
33
+ export declare function verifyBackup(backupRoot: string, manifest: BackupManifest): void;
34
+ export interface BackupInput {
35
+ space: {
36
+ id: string;
37
+ name: string;
38
+ };
39
+ /** The folder of the space to copy. */
40
+ spaceFolder: string;
41
+ backupsRoot: string;
42
+ ommsVersion: string;
43
+ now?: Date;
44
+ }
45
+ /**
46
+ * Backs up one space and verifies the copy. On any failure the incomplete backup folder is
47
+ * removed and the error names the file that failed. The space folder is never changed.
48
+ */
49
+ export declare function backupSpace(input: BackupInput, overrides?: Partial<BackupDeps>): Promise<{
50
+ backupPath: string;
51
+ manifest: SpaceBackupManifest;
52
+ }>;
@@ -0,0 +1,125 @@
1
+ import { createClient } from "@libsql/client";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { sha256File, timestampSlug, verifyManifest, walkTree, } from "../legacy-migration.js";
5
+ /**
6
+ * Space backups (design D6). A backup is a folder `space-<id>-<timestamp>` under the backups
7
+ * root. It holds a copy of every store file of the space and a `manifest.json` with the SHA-256
8
+ * of each copy. SQLite files are copied with `VACUUM INTO`, which gives one consistent snapshot
9
+ * while other connections write. Other files are copied byte for byte.
10
+ */
11
+ export const MANIFEST_FILE = "manifest.json";
12
+ export const SPACE_BACKUP_KIND = "space-backup";
13
+ export const SPACE_BACKUP_PREFIX = "space-";
14
+ /** Folders that hold no memory data: the model cache is shared, and lock files are transient. */
15
+ const SKIPPED_FOLDERS = [".cache", ".write-locks"];
16
+ /** Files SQLite keeps next to a database while it is open. `VACUUM INTO` makes them unnecessary. */
17
+ const SKIPPED_SUFFIXES = ["-journal", "-wal", "-shm"];
18
+ async function vacuumInto(source, destination) {
19
+ const client = createClient({ url: `file:${source}` });
20
+ try {
21
+ await client.execute("PRAGMA busy_timeout = 5000");
22
+ await client.execute({ sql: "VACUUM INTO ?", args: [destination] });
23
+ }
24
+ finally {
25
+ client.close();
26
+ }
27
+ }
28
+ const defaultDeps = { vacuumInto, copyFile: copyFileSync };
29
+ /** Writes `manifest.json` in a backup folder. */
30
+ export function writeBackupManifest(backupRoot, manifest) {
31
+ writeFileSync(join(backupRoot, MANIFEST_FILE), JSON.stringify(manifest, null, 2), "utf-8");
32
+ }
33
+ /** Reads `manifest.json` from a backup folder. Throws a plain message when it is missing or invalid. */
34
+ export function readBackupManifest(backupRoot) {
35
+ const path = join(backupRoot, MANIFEST_FILE);
36
+ if (!existsSync(path))
37
+ throw new Error(`The backup has no ${MANIFEST_FILE}.`);
38
+ let manifest;
39
+ try {
40
+ manifest = JSON.parse(readFileSync(path, "utf-8"));
41
+ }
42
+ catch {
43
+ throw new Error(`The backup ${MANIFEST_FILE} cannot be read.`);
44
+ }
45
+ if (manifest.kind !== SPACE_BACKUP_KIND ||
46
+ typeof manifest.spaceId !== "string" ||
47
+ typeof manifest.spaceName !== "string" ||
48
+ !Array.isArray(manifest.files)) {
49
+ throw new Error("The folder is not a space backup.");
50
+ }
51
+ return manifest;
52
+ }
53
+ /** Checks every file of a backup against its manifest. The error names the first damaged file. */
54
+ export function verifyBackup(backupRoot, manifest) {
55
+ verifyManifest(backupRoot, manifest.files);
56
+ }
57
+ function isStoreFile(relPath) {
58
+ const parts = relPath.split("/");
59
+ if (parts.some((part) => SKIPPED_FOLDERS.includes(part)))
60
+ return false;
61
+ return !SKIPPED_SUFFIXES.some((suffix) => relPath.endsWith(suffix));
62
+ }
63
+ function uniqueBackupFolder(backupsRoot, id, now) {
64
+ const base = join(backupsRoot, `${SPACE_BACKUP_PREFIX}${id}-${timestampSlug(now)}`);
65
+ for (let n = 1;; n++) {
66
+ const candidate = n === 1 ? base : `${base}-${n}`;
67
+ if (!existsSync(candidate))
68
+ return candidate;
69
+ }
70
+ }
71
+ /** Copies one file and checks the copy against its source, once more if the source moved on. */
72
+ function copyChecked(deps, source, destination) {
73
+ for (let attempt = 0; attempt < 2; attempt++) {
74
+ deps.copyFile(source, destination);
75
+ if (sha256File(destination) === sha256File(source))
76
+ return;
77
+ }
78
+ throw new Error("the copy does not match the original");
79
+ }
80
+ /**
81
+ * Backs up one space and verifies the copy. On any failure the incomplete backup folder is
82
+ * removed and the error names the file that failed. The space folder is never changed.
83
+ */
84
+ export async function backupSpace(input, overrides = {}) {
85
+ const deps = { ...defaultDeps, ...overrides };
86
+ const now = input.now ?? new Date();
87
+ mkdirSync(input.backupsRoot, { recursive: true });
88
+ const backupPath = uniqueBackupFolder(input.backupsRoot, input.space.id, now);
89
+ mkdirSync(backupPath);
90
+ let current = "";
91
+ try {
92
+ const files = [];
93
+ for (const rel of walkTree(input.spaceFolder).files.filter(isStoreFile)) {
94
+ current = rel;
95
+ const source = join(input.spaceFolder, rel);
96
+ const destination = join(backupPath, rel);
97
+ mkdirSync(dirname(destination), { recursive: true });
98
+ if (rel.endsWith(".db"))
99
+ await deps.vacuumInto(source, destination);
100
+ else
101
+ copyChecked(deps, source, destination);
102
+ files.push({ path: rel, size: statSync(destination).size, sha256: sha256File(destination) });
103
+ }
104
+ current = "";
105
+ const manifest = {
106
+ createdAt: now.toISOString(),
107
+ kind: SPACE_BACKUP_KIND,
108
+ spaceId: input.space.id,
109
+ spaceName: input.space.name,
110
+ ommsVersion: input.ommsVersion,
111
+ fileCount: files.length,
112
+ files,
113
+ };
114
+ writeBackupManifest(backupPath, manifest);
115
+ verifyBackup(backupPath, manifest);
116
+ return { backupPath, manifest };
117
+ }
118
+ catch (error) {
119
+ rmSync(backupPath, { recursive: true, force: true });
120
+ const reason = error instanceof Error ? error.message : String(error);
121
+ throw new Error(`Backup failed${current ? ` at ${current}` : ""}: ${reason}`, {
122
+ cause: error,
123
+ });
124
+ }
125
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The line that names the active memory space in every memory context block (design D9).
3
+ * Pi, OpenCode V2 and Claude Code all build their block in `formatContextForPrompt`, so one
4
+ * line serves the three hosts. A space name is user text, so it is cut to one line and
5
+ * stripped of angle brackets before it enters a model prompt.
6
+ */
7
+ export declare function activeSpaceLine(space: {
8
+ name: string;
9
+ customStoragePath: boolean;
10
+ }): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The line that names the active memory space in every memory context block (design D9).
3
+ * Pi, OpenCode V2 and Claude Code all build their block in `formatContextForPrompt`, so one
4
+ * line serves the three hosts. A space name is user text, so it is cut to one line and
5
+ * stripped of angle brackets before it enters a model prompt.
6
+ */
7
+ export function activeSpaceLine(space) {
8
+ if (space.customStoragePath) {
9
+ return "Active memory space: custom storage folder set by this project";
10
+ }
11
+ const name = space.name.replace(/[<>]/g, "").replace(/\s+/g, " ").trim();
12
+ return `Active memory space: ${name}`;
13
+ }
@@ -0,0 +1,22 @@
1
+ import { backupSpace } from "./backup.js";
2
+ import { type SpaceEntry } from "./registry.js";
3
+ import { type SpaceFiles } from "./registry-store.js";
4
+ export interface DeleteDeps {
5
+ /** Replaced in tests only. */
6
+ backup: typeof backupSpace;
7
+ removeFolder(folder: string): void;
8
+ }
9
+ export interface DeleteResult {
10
+ backupPath: string;
11
+ space: SpaceEntry;
12
+ /** Set when the registry entry is gone but the folder could not be removed. OMMS does not retry. */
13
+ leftoverFolder?: string;
14
+ }
15
+ /**
16
+ * Deletes a space (design D8): a verified backup first, then the registry entry, then the folder.
17
+ * The Default space and the active space are refused before anything is copied. Only the web app
18
+ * calls this. The CLI and the `memory` tool have no delete.
19
+ */
20
+ export declare function deleteSpace(files: SpaceFiles, idOrName: string, options: {
21
+ ommsVersion: string;
22
+ }, overrides?: Partial<DeleteDeps>): Promise<DeleteResult>;
@@ -0,0 +1,40 @@
1
+ import { rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { backupSpace } from "./backup.js";
4
+ import { SpaceError, findSpace, readRegistryFile, removeSpace, } from "./registry.js";
5
+ import { removeStoredSpace } from "./registry-store.js";
6
+ const defaultDeps = {
7
+ backup: backupSpace,
8
+ removeFolder: (folder) => rmSync(folder, { recursive: true, force: false }),
9
+ };
10
+ /**
11
+ * Deletes a space (design D8): a verified backup first, then the registry entry, then the folder.
12
+ * The Default space and the active space are refused before anything is copied. Only the web app
13
+ * calls this. The CLI and the `memory` tool have no delete.
14
+ */
15
+ export async function deleteSpace(files, idOrName, options, overrides = {}) {
16
+ const deps = { ...defaultDeps, ...overrides };
17
+ const { registry, problem } = readRegistryFile(files.registryPath);
18
+ if (problem)
19
+ throw new SpaceError("unreadable", problem);
20
+ const space = findSpace(registry, idOrName);
21
+ if (!space)
22
+ removeSpace(registry, idOrName); // throws "unknown" with the list of spaces
23
+ const checked = space;
24
+ removeSpace(registry, checked.id); // throws when the space is Default or active
25
+ const folder = join(files.spacesRoot, checked.id);
26
+ const { backupPath } = await deps.backup({
27
+ space: checked,
28
+ spaceFolder: folder,
29
+ backupsRoot: files.backupsRoot,
30
+ ommsVersion: options.ommsVersion,
31
+ });
32
+ await removeStoredSpace(files, checked.id);
33
+ try {
34
+ deps.removeFolder(folder);
35
+ return { backupPath, space: checked };
36
+ }
37
+ catch {
38
+ return { backupPath, space: checked, leftoverFolder: folder };
39
+ }
40
+ }
@@ -0,0 +1,2 @@
1
+ /** The total size in bytes of the files in a folder. A missing folder counts as zero. */
2
+ export declare function folderSize(folder: string): number;
@@ -0,0 +1,9 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { walkTree } from "../legacy-migration.js";
4
+ /** The total size in bytes of the files in a folder. A missing folder counts as zero. */
5
+ export function folderSize(folder) {
6
+ if (!existsSync(folder))
7
+ return 0;
8
+ return walkTree(folder).files.reduce((total, rel) => total + statSync(join(folder, rel)).size, 0);
9
+ }
@@ -0,0 +1,26 @@
1
+ import { type SpaceFiles } from "./registry-store.js";
2
+ /**
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.
7
+ */
8
+ export interface SpaceOperationArgs {
9
+ action?: string;
10
+ /** A space name or id. */
11
+ name?: string;
12
+ }
13
+ export interface SpaceOperationContext {
14
+ files: SpaceFiles;
15
+ /** The space this process uses now, from the last config load. */
16
+ current: {
17
+ id: string;
18
+ name: string;
19
+ customStoragePath: boolean;
20
+ problem?: string;
21
+ };
22
+ }
23
+ /** Actions that need a person at the web app, so the CLI and the `memory` tool refuse them. */
24
+ export declare const WEB_ONLY_SPACE_ACTIONS: string[];
25
+ export declare const SPACE_ACTIONS: string[];
26
+ export declare function executeSpaceOperation(args: SpaceOperationArgs, context: SpaceOperationContext): Promise<Record<string, unknown>>;