om-memory-system 5.5.0-next.212 → 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
@@ -0,0 +1,82 @@
1
+ import { listSpaces, SpaceError, readRegistryFile } from "./registry.js";
2
+ import { archiveStoredSpace, createStoredSpace, switchStoredSpace, unarchiveStoredSpace, } from "./registry-store.js";
3
+ /** Actions that need a person at the web app, so the CLI and the `memory` tool refuse them. */
4
+ export const WEB_ONLY_SPACE_ACTIONS = ["delete", "rename", "backup", "restore"];
5
+ export const SPACE_ACTIONS = ["list", "status", "create", "archive", "unarchive", "use"];
6
+ const WEB_ONLY = WEB_ONLY_SPACE_ACTIONS;
7
+ const ACTIONS = SPACE_ACTIONS;
8
+ function summary(registry) {
9
+ return listSpaces(registry).map((space) => ({
10
+ id: space.id,
11
+ name: space.name,
12
+ active: space.id === registry.activeSpaceId,
13
+ archived: space.archived,
14
+ createdAt: space.createdAt || undefined,
15
+ }));
16
+ }
17
+ function activeOf(registry) {
18
+ const active = listSpaces(registry).find((space) => space.id === registry.activeSpaceId);
19
+ return { id: active.id, name: active.name };
20
+ }
21
+ export async function executeSpaceOperation(args, context) {
22
+ const action = (args.action ?? "list").toLowerCase();
23
+ if (WEB_ONLY.includes(action)) {
24
+ return { success: false, error: `Only the web app can ${action} a space.` };
25
+ }
26
+ if (!ACTIONS.includes(action)) {
27
+ return {
28
+ success: false,
29
+ error: `Unknown space action "${args.action}". Actions: ${ACTIONS.join(", ")}.`,
30
+ };
31
+ }
32
+ 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.` };
35
+ }
36
+ try {
37
+ const read = readRegistryFile(context.files.registryPath);
38
+ if (action === "list") {
39
+ return {
40
+ success: true,
41
+ activeSpace: activeOf(read.registry),
42
+ spaces: summary(read.registry),
43
+ ...(read.problem ? { problem: read.problem } : {}),
44
+ };
45
+ }
46
+ if (action === "status") {
47
+ return {
48
+ success: true,
49
+ activeSpace: context.current,
50
+ registryActiveSpace: activeOf(read.registry),
51
+ ...(context.current.problem ? { problem: context.current.problem } : {}),
52
+ };
53
+ }
54
+ if (action === "create") {
55
+ const space = await createStoredSpace(context.files, name);
56
+ return {
57
+ success: true,
58
+ space: { id: space.id, name: space.name },
59
+ message: `Created space "${space.name}". Use the "use" action to make it active.`,
60
+ };
61
+ }
62
+ const change = {
63
+ archive: archiveStoredSpace,
64
+ unarchive: unarchiveStoredSpace,
65
+ use: switchStoredSpace,
66
+ }[action];
67
+ const registry = await change(context.files, name);
68
+ const active = activeOf(registry);
69
+ return {
70
+ success: true,
71
+ activeSpace: active,
72
+ message: action === "use"
73
+ ? `Active space is now "${active.name}". It applies from the next turn.`
74
+ : `Space "${name}" is now ${action === "archive" ? "archived" : "not archived"}.`,
75
+ };
76
+ }
77
+ catch (error) {
78
+ if (error instanceof SpaceError)
79
+ return { success: false, error: error.message };
80
+ throw error;
81
+ }
82
+ }
@@ -0,0 +1,32 @@
1
+ import { type SpaceEntry, type SpaceRegistry } from "./registry.js";
2
+ export interface SpaceFiles {
3
+ /** `<omms folder>/spaces.json` */
4
+ registryPath: string;
5
+ lockPath: string;
6
+ /** `<omms folder>/spaces`: one folder per extra space. */
7
+ spacesRoot: string;
8
+ /** `<omms folder>/backups` */
9
+ backupsRoot: string;
10
+ /** `<omms folder>/space-appearance`: the icon of each space. It is outside every space folder. */
11
+ appearanceRoot: string;
12
+ }
13
+ /** Locations of the registry and space folders under the OMMS folder (normally `~/.omms`). */
14
+ export declare function spaceFiles(ommsDir: string): SpaceFiles;
15
+ /**
16
+ * Applies `change` to the registry under the lock and writes the result.
17
+ * A registry file that cannot be read is never overwritten: the caller must fix it first.
18
+ * When `change` throws, the file is left as it was.
19
+ */
20
+ export declare function updateRegistry<T>(files: SpaceFiles, change: (registry: SpaceRegistry) => {
21
+ registry: SpaceRegistry;
22
+ result: T;
23
+ }): Promise<{
24
+ registry: SpaceRegistry;
25
+ result: T;
26
+ }>;
27
+ export declare function createStoredSpace(files: SpaceFiles, name: string): Promise<SpaceEntry>;
28
+ export declare const archiveStoredSpace: (files: SpaceFiles, idOrName: string) => Promise<SpaceRegistry>;
29
+ export declare const unarchiveStoredSpace: (files: SpaceFiles, idOrName: string) => Promise<SpaceRegistry>;
30
+ export declare const switchStoredSpace: (files: SpaceFiles, idOrName: string) => Promise<SpaceRegistry>;
31
+ export declare const removeStoredSpace: (files: SpaceFiles, idOrName: string) => Promise<SpaceRegistry>;
32
+ export declare function renameStoredSpace(files: SpaceFiles, idOrName: string, name: string): Promise<SpaceRegistry>;
@@ -0,0 +1,134 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { SpaceError, archiveSpace, createSpace, readRegistryFile, removeSpace, renameSpace, switchSpace, unarchiveSpace, } from "./registry.js";
4
+ /**
5
+ * The one write path for `spaces.json` (design D5). The web server, the CLI and the
6
+ * `memory` tool all change the registry through `updateRegistry`. Readers never lock.
7
+ *
8
+ * A change runs as: take the lock file, read the file, apply the change, write a
9
+ * temporary file, rename it over `spaces.json`, release the lock. A reader sees
10
+ * the old file or the new file, never a part of one.
11
+ */
12
+ const LOCK_TIMEOUT_MS = 5000;
13
+ const LOCK_POLL_MS = 15;
14
+ /** A lock file with no readable owner is stale only after this long, so a lock still being written is kept. */
15
+ const UNREADABLE_LOCK_GRACE_MS = 2000;
16
+ /** Locations of the registry and space folders under the OMMS folder (normally `~/.omms`). */
17
+ export function spaceFiles(ommsDir) {
18
+ return {
19
+ registryPath: join(ommsDir, "spaces.json"),
20
+ lockPath: join(ommsDir, "spaces.json.lock"),
21
+ spacesRoot: join(ommsDir, "spaces"),
22
+ backupsRoot: join(ommsDir, "backups"),
23
+ appearanceRoot: join(ommsDir, "space-appearance"),
24
+ };
25
+ }
26
+ function isProcessAlive(pid) {
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ /** True when the lock file exists but its owner is gone, so the next writer may remove it. */
36
+ function lockIsStale(lockPath) {
37
+ try {
38
+ const state = JSON.parse(readFileSync(lockPath, "utf-8"));
39
+ return typeof state.pid === "number" && state.pid > 0 && !isProcessAlive(state.pid);
40
+ }
41
+ catch {
42
+ try {
43
+ return Date.now() - statSync(lockPath).mtimeMs > UNREADABLE_LOCK_GRACE_MS;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ }
50
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
51
+ async function acquireLock(lockPath) {
52
+ mkdirSync(dirname(lockPath), { recursive: true });
53
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
54
+ for (;;) {
55
+ try {
56
+ writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: "wx" });
57
+ return;
58
+ }
59
+ catch (error) {
60
+ if (error.code !== "EEXIST")
61
+ throw error;
62
+ }
63
+ if (lockIsStale(lockPath)) {
64
+ try {
65
+ unlinkSync(lockPath);
66
+ }
67
+ catch {
68
+ // Another writer removed it first. Try to take it again.
69
+ }
70
+ continue;
71
+ }
72
+ if (Date.now() > deadline) {
73
+ throw new SpaceError("lock-timeout", "Another OMMS process is changing the space list. Try again in a moment.");
74
+ }
75
+ await sleep(LOCK_POLL_MS);
76
+ }
77
+ }
78
+ /**
79
+ * Applies `change` to the registry under the lock and writes the result.
80
+ * A registry file that cannot be read is never overwritten: the caller must fix it first.
81
+ * When `change` throws, the file is left as it was.
82
+ */
83
+ export async function updateRegistry(files, change) {
84
+ await acquireLock(files.lockPath);
85
+ try {
86
+ const current = readRegistryFile(files.registryPath);
87
+ if (current.problem) {
88
+ throw new SpaceError("unreadable", `${current.problem} Fix or remove ${files.registryPath} before changing spaces.`);
89
+ }
90
+ const next = change(current.registry);
91
+ const temporary = `${files.registryPath}.${process.pid}.tmp`;
92
+ try {
93
+ writeFileSync(temporary, `${JSON.stringify(next.registry, null, 2)}\n`);
94
+ renameSync(temporary, files.registryPath);
95
+ }
96
+ catch (error) {
97
+ if (existsSync(temporary))
98
+ unlinkSync(temporary);
99
+ throw error;
100
+ }
101
+ return next;
102
+ }
103
+ finally {
104
+ try {
105
+ unlinkSync(files.lockPath);
106
+ }
107
+ catch {
108
+ // The lock is already gone.
109
+ }
110
+ }
111
+ }
112
+ export async function createStoredSpace(files, name) {
113
+ const { result } = await updateRegistry(files, (registry) => {
114
+ const created = createSpace(registry, name, new Date().toISOString());
115
+ // The folder comes first, so the registry never names a space that has no folder.
116
+ mkdirSync(join(files.spacesRoot, created.space.id), { recursive: true });
117
+ return { registry: created.registry, result: created.space };
118
+ });
119
+ return result;
120
+ }
121
+ const simple = (apply) => async (files, idOrName) => (await updateRegistry(files, (registry) => ({
122
+ registry: apply(registry, idOrName),
123
+ result: 0,
124
+ }))).registry;
125
+ export const archiveStoredSpace = simple(archiveSpace);
126
+ export const unarchiveStoredSpace = simple(unarchiveSpace);
127
+ export const switchStoredSpace = simple(switchSpace);
128
+ export const removeStoredSpace = simple(removeSpace);
129
+ export async function renameStoredSpace(files, idOrName, name) {
130
+ return (await updateRegistry(files, (registry) => ({
131
+ registry: renameSpace(registry, idOrName, name),
132
+ result: 0,
133
+ }))).registry;
134
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Memory space registry (ADR-041). Pure logic: it reads paths from arguments and
3
+ * never imports CONFIG, so tests that stub `src/config.js` keep working.
4
+ *
5
+ * `spaces.json` holds the spaces the user created plus the active space id.
6
+ * The Default space is not stored. It always exists and uses the global storage path.
7
+ */
8
+ export declare const DEFAULT_SPACE_ID = "default";
9
+ export declare const DEFAULT_SPACE_NAME = "Default";
10
+ export declare const MAX_SPACE_ID_LENGTH = 40;
11
+ export declare const MAX_SPACE_NAME_LENGTH = 60;
12
+ export interface SpaceEntry {
13
+ id: string;
14
+ name: string;
15
+ /** ISO 8601 time. */
16
+ createdAt: string;
17
+ archived: boolean;
18
+ /** ISO 8601 time of the archive. Absent for a space archived before OMMS recorded it. */
19
+ archivedAt?: string;
20
+ }
21
+ export interface SpaceRegistry {
22
+ version: number;
23
+ activeSpaceId: string;
24
+ /** User-created spaces only. Default is added by `listSpaces`. */
25
+ spaces: SpaceEntry[];
26
+ }
27
+ export type SpaceErrorCode = "invalid-name" | "name-taken" | "id-taken" | "reserved" | "protected" | "active" | "archived" | "not-archived" | "unknown" | "invalid-id" | "unreadable" | "lock-timeout";
28
+ /** A refused space operation. The message is safe to show to the user. */
29
+ export declare class SpaceError extends Error {
30
+ readonly code: SpaceErrorCode;
31
+ constructor(code: SpaceErrorCode, message: string);
32
+ }
33
+ export interface SpacePaths {
34
+ /** The global store folder, normally `~/.omms/data`. The Default space uses it. */
35
+ globalStoragePath: string;
36
+ /** The folder that holds one folder per extra space, normally `~/.omms/spaces`. */
37
+ spacesRoot: string;
38
+ }
39
+ export declare function emptyRegistry(): SpaceRegistry;
40
+ /** Every space in display order: Default first, then user spaces in creation order. */
41
+ export declare function listSpaces(registry: SpaceRegistry): SpaceEntry[];
42
+ /** Lowercase letters, digits and single hyphens, at most 40 characters. Empty when the name has none. */
43
+ export declare function spaceIdFromName(name: string): string;
44
+ /** True for a space id that is safe to use in a path: lowercase letters, digits and single hyphens. */
45
+ export declare function isValidSpaceId(id: string): boolean;
46
+ /** Finds a space by id, or by display name without regard to case. */
47
+ export declare function findSpace(registry: SpaceRegistry, idOrName: string): SpaceEntry | undefined;
48
+ /** Adds a space. Returns the new registry and the new entry. The active space does not change. */
49
+ export declare function createSpace(registry: SpaceRegistry, name: string, now: string): {
50
+ registry: SpaceRegistry;
51
+ space: SpaceEntry;
52
+ };
53
+ export declare function renameSpace(registry: SpaceRegistry, idOrName: string, name: string): SpaceRegistry;
54
+ export declare function archiveSpace(registry: SpaceRegistry, idOrName: string, now?: string): SpaceRegistry;
55
+ export declare function unarchiveSpace(registry: SpaceRegistry, idOrName: string): SpaceRegistry;
56
+ export declare function switchSpace(registry: SpaceRegistry, idOrName: string): SpaceRegistry;
57
+ /** Removes an entry. The caller checks backup and folder removal (D8). */
58
+ export declare function removeSpace(registry: SpaceRegistry, idOrName: string): SpaceRegistry;
59
+ /** The folder of a space. Refuses an id that is not safe, so a path cannot leave the spaces root. */
60
+ export declare function resolveSpaceFolder(id: string, paths: SpacePaths): string;
61
+ export interface RegistryReadResult {
62
+ registry: SpaceRegistry;
63
+ /** Set when the file exists but cannot be used. The registry is then Default only. */
64
+ problem?: string;
65
+ }
66
+ /**
67
+ * Reads `spaces.json`. A missing file means Default only. An unreadable or invalid
68
+ * file also gives Default only, leaves the file alone, and sets `problem`.
69
+ */
70
+ export declare function readRegistryFile(path: string): RegistryReadResult;
@@ -0,0 +1,227 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, resolve, sep } from "node:path";
3
+ /**
4
+ * Memory space registry (ADR-041). Pure logic: it reads paths from arguments and
5
+ * never imports CONFIG, so tests that stub `src/config.js` keep working.
6
+ *
7
+ * `spaces.json` holds the spaces the user created plus the active space id.
8
+ * The Default space is not stored. It always exists and uses the global storage path.
9
+ */
10
+ export const DEFAULT_SPACE_ID = "default";
11
+ export const DEFAULT_SPACE_NAME = "Default";
12
+ export const MAX_SPACE_ID_LENGTH = 40;
13
+ export const MAX_SPACE_NAME_LENGTH = 60;
14
+ const REGISTRY_VERSION = 1;
15
+ /** A refused space operation. The message is safe to show to the user. */
16
+ export class SpaceError extends Error {
17
+ code;
18
+ constructor(code, message) {
19
+ super(message);
20
+ this.code = code;
21
+ this.name = "SpaceError";
22
+ }
23
+ }
24
+ export function emptyRegistry() {
25
+ return { version: REGISTRY_VERSION, activeSpaceId: DEFAULT_SPACE_ID, spaces: [] };
26
+ }
27
+ function defaultEntry() {
28
+ return { id: DEFAULT_SPACE_ID, name: DEFAULT_SPACE_NAME, createdAt: "", archived: false };
29
+ }
30
+ /** Every space in display order: Default first, then user spaces in creation order. */
31
+ export function listSpaces(registry) {
32
+ return [defaultEntry(), ...registry.spaces];
33
+ }
34
+ /** Lowercase letters, digits and single hyphens, at most 40 characters. Empty when the name has none. */
35
+ export function spaceIdFromName(name) {
36
+ const id = name
37
+ .toLowerCase()
38
+ .replace(/[^a-z0-9]+/g, "-")
39
+ .replace(/^-+|-+$/g, "");
40
+ return id.slice(0, MAX_SPACE_ID_LENGTH).replace(/-+$/, "");
41
+ }
42
+ const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
43
+ /** True for a space id that is safe to use in a path: lowercase letters, digits and single hyphens. */
44
+ export function isValidSpaceId(id) {
45
+ return id.length >= 1 && id.length <= MAX_SPACE_ID_LENGTH && ID_PATTERN.test(id);
46
+ }
47
+ function findByName(registry, name) {
48
+ const wanted = name.trim().toLowerCase();
49
+ return listSpaces(registry).find((space) => space.name.toLowerCase() === wanted);
50
+ }
51
+ /** Finds a space by id, or by display name without regard to case. */
52
+ export function findSpace(registry, idOrName) {
53
+ const wanted = idOrName.trim().toLowerCase();
54
+ const all = listSpaces(registry);
55
+ return (all.find((space) => space.id === wanted) ?? all.find((s) => s.name.toLowerCase() === wanted));
56
+ }
57
+ function requireSpace(registry, idOrName) {
58
+ const space = findSpace(registry, idOrName);
59
+ if (!space) {
60
+ throw new SpaceError("unknown", `No space named "${idOrName}". ${validTargets(registry)}`);
61
+ }
62
+ return space;
63
+ }
64
+ function validTargets(registry) {
65
+ const names = listSpaces(registry)
66
+ .filter((space) => !space.archived)
67
+ .map((space) => space.id);
68
+ return `Spaces that can become active: ${names.join(", ")}.`;
69
+ }
70
+ function checkedName(name) {
71
+ const trimmed = name.trim();
72
+ if (trimmed.length < 1 || trimmed.length > MAX_SPACE_NAME_LENGTH) {
73
+ throw new SpaceError("invalid-name", `A space name needs 1 to ${MAX_SPACE_NAME_LENGTH} characters.`);
74
+ }
75
+ return trimmed;
76
+ }
77
+ /** Adds a space. Returns the new registry and the new entry. The active space does not change. */
78
+ export function createSpace(registry, name, now) {
79
+ const display = checkedName(name);
80
+ const existing = findByName(registry, display);
81
+ if (existing) {
82
+ throw new SpaceError("name-taken", `A space named "${existing.name}" already exists.`);
83
+ }
84
+ const id = spaceIdFromName(display);
85
+ if (id === "") {
86
+ throw new SpaceError("invalid-name", "A space name needs at least one letter or digit.");
87
+ }
88
+ if (id === DEFAULT_SPACE_ID) {
89
+ throw new SpaceError("reserved", `The id "${DEFAULT_SPACE_ID}" belongs to the Default space.`);
90
+ }
91
+ const clash = registry.spaces.find((space) => space.id === id);
92
+ if (clash) {
93
+ throw new SpaceError("id-taken", `The name "${display}" gives the same folder id as "${clash.name}". Choose another name.`);
94
+ }
95
+ const space = { id, name: display, createdAt: now, archived: false };
96
+ return { registry: { ...registry, spaces: [...registry.spaces, space] }, space };
97
+ }
98
+ function updateSpace(registry, id, change) {
99
+ return {
100
+ ...registry,
101
+ spaces: registry.spaces.map((space) => (space.id === id ? { ...space, ...change } : space)),
102
+ };
103
+ }
104
+ export function renameSpace(registry, idOrName, name) {
105
+ const space = requireSpace(registry, idOrName);
106
+ if (space.id === DEFAULT_SPACE_ID) {
107
+ throw new SpaceError("protected", "The Default space cannot be renamed.");
108
+ }
109
+ const display = checkedName(name);
110
+ const clash = findByName(registry, display);
111
+ if (clash && clash.id !== space.id) {
112
+ throw new SpaceError("name-taken", `A space named "${clash.name}" already exists.`);
113
+ }
114
+ return updateSpace(registry, space.id, { name: display });
115
+ }
116
+ export function archiveSpace(registry, idOrName, now = new Date().toISOString()) {
117
+ const space = requireSpace(registry, idOrName);
118
+ if (space.id === DEFAULT_SPACE_ID) {
119
+ throw new SpaceError("protected", "The Default space cannot be archived.");
120
+ }
121
+ if (space.id === registry.activeSpaceId) {
122
+ throw new SpaceError("active", `"${space.name}" is the active space. Switch to another space first.`);
123
+ }
124
+ return updateSpace(registry, space.id, { archived: true, archivedAt: now });
125
+ }
126
+ export function unarchiveSpace(registry, idOrName) {
127
+ const space = requireSpace(registry, idOrName);
128
+ if (!space.archived) {
129
+ throw new SpaceError("not-archived", `"${space.name}" is not archived.`);
130
+ }
131
+ return updateSpace(registry, space.id, { archived: false, archivedAt: undefined });
132
+ }
133
+ export function switchSpace(registry, idOrName) {
134
+ const space = requireSpace(registry, idOrName);
135
+ if (space.archived) {
136
+ throw new SpaceError("archived", `"${space.name}" is archived. Unarchive it first. ${validTargets(registry)}`);
137
+ }
138
+ return { ...registry, activeSpaceId: space.id };
139
+ }
140
+ /** Removes an entry. The caller checks backup and folder removal (D8). */
141
+ export function removeSpace(registry, idOrName) {
142
+ const space = requireSpace(registry, idOrName);
143
+ if (space.id === DEFAULT_SPACE_ID) {
144
+ throw new SpaceError("protected", "The Default space cannot be deleted.");
145
+ }
146
+ if (space.id === registry.activeSpaceId) {
147
+ throw new SpaceError("active", `"${space.name}" is the active space. Switch to another space first.`);
148
+ }
149
+ return { ...registry, spaces: registry.spaces.filter((s) => s.id !== space.id) };
150
+ }
151
+ /** The folder of a space. Refuses an id that is not safe, so a path cannot leave the spaces root. */
152
+ export function resolveSpaceFolder(id, paths) {
153
+ if (id === DEFAULT_SPACE_ID)
154
+ return paths.globalStoragePath;
155
+ if (!isValidSpaceId(id)) {
156
+ throw new SpaceError("invalid-id", `"${id}" is not a valid space id.`);
157
+ }
158
+ const root = resolve(paths.spacesRoot);
159
+ const folder = resolve(root, id);
160
+ if (!folder.startsWith(root + sep)) {
161
+ throw new SpaceError("invalid-id", `"${id}" is not a valid space id.`);
162
+ }
163
+ return join(paths.spacesRoot, id);
164
+ }
165
+ function parseEntry(raw) {
166
+ if (typeof raw !== "object" || raw === null)
167
+ return undefined;
168
+ const entry = raw;
169
+ if (typeof entry.id !== "string" ||
170
+ !isValidSpaceId(entry.id) ||
171
+ entry.id === DEFAULT_SPACE_ID ||
172
+ typeof entry.name !== "string" ||
173
+ entry.name.trim() === "" ||
174
+ typeof entry.createdAt !== "string" ||
175
+ typeof entry.archived !== "boolean" ||
176
+ (entry.archivedAt !== undefined && typeof entry.archivedAt !== "string")) {
177
+ return undefined;
178
+ }
179
+ return {
180
+ id: entry.id,
181
+ name: entry.name,
182
+ createdAt: entry.createdAt,
183
+ archived: entry.archived,
184
+ ...(typeof entry.archivedAt === "string" ? { archivedAt: entry.archivedAt } : {}),
185
+ };
186
+ }
187
+ /**
188
+ * Reads `spaces.json`. A missing file means Default only. An unreadable or invalid
189
+ * file also gives Default only, leaves the file alone, and sets `problem`.
190
+ */
191
+ export function readRegistryFile(path) {
192
+ if (!existsSync(path))
193
+ return { registry: emptyRegistry() };
194
+ const unusable = (reason) => ({
195
+ registry: emptyRegistry(),
196
+ problem: `spaces.json is not usable (${reason}); using the Default space.`,
197
+ });
198
+ let raw;
199
+ try {
200
+ raw = JSON.parse(readFileSync(path, "utf-8"));
201
+ }
202
+ catch {
203
+ return unusable("cannot be read as JSON");
204
+ }
205
+ if (typeof raw !== "object" || raw === null)
206
+ return unusable("wrong shape");
207
+ const file = raw;
208
+ if (typeof file.activeSpaceId !== "string" || !Array.isArray(file.spaces)) {
209
+ return unusable("wrong shape");
210
+ }
211
+ const spaces = [];
212
+ for (const item of file.spaces) {
213
+ const entry = parseEntry(item);
214
+ if (!entry || spaces.some((space) => space.id === entry.id))
215
+ return unusable("bad entry");
216
+ spaces.push(entry);
217
+ }
218
+ const registry = {
219
+ version: typeof file.version === "number" ? file.version : REGISTRY_VERSION,
220
+ activeSpaceId: file.activeSpaceId,
221
+ spaces,
222
+ };
223
+ if (!listSpaces(registry).some((space) => space.id === registry.activeSpaceId)) {
224
+ return unusable(`active space "${registry.activeSpaceId}" is not in the list`);
225
+ }
226
+ return { registry };
227
+ }
@@ -0,0 +1,20 @@
1
+ import { type SpaceEntry } from "./registry.js";
2
+ import { type SpaceFiles } from "./registry-store.js";
3
+ export interface BackupSummary {
4
+ /** The full path of the backup folder. */
5
+ path: string;
6
+ spaceId: string;
7
+ spaceName: string;
8
+ createdAt: string;
9
+ fileCount: number;
10
+ /** The total size of the backed-up files. */
11
+ sizeBytes: number;
12
+ }
13
+ /** The space backups in the backups folder, newest first. A folder that is not a valid backup is left out. */
14
+ export declare function listBackups(backupsRoot: string): BackupSummary[];
15
+ /**
16
+ * Restores a backup into a new space (design D7). It checks every file against the manifest
17
+ * first and creates nothing when one is damaged. It never replaces an existing space and never
18
+ * changes the active space.
19
+ */
20
+ export declare function restoreBackup(files: SpaceFiles, backupPath: string, now?: Date): Promise<SpaceEntry>;
@@ -0,0 +1,80 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { SPACE_BACKUP_PREFIX, readBackupManifest, verifyBackup, } from "./backup.js";
4
+ import { MAX_SPACE_NAME_LENGTH, SpaceError } from "./registry.js";
5
+ import { createStoredSpace, removeStoredSpace } from "./registry-store.js";
6
+ /** The space backups in the backups folder, newest first. A folder that is not a valid backup is left out. */
7
+ export function listBackups(backupsRoot) {
8
+ if (!existsSync(backupsRoot))
9
+ return [];
10
+ const summaries = [];
11
+ for (const entry of readdirSync(backupsRoot, { withFileTypes: true })) {
12
+ if (!entry.isDirectory() || !entry.name.startsWith(SPACE_BACKUP_PREFIX))
13
+ continue;
14
+ const path = join(backupsRoot, entry.name);
15
+ let manifest;
16
+ try {
17
+ manifest = readBackupManifest(path);
18
+ }
19
+ catch {
20
+ continue;
21
+ }
22
+ summaries.push({
23
+ path,
24
+ spaceId: manifest.spaceId,
25
+ spaceName: manifest.spaceName,
26
+ createdAt: manifest.createdAt,
27
+ fileCount: manifest.files.length,
28
+ sizeBytes: manifest.files.reduce((total, file) => total + file.size, 0),
29
+ });
30
+ }
31
+ return summaries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
32
+ }
33
+ /** `<name> (restored <date>)`, with the name cut so the whole display name fits the limit. */
34
+ function restoredName(name, date, attempt) {
35
+ const suffix = ` (restored ${date.toISOString().slice(0, 10)})${attempt > 1 ? ` ${attempt}` : ""}`;
36
+ return `${name.slice(0, MAX_SPACE_NAME_LENGTH - suffix.length).trimEnd()}${suffix}`;
37
+ }
38
+ /**
39
+ * Restores a backup into a new space (design D7). It checks every file against the manifest
40
+ * first and creates nothing when one is damaged. It never replaces an existing space and never
41
+ * changes the active space.
42
+ */
43
+ export async function restoreBackup(files, backupPath, now = new Date()) {
44
+ const manifest = readBackupManifest(backupPath);
45
+ try {
46
+ verifyBackup(backupPath, manifest);
47
+ }
48
+ catch (error) {
49
+ const reason = error instanceof Error ? error.message : String(error);
50
+ throw new Error(`Restore refused, the backup is damaged: ${reason}`, { cause: error });
51
+ }
52
+ const space = await createWithFreeName(files, manifest.spaceName, now);
53
+ const folder = join(files.spacesRoot, space.id);
54
+ try {
55
+ for (const rel of manifest.files.map((file) => file.path)) {
56
+ mkdirSync(dirname(join(folder, rel)), { recursive: true });
57
+ copyFileSync(join(backupPath, rel), join(folder, rel));
58
+ }
59
+ verifyBackup(folder, manifest);
60
+ }
61
+ catch (error) {
62
+ await removeStoredSpace(files, space.id);
63
+ rmSync(folder, { recursive: true, force: true });
64
+ const reason = error instanceof Error ? error.message : String(error);
65
+ throw new Error(`Restore failed: ${reason}`, { cause: error });
66
+ }
67
+ return space;
68
+ }
69
+ async function createWithFreeName(files, name, now) {
70
+ for (let attempt = 1;; attempt++) {
71
+ try {
72
+ return await createStoredSpace(files, restoredName(name, now, attempt));
73
+ }
74
+ catch (error) {
75
+ const taken = error instanceof SpaceError && ["name-taken", "id-taken"].includes(error.code);
76
+ if (!taken || attempt >= 50)
77
+ throw error;
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,17 @@
1
+ /** The OMMS folder, normally `~/.omms`. It holds `spaces.json`, `spaces/` and `backups/`. */
2
+ export declare function ommsHomeDir(): string;
3
+ /** Expands a leading `~` to the home folder. */
4
+ export declare function expandPath(path: string): string;
5
+ /** The global store folder: the config's `storagePath`, or the default `~/.omms/data`. */
6
+ export declare function storagePathFrom(fileConfig: {
7
+ storagePath?: string;
8
+ }): string;
9
+ /**
10
+ * Keeps skills.db and the embedding model cache in the global storage path when a
11
+ * project config or the active space moves `storagePath` (ADR-036, ADR-041).
12
+ */
13
+ export declare function withSkillStorage<T extends {
14
+ storagePath: string;
15
+ }>(config: T, global: {
16
+ storagePath?: string;
17
+ }): T;