om-memory-system 5.6.1 → 5.8.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 (31) hide show
  1. package/dist/adapters/opencode/self-update.d.ts +19 -0
  2. package/dist/adapters/opencode/self-update.js +42 -0
  3. package/dist/adapters/opencode/tui-status.d.ts +12 -2
  4. package/dist/adapters/opencode/tui-status.js +84 -11
  5. package/dist/adapters/pi/extension.js +19 -3
  6. package/dist/adapters/pi/self-update.d.ts +25 -0
  7. package/dist/adapters/pi/self-update.js +146 -0
  8. package/dist/cli/space-command.js +21 -1
  9. package/dist/config.js +11 -1
  10. package/dist/services/host-copies.d.ts +52 -0
  11. package/dist/services/host-copies.js +150 -0
  12. package/dist/services/host-self-update.d.ts +108 -0
  13. package/dist/services/host-self-update.js +254 -0
  14. package/dist/services/space-routes.d.ts +10 -1
  15. package/dist/services/space-routes.js +63 -20
  16. package/dist/services/spaces/backup.js +3 -0
  17. package/dist/services/spaces/operations.js +3 -1
  18. package/dist/services/spaces/registry-store.d.ts +5 -3
  19. package/dist/services/spaces/registry-store.js +38 -9
  20. package/dist/services/spaces/registry.d.ts +11 -5
  21. package/dist/services/spaces/registry.js +31 -23
  22. package/dist/services/spaces/restore.js +1 -1
  23. package/dist/services/spaces/space-label.d.ts +16 -0
  24. package/dist/services/spaces/space-label.js +69 -0
  25. package/dist/services/web-route-modules.d.ts +3 -1
  26. package/dist/services/web-route-modules.js +3 -1
  27. package/dist/services/web-server.js +1 -0
  28. package/dist/v2/plugin.js +60 -1
  29. package/dist/web/assets/{index-C6WnDE6h.js → index-B94xLmhL.js} +2 -2
  30. package/dist/web/index.html +1 -1
  31. package/package.json +1 -1
@@ -1,4 +1,7 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
1
3
  import { basename } from "node:path";
4
+ import { promisify } from "node:util";
2
5
  import * as config from "../config.js";
3
6
  import { HostAppearanceError, createAppearance } from "./host-appearance.js";
4
7
  import { log } from "./logger.js";
@@ -10,6 +13,7 @@ import { DEFAULT_SPACE_ID, SpaceError, findSpace, listSpaces, isValidSpaceId, re
10
13
  import { archiveStoredSpace, createStoredSpace, renameStoredSpace, spaceFiles, switchStoredSpace, unarchiveStoredSpace, } from "./spaces/registry-store.js";
11
14
  import { listBackups, restoreBackup } from "./spaces/restore.js";
12
15
  import { ommsHomeDir } from "./spaces/storage-resolution.js";
16
+ import { isLoopbackAddress } from "./web-api-auth.js";
13
17
  const ok = (data) => ({ status: 200, body: { success: true, data } });
14
18
  const fail = (status, code, error) => ({
15
19
  status,
@@ -18,10 +22,8 @@ const fail = (status, code, error) => ({
18
22
  export const STATUS_BY_CODE = {
19
23
  "invalid-name": 400,
20
24
  "invalid-id": 400,
21
- reserved: 400,
22
25
  unknown: 404,
23
26
  "name-taken": 409,
24
- "id-taken": 409,
25
27
  protected: 409,
26
28
  active: 409,
27
29
  archived: 409,
@@ -53,21 +55,25 @@ function appearanceOf(files) {
53
55
  return createAppearance(files.appearanceRoot, isValidSpaceId, "Unknown space");
54
56
  }
55
57
  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, {
58
+ return listSpaces(registry).map((space) => {
59
+ const folder = resolveSpaceFolder(space.id, {
65
60
  globalStoragePath: globalFolder,
66
61
  spacesRoot: files.spacesRoot,
67
- })),
68
- disabled: disabledReasons(space, registry.activeSpaceId),
69
- links: spaceLinks(registry, space.id),
70
- }));
62
+ });
63
+ return {
64
+ look: looks[space.id] ?? { kind: "default" },
65
+ id: space.id,
66
+ name: space.name,
67
+ createdAt: space.createdAt || null,
68
+ archived: space.archived,
69
+ archivedAt: space.archivedAt ?? null,
70
+ active: space.id === registry.activeSpaceId,
71
+ folder,
72
+ sizeBytes: folderSize(folder),
73
+ disabled: disabledReasons(space, registry.activeSpaceId),
74
+ links: spaceLinks(registry, space.id),
75
+ };
76
+ });
71
77
  }
72
78
  function describeBackup(path, summary) {
73
79
  return {
@@ -98,12 +104,12 @@ async function snapshot(files, cwd) {
98
104
  function nameOf(body) {
99
105
  return typeof body.name === "string" ? body.name : "";
100
106
  }
101
- export async function routeSpaces(path, method, readBody, cwd) {
107
+ export async function routeSpaces(path, method, readBody, cwd, options = {}) {
102
108
  if (path !== "/api/spaces" && !path.startsWith("/api/spaces/"))
103
109
  return undefined;
104
110
  const files = spaceFiles(ommsHomeDir());
105
111
  try {
106
- return await dispatch(path, method, readBody, cwd, files);
112
+ return await dispatch(path, method, readBody, cwd, files, options);
107
113
  }
108
114
  catch (error) {
109
115
  if (error instanceof SpaceError) {
@@ -115,7 +121,7 @@ export async function routeSpaces(path, method, readBody, cwd) {
115
121
  return fail(500, "error", error instanceof Error ? error.message : "Spaces request failed");
116
122
  }
117
123
  }
118
- async function dispatch(path, method, readBody, cwd, files) {
124
+ async function dispatch(path, method, readBody, cwd, files, options) {
119
125
  if (path === "/api/spaces" && method === "GET")
120
126
  return ok(await snapshot(files, cwd));
121
127
  if (path === "/api/spaces" && method === "POST") {
@@ -147,7 +153,7 @@ async function dispatch(path, method, readBody, cwd, files) {
147
153
  }
148
154
  return ok({ look: (await appearance.read([space.id]))[space.id] });
149
155
  }
150
- const match = /^\/api\/spaces\/([^/]+)(?:\/(switch|rename|archive|unarchive|backup))?$/.exec(path);
156
+ const match = /^\/api\/spaces\/([^/]+)(?:\/(switch|rename|archive|unarchive|backup|open-folder))?$/.exec(path);
151
157
  if (!match)
152
158
  return undefined;
153
159
  const id = decodeURIComponent(match[1]);
@@ -160,7 +166,11 @@ async function dispatch(path, method, readBody, cwd, files) {
160
166
  else if (action === "unarchive")
161
167
  await unarchiveStoredSpace(files, id);
162
168
  else if (action === "rename")
163
- await renameStoredSpace(files, id, nameOf(await readBody()));
169
+ await renameStoredSpace(files, id, nameOf(await readBody()), {
170
+ defaultFolder: globalFolder(),
171
+ });
172
+ else if (action === "open-folder")
173
+ return await openFolder(files, id, options);
164
174
  else
165
175
  return ok(await backUp(files, id));
166
176
  config.refreshConfigIfChanged(cwd);
@@ -173,6 +183,39 @@ async function dispatch(path, method, readBody, cwd, files) {
173
183
  function globalFolder() {
174
184
  return config.CONFIG.skillStoragePath ?? config.CONFIG.storagePath;
175
185
  }
186
+ /** The file manager command of this platform. The folder is its only argument. */
187
+ const openerCommand = process.platform === "win32" ? "explorer" : process.platform === "darwin" ? "open" : "xdg-open";
188
+ const execFileAsync = promisify(execFile);
189
+ /** The real opener. execFile runs no shell, so a path can never be reinterpreted as a command. */
190
+ async function startFileManager(command, args) {
191
+ // The opener hands off to the file manager and exits, so this await reports a missing
192
+ // opener without waiting on the file manager itself.
193
+ await execFileAsync(command, args);
194
+ }
195
+ /**
196
+ * Opens the space folder in the platform file manager. Refused unless the caller is this
197
+ * machine, and the folder always comes from the registry, never from the request.
198
+ */
199
+ async function openFolder(files, id, options) {
200
+ if (!isLoopbackAddress(options.remoteAddress)) {
201
+ return fail(403, "forbidden", "Only the local machine can open a space folder. Use the web app on this computer.");
202
+ }
203
+ const space = requireSpace(files, id);
204
+ const folder = resolveSpaceFolder(space.id, {
205
+ globalStoragePath: globalFolder(),
206
+ spacesRoot: files.spacesRoot,
207
+ });
208
+ if (!existsSync(folder)) {
209
+ return fail(409, "missing-folder", `The space folder is missing: ${folder}. Restore the space from a backup, then try again.`);
210
+ }
211
+ try {
212
+ await (options.startOpener ?? startFileManager)(openerCommand, [folder]);
213
+ }
214
+ catch (error) {
215
+ return fail(500, "opener", `Could not open the folder: ${folder}. ${error instanceof Error ? error.message : "The file manager did not start."}`);
216
+ }
217
+ return ok({ folder });
218
+ }
176
219
  async function backUp(files, id) {
177
220
  const space = requireSpace(files, id);
178
221
  const { backupPath, manifest } = await backupSpace({
@@ -58,6 +58,9 @@ function isStoreFile(relPath) {
58
58
  const parts = relPath.split("/");
59
59
  if (parts.some((part) => SKIPPED_FOLDERS.includes(part)))
60
60
  return false;
61
+ // The root space.json label is live-instance state; a restored space gets a fresh one.
62
+ if (parts.length === 1 && parts[0] === "space.json")
63
+ return false;
61
64
  return !SKIPPED_SUFFIXES.some((suffix) => relPath.endsWith(suffix));
62
65
  }
63
66
  function uniqueBackupFolder(backupsRoot, id, now) {
@@ -222,12 +222,14 @@ export async function executeSpaceOperation(args, context) {
222
222
  }[action];
223
223
  const registry = await change(context.files, name);
224
224
  const active = activeOf(registry);
225
+ // The caller may have passed the id, so the message names the display name.
226
+ const displayName = findSpace(registry, name).name;
225
227
  return {
226
228
  success: true,
227
229
  activeSpace: active,
228
230
  message: action === "use"
229
231
  ? `Active space is now "${active.name}". It applies from the next turn.`
230
- : `Space "${name}" is now ${action === "archive" ? "archived" : "not archived"}.`,
232
+ : `Space "${displayName}" is now ${action === "archive" ? "archived" : "not archived"}.`,
231
233
  };
232
234
  }
233
235
  catch (error) {
@@ -1,4 +1,4 @@
1
- import { type SpaceEntry, type SpaceRegistry } from "./registry.js";
1
+ import { type CreateSpaceOptions, type SpaceEntry, type SpaceRegistry } from "./registry.js";
2
2
  export interface SpaceFiles {
3
3
  /** `<omms folder>/spaces.json` */
4
4
  registryPath: string;
@@ -24,12 +24,14 @@ export declare function updateRegistry<T>(files: SpaceFiles, change: (registry:
24
24
  registry: SpaceRegistry;
25
25
  result: T;
26
26
  }>;
27
- export declare function createStoredSpace(files: SpaceFiles, name: string): Promise<SpaceEntry>;
27
+ export declare function createStoredSpace(files: SpaceFiles, name: string, options?: CreateSpaceOptions): Promise<SpaceEntry>;
28
28
  export declare const archiveStoredSpace: (files: SpaceFiles, idOrName: string) => Promise<SpaceRegistry>;
29
29
  export declare const unarchiveStoredSpace: (files: SpaceFiles, idOrName: string) => Promise<SpaceRegistry>;
30
30
  export declare const switchStoredSpace: (files: SpaceFiles, idOrName: string) => Promise<SpaceRegistry>;
31
31
  export declare const removeStoredSpace: (files: SpaceFiles, idOrName: string) => Promise<SpaceRegistry>;
32
- export declare function renameStoredSpace(files: SpaceFiles, idOrName: string, name: string): Promise<SpaceRegistry>;
32
+ export declare function renameStoredSpace(files: SpaceFiles, idOrName: string, name: string, options?: {
33
+ defaultFolder?: string;
34
+ }): Promise<SpaceRegistry>;
33
35
  /** Adds a link between two stored spaces under the lock (design D5). */
34
36
  export declare function linkStoredSpace(files: SpaceFiles, idOrName: string, targetIdOrName: string): Promise<SpaceRegistry>;
35
37
  /** Removes a link between two stored spaces under the lock (design D5). */
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { SpaceError, archiveSpace, createSpace, linkSpace, readRegistryFile, removeSpace, renameSpace, switchSpace, unarchiveSpace, unlinkSpace, } from "./registry.js";
3
+ import { SpaceError, archiveSpace, createSpace, findSpace, linkSpace, listSpaces, readRegistryFile, removeSpace, renameSpace, switchSpace, unarchiveSpace, unlinkSpace, DEFAULT_SPACE_ID, } from "./registry.js";
4
+ import { writeSpaceLabel } from "./space-label.js";
4
5
  /**
5
6
  * The one write path for `spaces.json` (design D5). The web server, the CLI and the
6
7
  * `memory` tool all change the registry through `updateRegistry`. Readers never lock.
@@ -109,13 +110,25 @@ export async function updateRegistry(files, change) {
109
110
  }
110
111
  }
111
112
  }
112
- export async function createStoredSpace(files, name) {
113
+ export async function createStoredSpace(files, name, options = {}) {
113
114
  const { result } = await updateRegistry(files, (registry) => {
114
- const created = createSpace(registry, name, new Date().toISOString());
115
+ // A leftover folder must not be adopted by a new space, so its name counts as a taken id.
116
+ const folderIds = existsSync(files.spacesRoot) ? readdirSync(files.spacesRoot) : [];
117
+ const created = createSpace(registry, name, new Date().toISOString(), {
118
+ ...options,
119
+ avoidIds: [...(options.avoidIds ?? []), ...folderIds],
120
+ });
115
121
  // The folder comes first, so the registry never names a space that has no folder.
116
122
  mkdirSync(join(files.spacesRoot, created.space.id), { recursive: true });
117
123
  return { registry: created.registry, result: created.space };
118
124
  });
125
+ // The label only names the folder; a failed write must not fail the create.
126
+ try {
127
+ writeSpaceLabel(join(files.spacesRoot, result.id), result);
128
+ }
129
+ catch {
130
+ // Start-up backfills the label later.
131
+ }
119
132
  return result;
120
133
  }
121
134
  const simple = (apply) => async (files, idOrName) => (await updateRegistry(files, (registry) => ({
@@ -126,11 +139,27 @@ export const archiveStoredSpace = simple(archiveSpace);
126
139
  export const unarchiveStoredSpace = simple(unarchiveSpace);
127
140
  export const switchStoredSpace = simple(switchSpace);
128
141
  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;
142
+ export async function renameStoredSpace(files, idOrName, name, options = {}) {
143
+ const { registry, result: renamed } = await updateRegistry(files, (current) => {
144
+ // Found by id: the new name may equal another space's id, which findSpace would pick.
145
+ const id = findSpace(current, idOrName)?.id;
146
+ const next = renameSpace(current, idOrName, name);
147
+ return { registry: next, result: listSpaces(next).find((space) => space.id === id) };
148
+ });
149
+ if (renamed) {
150
+ // Default lives in the global storage folder, which only the caller knows.
151
+ const folder = renamed.id === DEFAULT_SPACE_ID ? options.defaultFolder : join(files.spacesRoot, renamed.id);
152
+ if (folder) {
153
+ // The label only names the folder; a failed write must not fail the rename.
154
+ try {
155
+ writeSpaceLabel(folder, renamed);
156
+ }
157
+ catch {
158
+ // Start-up backfills the label later.
159
+ }
160
+ }
161
+ }
162
+ return registry;
134
163
  }
135
164
  /** Adds a link between two stored spaces under the lock (design D5). */
136
165
  export async function linkStoredSpace(files, idOrName, targetIdOrName) {
@@ -28,7 +28,7 @@ export interface SpaceRegistry {
28
28
  /** User-created spaces only. Default is added by `listSpaces`. */
29
29
  spaces: SpaceEntry[];
30
30
  }
31
- export type SpaceErrorCode = "invalid-name" | "name-taken" | "id-taken" | "reserved" | "protected" | "active" | "archived" | "not-archived" | "unknown" | "invalid-id" | "unreadable" | "lock-timeout";
31
+ export type SpaceErrorCode = "invalid-name" | "name-taken" | "protected" | "active" | "archived" | "not-archived" | "unknown" | "invalid-id" | "unreadable" | "lock-timeout";
32
32
  /** A refused space operation. The message is safe to show to the user. */
33
33
  export declare class SpaceError extends Error {
34
34
  readonly code: SpaceErrorCode;
@@ -43,14 +43,20 @@ export interface SpacePaths {
43
43
  export declare function emptyRegistry(): SpaceRegistry;
44
44
  /** Every space in display order: Default first, then user spaces in creation order. */
45
45
  export declare function listSpaces(registry: SpaceRegistry): SpaceEntry[];
46
- /** Lowercase letters, digits and single hyphens, at most 40 characters. Empty when the name has none. */
47
- export declare function spaceIdFromName(name: string): string;
48
46
  /** True for a space id that is safe to use in a path: lowercase letters, digits and single hyphens. */
49
47
  export declare function isValidSpaceId(id: string): boolean;
50
48
  /** Finds a space by id, or by display name without regard to case. */
51
49
  export declare function findSpace(registry: SpaceRegistry, idOrName: string): SpaceEntry | undefined;
52
- /** Adds a space. Returns the new registry and the new entry. The active space does not change. */
53
- export declare function createSpace(registry: SpaceRegistry, name: string, now: string): {
50
+ /** Draws a random space id: `space-` plus 10 lowercase letters and digits (ADR for random ids). */
51
+ export declare function drawRandomSpaceId(): string;
52
+ export interface CreateSpaceOptions {
53
+ /** Draws a candidate id. Tests inject a fixed source. */
54
+ drawId?: () => string;
55
+ /** Ids to refuse beyond the registry, such as folders already on disk. */
56
+ avoidIds?: readonly string[];
57
+ }
58
+ /** Adds a space with a random id. Returns the new registry and the new entry. The active space does not change. */
59
+ export declare function createSpace(registry: SpaceRegistry, name: string, now: string, options?: CreateSpaceOptions): {
54
60
  registry: SpaceRegistry;
55
61
  space: SpaceEntry;
56
62
  };
@@ -1,3 +1,4 @@
1
+ import { randomInt } from "node:crypto";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { join, resolve, sep } from "node:path";
3
4
  /**
@@ -36,14 +37,6 @@ function defaultEntry(registry) {
36
37
  export function listSpaces(registry) {
37
38
  return [defaultEntry(registry), ...registry.spaces];
38
39
  }
39
- /** Lowercase letters, digits and single hyphens, at most 40 characters. Empty when the name has none. */
40
- export function spaceIdFromName(name) {
41
- const id = name
42
- .toLowerCase()
43
- .replace(/[^a-z0-9]+/g, "-")
44
- .replace(/^-+|-+$/g, "");
45
- return id.slice(0, MAX_SPACE_ID_LENGTH).replace(/-+$/, "");
46
- }
47
40
  const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
48
41
  /** True for a space id that is safe to use in a path: lowercase letters, digits and single hyphens. */
49
42
  export function isValidSpaceId(id) {
@@ -67,10 +60,10 @@ function requireSpace(registry, idOrName) {
67
60
  return space;
68
61
  }
69
62
  function validTargets(registry) {
70
- const names = listSpaces(registry)
63
+ const targets = listSpaces(registry)
71
64
  .filter((space) => !space.archived)
72
- .map((space) => space.id);
73
- return `Spaces that can become active: ${names.join(", ")}.`;
65
+ .map((space) => `${space.name} (${space.id})`);
66
+ return `Spaces that can become active: ${targets.join(", ")}.`;
74
67
  }
75
68
  function checkedName(name) {
76
69
  const trimmed = name.trim();
@@ -79,23 +72,38 @@ function checkedName(name) {
79
72
  }
80
73
  return trimmed;
81
74
  }
82
- /** Adds a space. Returns the new registry and the new entry. The active space does not change. */
83
- export function createSpace(registry, name, now) {
75
+ const RANDOM_ID_SUFFIX_LENGTH = 10;
76
+ const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
77
+ /** The most draws before createSpace gives up, so a broken source cannot loop forever. */
78
+ const MAX_ID_DRAWS = 100;
79
+ /** Draws a random space id: `space-` plus 10 lowercase letters and digits (ADR for random ids). */
80
+ export function drawRandomSpaceId() {
81
+ let suffix = "";
82
+ for (let i = 0; i < RANDOM_ID_SUFFIX_LENGTH; i++) {
83
+ suffix += ID_ALPHABET[randomInt(ID_ALPHABET.length)];
84
+ }
85
+ return `space-${suffix}`;
86
+ }
87
+ /** Adds a space with a random id. Returns the new registry and the new entry. The active space does not change. */
88
+ export function createSpace(registry, name, now, options = {}) {
84
89
  const display = checkedName(name);
85
90
  const existing = findByName(registry, display);
86
91
  if (existing) {
87
92
  throw new SpaceError("name-taken", `A space named "${existing.name}" already exists.`);
88
93
  }
89
- const id = spaceIdFromName(display);
90
- if (id === "") {
91
- throw new SpaceError("invalid-name", "A space name needs at least one letter or digit.");
92
- }
93
- if (id === DEFAULT_SPACE_ID) {
94
- throw new SpaceError("reserved", `The id "${DEFAULT_SPACE_ID}" belongs to the Default space.`);
95
- }
96
- const clash = registry.spaces.find((space) => space.id === id);
97
- if (clash) {
98
- throw new SpaceError("id-taken", `The name "${display}" gives the same folder id as "${clash.name}". Choose another name.`);
94
+ const drawId = options.drawId ?? drawRandomSpaceId;
95
+ const avoid = options.avoidIds ?? [];
96
+ let id = "";
97
+ for (let draws = 0;; draws++) {
98
+ if (draws >= MAX_ID_DRAWS) {
99
+ throw new SpaceError("invalid-id", "Could not draw an unused space id. Try again.");
100
+ }
101
+ id = drawId();
102
+ const taken = id === DEFAULT_SPACE_ID ||
103
+ avoid.includes(id) ||
104
+ registry.spaces.some((space) => space.id === id);
105
+ if (isValidSpaceId(id) && !taken)
106
+ break;
99
107
  }
100
108
  const space = { id, name: display, createdAt: now, archived: false };
101
109
  return { registry: { ...registry, spaces: [...registry.spaces, space] }, space };
@@ -72,7 +72,7 @@ async function createWithFreeName(files, name, now) {
72
72
  return await createStoredSpace(files, restoredName(name, now, attempt));
73
73
  }
74
74
  catch (error) {
75
- const taken = error instanceof SpaceError && ["name-taken", "id-taken"].includes(error.code);
75
+ const taken = error instanceof SpaceError && error.code === "name-taken";
76
76
  if (!taken || attempt >= 50)
77
77
  throw error;
78
78
  }
@@ -0,0 +1,16 @@
1
+ import { type SpaceEntry, type SpaceRegistry } from "./registry.js";
2
+ /** Writes the label of one space folder atomically: temp file in the folder, then rename. */
3
+ export declare function writeSpaceLabel(folder: string, space: SpaceEntry): void;
4
+ /**
5
+ * Adds or repairs the label of every existing space folder. A folder that does
6
+ * not exist is skipped: the pass never creates a folder and touches no other
7
+ * file. One failing folder does not stop the rest.
8
+ */
9
+ export declare function backfillSpaceLabels(registry: SpaceRegistry, files: {
10
+ spacesRoot: string;
11
+ }, globalStoragePath: string): void;
12
+ /**
13
+ * Reads the registry from the OMMS folder and runs the backfill pass over it.
14
+ * Best effort: callers wrap it so a failure never reaches start-up.
15
+ */
16
+ export declare function backfillSpaceLabelsFromDisk(ommsDir: string, globalStoragePath: string): void;
@@ -0,0 +1,69 @@
1
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { listSpaces, readRegistryFile, resolveSpaceFolder, } from "./registry.js";
4
+ /**
5
+ * The `space.json` label inside every space folder (space-folder-ids design). It
6
+ * names a folder found on disk. OMMS writes it but never reads it to decide
7
+ * anything: the registry stays the only source for ids, names and folders.
8
+ */
9
+ const LABEL_FILE = "space.json";
10
+ /** Writes the label of one space folder atomically: temp file in the folder, then rename. */
11
+ export function writeSpaceLabel(folder, space) {
12
+ const target = join(folder, LABEL_FILE);
13
+ const temporary = `${target}.${process.pid}.tmp`;
14
+ try {
15
+ writeFileSync(temporary, `${JSON.stringify(spaceLabelOf(space), null, 2)}\n`);
16
+ renameSync(temporary, target);
17
+ }
18
+ catch (error) {
19
+ if (existsSync(temporary))
20
+ unlinkSync(temporary);
21
+ throw error;
22
+ }
23
+ }
24
+ function spaceLabelOf(space) {
25
+ // The Default space has no creation time in the registry, so its label keeps the empty string.
26
+ return { id: space.id, name: space.name, createdAt: space.createdAt };
27
+ }
28
+ /** The name in the folder label, or undefined when the file is missing or broken. */
29
+ function labelName(folder) {
30
+ try {
31
+ const raw = JSON.parse(readFileSync(join(folder, LABEL_FILE), "utf-8"));
32
+ return typeof raw?.name === "string" ? raw.name : undefined;
33
+ }
34
+ catch {
35
+ // A missing or broken label is rewritten, never trusted.
36
+ return undefined;
37
+ }
38
+ }
39
+ /**
40
+ * Adds or repairs the label of every existing space folder. A folder that does
41
+ * not exist is skipped: the pass never creates a folder and touches no other
42
+ * file. One failing folder does not stop the rest.
43
+ */
44
+ export function backfillSpaceLabels(registry, files, globalStoragePath) {
45
+ for (const space of listSpaces(registry)) {
46
+ try {
47
+ const folder = resolveSpaceFolder(space.id, {
48
+ globalStoragePath,
49
+ spacesRoot: files.spacesRoot,
50
+ });
51
+ if (!existsSync(folder))
52
+ continue;
53
+ if (labelName(folder) === space.name)
54
+ continue;
55
+ writeSpaceLabel(folder, space);
56
+ }
57
+ catch {
58
+ // The label is metadata; the registry entry is what counts.
59
+ }
60
+ }
61
+ }
62
+ /**
63
+ * Reads the registry from the OMMS folder and runs the backfill pass over it.
64
+ * Best effort: callers wrap it so a failure never reaches start-up.
65
+ */
66
+ export function backfillSpaceLabelsFromDisk(ommsDir, globalStoragePath) {
67
+ const { registry } = readRegistryFile(join(ommsDir, "spaces.json"));
68
+ backfillSpaceLabels(registry, { spacesRoot: join(ommsDir, "spaces") }, globalStoragePath);
69
+ }
@@ -10,4 +10,6 @@ export type RouteModuleResult = RouteResult & {
10
10
  * ceiling. Each returns undefined for paths it does not own. The request body
11
11
  * is read once, and only by a route that needs it.
12
12
  */
13
- export declare function routeModules(path: string, method: string, url: URL, req: Request, ctx: SkillRouteContext): Promise<RouteModuleResult | undefined>;
13
+ export declare function routeModules(path: string, method: string, url: URL, req: Request, ctx: SkillRouteContext & {
14
+ remoteAddress?: string;
15
+ }): Promise<RouteModuleResult | undefined>;
@@ -26,7 +26,9 @@ export async function routeModules(path, method, url, req, ctx) {
26
26
  const transfers = await routeSpaceTransfers(path, method, req, ctx.cwd);
27
27
  if (transfers !== undefined)
28
28
  return transfers;
29
- const spaces = await routeSpaces(path, method, readBody, ctx.cwd);
29
+ const spaces = await routeSpaces(path, method, readBody, ctx.cwd, {
30
+ remoteAddress: ctx.remoteAddress,
31
+ });
30
32
  if (spaces !== undefined)
31
33
  return spaces;
32
34
  return routeChat(path, method, readBody, config);
@@ -1375,6 +1375,7 @@ export class WebServer {
1375
1375
  }
1376
1376
  const routed = await routeModules(path, method, url, req, {
1377
1377
  cwd: this.config.directory ?? process.cwd(),
1378
+ remoteAddress,
1378
1379
  startRecordPast: async (cwd) => (await import("../importer/web-import-api.js")).startRecordPast(this.importJobs, cwd),
1379
1380
  });
1380
1381
  if (routed)
package/dist/v2/plugin.js CHANGED
@@ -1,8 +1,64 @@
1
+ import { fileURLToPath } from "node:url";
1
2
  import { OmmsPlugin } from "../index.js";
2
3
  import { loadOpencodeProvider } from "../adapters/opencode/opencode-provider-loader.js";
3
4
  import { registerV2Adapter } from "./adapter.js";
4
5
  import { createV2MemoryBridge } from "./memory-bridge.js";
5
6
  import { createLegacyClient } from "./legacy-client.js";
7
+ /**
8
+ * Remove old OMMS copies from the OpenCode cache. It runs in the background and
9
+ * is not awaited, so a slow or failing clean-up never delays or breaks setup.
10
+ * `OMMS_DISABLE_UPDATE_CHECK` does not stop it: removing old copies saves disk
11
+ * space and installs nothing. It keeps the copy that `~/.omms/runtime.json` names.
12
+ */
13
+ function startCacheCleanup() {
14
+ void (async () => {
15
+ const modulePath = fileURLToPath(import.meta.url);
16
+ const [{ installedFromNpm }, { pruneOldCacheCopies }, { readRuntimeRecord }, { ommsDir }, { packageVersion }, { log },] = await Promise.all([
17
+ import("../services/host-self-update.js"),
18
+ import("../services/host-copies.js"),
19
+ import("../services/runtime-record.js"),
20
+ import("../services/runtime-handoff.js"),
21
+ import("../services/package-version.js"),
22
+ import("../services/logger.js"),
23
+ ]);
24
+ if (!installedFromNpm(modulePath))
25
+ return;
26
+ const recorded = readRuntimeRecord(ommsDir());
27
+ await pruneOldCacheCopies({
28
+ modulePath,
29
+ runningVersion: packageVersion(),
30
+ keepRoots: recorded ? [recorded.root] : [],
31
+ now: Date.now(),
32
+ log: (message, data) => log(message, data),
33
+ });
34
+ })().catch(() => { });
35
+ }
36
+ let serviceUpdateStarted = false;
37
+ /**
38
+ * Check npm for a newer OMMS from the background service too, so OpenCode
39
+ * without a TUI (OpenChamber, `opencode serve`) also updates. Once per
40
+ * process: setup can run for each project. Not awaited; a failure is ignored.
41
+ */
42
+ function startServiceSelfUpdate() {
43
+ if (serviceUpdateStarted)
44
+ return;
45
+ serviceUpdateStarted = true;
46
+ void (async () => {
47
+ const modulePath = fileURLToPath(import.meta.url);
48
+ const [{ opencodeSelfUpdateDeps, startOpencodeServiceSelfUpdate }, { startHostSelfUpdate }, { packageVersion }, { log },] = await Promise.all([
49
+ import("../adapters/opencode/self-update.js"),
50
+ import("../services/host-self-update.js"),
51
+ import("../services/package-version.js"),
52
+ import("../services/logger.js"),
53
+ ]);
54
+ const deps = opencodeSelfUpdateDeps({
55
+ modulePath,
56
+ currentVersion: packageVersion(),
57
+ log: (message, data) => log(message, data),
58
+ });
59
+ startOpencodeServiceSelfUpdate({ run: () => startHostSelfUpdate(deps) });
60
+ })().catch(() => { });
61
+ }
6
62
  const OmmsPluginV2 = {
7
63
  id: "omms",
8
64
  async setup(ctx) {
@@ -18,7 +74,10 @@ const OmmsPluginV2 = {
18
74
  // context. Route internal structured-output calls through the adapter.
19
75
  const { setV2Client } = await loadOpencodeProvider();
20
76
  setV2Client(legacyClient);
21
- return registerV2Adapter(ctx, legacy, createV2MemoryBridge(ctx.location.directory));
77
+ const adapter = await registerV2Adapter(ctx, legacy, createV2MemoryBridge(ctx.location.directory));
78
+ startCacheCleanup();
79
+ startServiceSelfUpdate();
80
+ return adapter;
22
81
  },
23
82
  };
24
83
  export default OmmsPluginV2;