om-memory-system 5.7.0-next.218 → 5.9.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 (38) 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/cli/web-command.js +1 -1
  10. package/dist/config.js +11 -1
  11. package/dist/services/host-copies.d.ts +52 -0
  12. package/dist/services/host-copies.js +150 -0
  13. package/dist/services/host-self-update.d.ts +108 -0
  14. package/dist/services/host-self-update.js +254 -0
  15. package/dist/services/space-routes.d.ts +10 -1
  16. package/dist/services/space-routes.js +63 -20
  17. package/dist/services/spaces/backup.js +3 -0
  18. package/dist/services/spaces/operations.js +3 -1
  19. package/dist/services/spaces/registry-store.d.ts +5 -3
  20. package/dist/services/spaces/registry-store.js +38 -9
  21. package/dist/services/spaces/registry.d.ts +11 -5
  22. package/dist/services/spaces/registry.js +31 -23
  23. package/dist/services/spaces/restore.js +1 -1
  24. package/dist/services/spaces/space-label.d.ts +16 -0
  25. package/dist/services/spaces/space-label.js +69 -0
  26. package/dist/services/web-route-modules.d.ts +3 -1
  27. package/dist/services/web-route-modules.js +3 -1
  28. package/dist/services/web-server.d.ts +1 -1
  29. package/dist/services/web-server.js +2 -1
  30. package/dist/services/web-update-runtime.d.ts +1 -1
  31. package/dist/services/web-update-runtime.js +2 -1
  32. package/dist/services/web-update.d.ts +8 -0
  33. package/dist/services/web-update.js +27 -0
  34. package/dist/v2/plugin.js +60 -1
  35. package/dist/web/assets/index-Bhk1P5Z7.js +170 -0
  36. package/dist/web/index.html +1 -1
  37. package/package.json +1 -1
  38. package/dist/web/assets/index-C6WnDE6h.js +0 -170
@@ -0,0 +1,19 @@
1
+ import { type HostSelfUpdateDeps } from "../../services/host-self-update.js";
2
+ /** The background service checks npm at start and then this often, like the footer. */
3
+ export declare const OPENCODE_SERVICE_CHECK_MS: number;
4
+ /** Production inputs for OpenCode: npm, the OpenCode marker file and a detached `opencode plugin update`. */
5
+ export declare function opencodeSelfUpdateDeps(options: {
6
+ modulePath: string;
7
+ currentVersion: string;
8
+ log: HostSelfUpdateDeps["log"];
9
+ home?: string;
10
+ env?: NodeJS.ProcessEnv;
11
+ }): HostSelfUpdateDeps;
12
+ /**
13
+ * Run the update check now and then every `intervalMs`. A failed check never
14
+ * throws. The timer does not keep the process alive. Returns a stop function.
15
+ */
16
+ export declare function startOpencodeServiceSelfUpdate(options: {
17
+ run: () => unknown;
18
+ intervalMs?: number;
19
+ }): () => void;
@@ -0,0 +1,42 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { hostSelfUpdateDeps, installedFromNpm, unpinnedCacheFolder, } from "../../services/host-self-update.js";
4
+ // OpenCode's update inputs, shared by the TUI footer and the background
5
+ // service. The service runs the check too, so OpenCode without a TUI (for
6
+ // example OpenChamber or `opencode serve`) also updates. The marker file stops
7
+ // the two from starting the same update twice.
8
+ /** The background service checks npm at start and then this often, like the footer. */
9
+ export const OPENCODE_SERVICE_CHECK_MS = 6 * 3_600_000;
10
+ /** Production inputs for OpenCode: npm, the OpenCode marker file and a detached `opencode plugin update`. */
11
+ export function opencodeSelfUpdateDeps(options) {
12
+ const { modulePath } = options;
13
+ return hostSelfUpdateDeps({
14
+ logLabel: "OpenCode plugin self-update",
15
+ currentVersion: options.currentVersion,
16
+ command: "opencode",
17
+ args: ["plugin", "update", "om-memory-system"],
18
+ markerPath: join(options.home ?? homedir(), ".omms", "opencode-plugin-update.json"),
19
+ modulePath,
20
+ installSource: () => !installedFromNpm(modulePath) ? "local" : unpinnedCacheFolder(modulePath) ? "npm" : "pinned",
21
+ log: options.log,
22
+ ...(options.env ? { env: options.env } : {}),
23
+ });
24
+ }
25
+ /**
26
+ * Run the update check now and then every `intervalMs`. A failed check never
27
+ * throws. The timer does not keep the process alive. Returns a stop function.
28
+ */
29
+ export function startOpencodeServiceSelfUpdate(options) {
30
+ const check = () => {
31
+ try {
32
+ void Promise.resolve(options.run()).catch(() => { });
33
+ }
34
+ catch {
35
+ // The update is housekeeping. It never breaks the service.
36
+ }
37
+ };
38
+ check();
39
+ const timer = setInterval(check, options.intervalMs ?? OPENCODE_SERVICE_CHECK_MS);
40
+ timer.unref?.();
41
+ return () => clearInterval(timer);
42
+ }
@@ -1,7 +1,10 @@
1
+ import { type HostSelfUpdateResult, type HostUpdateStatus } from "../../services/host-self-update.js";
1
2
  /** What the OpenCode footer shows: the web app's state and a newer release, if any. */
2
3
  export interface TuiStatusState {
3
4
  web: "warming" | "connected" | "offline";
4
5
  update: string | null;
6
+ /** "installed" once the background update has finished; otherwise "available". */
7
+ updateState?: "available" | "installed";
5
8
  }
6
9
  export declare function tuiStatusText(state: TuiStatusState): string;
7
10
  export interface TuiStatusDeps {
@@ -9,15 +12,22 @@ export interface TuiStatusDeps {
9
12
  healthUrl: string;
10
13
  fetch: typeof fetch;
11
14
  setText: (text: string) => void;
12
- /** Shown once per start when a newer release is on npm. */
15
+ /** Shown once per update state and version. */
13
16
  notify: (message: string) => void;
14
17
  checkUpdates?: boolean;
15
18
  healthIntervalMs?: number;
16
19
  updateIntervalMs?: number;
20
+ /** Reads npm and starts the background update. Without it the check only reads npm. */
21
+ selfUpdate?: () => Promise<HostSelfUpdateResult>;
22
+ /** The state the update marker records for a version. */
23
+ readUpdateStatus?: (version: string) => HostUpdateStatus | null;
24
+ watchIntervalMs?: number;
25
+ watchLimitMs?: number;
17
26
  }
18
27
  /**
19
28
  * Keep the footer text current: the web app is polled, and npm is asked for a
20
- * newer release at start and then every few hours. Returns a stop function.
29
+ * newer release at start and then every few hours. A newer release installs in
30
+ * the background when the host allows it. Returns a stop function.
21
31
  */
22
32
  export declare function startTuiStatus(deps: TuiStatusDeps): () => void;
23
33
  /** Wire the status to this install: its version, the configured web app port, and global fetch. */
@@ -1,17 +1,27 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { hostUpdateStatus, startHostSelfUpdate, updateNotice, updateSuffix, watchHostUpdate, } from "../../services/host-self-update.js";
1
3
  import { ommsStatusLabel } from "../../services/status-glyphs.js";
4
+ import { opencodeSelfUpdateDeps } from "./self-update.js";
2
5
  import { availableUpdate, latestNpmVersion } from "../../services/update-check.js";
6
+ const UPDATE_COMMAND = "opencode plugin update om-memory-system";
7
+ // The background service keeps the old copy loaded until it restarts too.
8
+ const RESTART = "restart OpenCode and run opencode service restart";
3
9
  export function tuiStatusText(state) {
4
10
  const web = ommsStatusLabel(state.web === "offline" ? "web app off" : state.web);
5
- return state.update ? `${web} · ${state.update} available` : web;
11
+ return state.update
12
+ ? `${web}${updateSuffix(state.update, state.updateState ?? "available")}`
13
+ : web;
6
14
  }
7
15
  /**
8
16
  * Keep the footer text current: the web app is polled, and npm is asked for a
9
- * newer release at start and then every few hours. Returns a stop function.
17
+ * newer release at start and then every few hours. A newer release installs in
18
+ * the background when the host allows it. Returns a stop function.
10
19
  */
11
20
  export function startTuiStatus(deps) {
12
21
  const state = { web: "warming", update: null };
13
22
  let stopped = false;
14
- let notified = null;
23
+ let stopWatch;
24
+ const shown = new Set();
15
25
  const show = () => {
16
26
  if (!stopped)
17
27
  deps.setText(tuiStatusText(state));
@@ -26,13 +36,65 @@ export function startTuiStatus(deps) {
26
36
  }
27
37
  show();
28
38
  };
29
- const checkUpdate = async () => {
30
- const update = availableUpdate(deps.currentVersion, await latestNpmVersion(deps.fetch));
31
- state.update = update;
39
+ /** One toast per update state and version. */
40
+ const toast = (update, notice) => {
41
+ const key = `${notice}:${update}`;
42
+ if (stopped || shown.has(key))
43
+ return;
44
+ shown.add(key);
45
+ deps.notify(updateNotice({
46
+ hostName: "OpenCode",
47
+ command: UPDATE_COMMAND,
48
+ restart: RESTART,
49
+ current: deps.currentVersion,
50
+ update,
51
+ state: notice,
52
+ }));
53
+ };
54
+ const settle = (update, outcome) => {
55
+ state.updateState = outcome === "installed" ? "installed" : "available";
32
56
  show();
33
- if (update && notified !== update && !stopped) {
34
- notified = update;
35
- deps.notify(`om-memory-system ${update} is available (running ${deps.currentVersion}). Run: opencode plugin update om-memory-system, then restart OpenCode.`);
57
+ toast(update, outcome === "installed" ? "installed" : "available");
58
+ };
59
+ const selfUpdate = deps.selfUpdate ??
60
+ (async () => ({
61
+ code: "local",
62
+ update: availableUpdate(deps.currentVersion, await latestNpmVersion(deps.fetch)),
63
+ }));
64
+ const checkUpdate = async () => {
65
+ try {
66
+ // The restart installs the release; there is nothing more to ask npm.
67
+ if (state.updateState === "installed")
68
+ return;
69
+ const { code, update } = await selfUpdate();
70
+ state.update = update;
71
+ state.updateState = "available";
72
+ show();
73
+ if (!update || stopped)
74
+ return;
75
+ if (code !== "started" && code !== "recent")
76
+ return toast(update, "available");
77
+ const status = (deps.readUpdateStatus ?? (() => null))(update);
78
+ if (status === "installed" || status === "failed")
79
+ return settle(update, status);
80
+ toast(update, "installing");
81
+ stopWatch?.();
82
+ stopWatch = watchHostUpdate({
83
+ read: () => (deps.readUpdateStatus ?? (() => null))(update),
84
+ onSettled: (outcome) => {
85
+ try {
86
+ settle(update, outcome);
87
+ }
88
+ catch {
89
+ // A failed toast must not break the timer.
90
+ }
91
+ },
92
+ intervalMs: deps.watchIntervalMs,
93
+ maxMs: deps.watchLimitMs,
94
+ });
95
+ }
96
+ catch {
97
+ // A failed update check must not break the footer.
36
98
  }
37
99
  };
38
100
  show();
@@ -47,20 +109,31 @@ export function startTuiStatus(deps) {
47
109
  timer.unref?.();
48
110
  return () => {
49
111
  stopped = true;
112
+ stopWatch?.();
50
113
  for (const timer of timers)
51
114
  clearInterval(timer);
52
115
  };
53
116
  }
54
117
  /** Wire the status to this install: its version, the configured web app port, and global fetch. */
55
118
  export async function startInstalledTuiStatus(callbacks) {
56
- const [{ CONFIG }, { packageVersion }] = await Promise.all([
119
+ const [{ CONFIG }, { packageVersion }, { log }] = await Promise.all([
57
120
  import("../../config.js"),
58
121
  import("../../services/package-version.js"),
122
+ import("../../services/logger.js"),
59
123
  ]);
60
124
  const host = CONFIG.webServerHost === "0.0.0.0" ? "127.0.0.1" : CONFIG.webServerHost;
125
+ const currentVersion = packageVersion();
126
+ const modulePath = fileURLToPath(import.meta.url);
127
+ const update = opencodeSelfUpdateDeps({
128
+ modulePath,
129
+ currentVersion,
130
+ log: (message, data) => log(message, data),
131
+ });
61
132
  return startTuiStatus({
62
133
  ...callbacks,
63
- currentVersion: packageVersion(),
134
+ currentVersion,
135
+ selfUpdate: () => startHostSelfUpdate(update),
136
+ readUpdateStatus: (version) => hostUpdateStatus(update, version),
64
137
  healthUrl: `http://${host.includes(":") ? `[${host}]` : host}:${CONFIG.webServerPort}/api/health`,
65
138
  fetch: globalThis.fetch.bind(globalThis),
66
139
  checkUpdates: process.env.OMMS_DISABLE_UPDATE_CHECK !== "1",
@@ -14,6 +14,7 @@ import { registerPiSkillsTool } from "./skills-tool.js";
14
14
  import { performPiProfileLearning } from "./profile.js";
15
15
  import { createPiRecallRows } from "./recall-rows.js";
16
16
  import { createPiSessionContext } from "./session-context.js";
17
+ import { createPiSelfUpdate } from "./self-update.js";
17
18
  import { DEFAULT_RETRIEVAL_MAX_TOKENS, tokensToByteCeiling } from "../../core/context-budget.js";
18
19
  import { ommsStatusLabel } from "../../services/status-glyphs.js";
19
20
  const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("omms.plugin.warmedup");
@@ -82,21 +83,30 @@ export default function ommsPiExtension(pi) {
82
83
  let latestCtx = null;
83
84
  let backfillSessionId = null;
84
85
  let statusVersion = 0;
86
+ // The glyph on screen, so a new update suffix can redraw the label. Null once cleared.
87
+ let shownStatus = null;
88
+ const selfUpdate = createPiSelfUpdate();
85
89
  const recallRows = createPiRecallRows();
86
90
  const sessionContext = createPiSessionContext(recallRows);
91
+ // Every label carries the update suffix, so it stays when the glyph changes.
92
+ const drawStatus = (ctx, status) => {
93
+ shownStatus = status;
94
+ if (ctx.hasUI)
95
+ ctx.ui.setStatus(OMMS_STATUS_KEY, ommsStatusLabel(status) + selfUpdate.suffix());
96
+ };
87
97
  const startStatus = (ctx, status) => {
88
98
  const version = ++statusVersion;
89
- if (ctx.hasUI)
90
- ctx.ui.setStatus(OMMS_STATUS_KEY, ommsStatusLabel(status));
99
+ drawStatus(ctx, status);
91
100
  return version;
92
101
  };
93
102
  const finishStatus = (ctx, version, status) => {
94
103
  if (version !== statusVersion || !ctx.hasUI)
95
104
  return;
96
- ctx.ui.setStatus(OMMS_STATUS_KEY, ommsStatusLabel(status));
105
+ drawStatus(ctx, status);
97
106
  };
98
107
  const clearStatus = (ctx) => {
99
108
  statusVersion++;
109
+ shownStatus = null;
100
110
  if (ctx.hasUI)
101
111
  ctx.ui.setStatus(OMMS_STATUS_KEY, undefined);
102
112
  };
@@ -108,6 +118,11 @@ export default function ommsPiExtension(pi) {
108
118
  };
109
119
  pi.on("session_start", async (event, ctx) => {
110
120
  const status = startStatus(ctx, "warming");
121
+ // The update check does not delay the session and cannot stop memory work.
122
+ void selfUpdate.start({ hasUI: ctx.hasUI, notify: (message, level) => ctx.ui.notify(message, level) }, () => {
123
+ if (shownStatus)
124
+ drawStatus(ctx, shownStatus);
125
+ });
111
126
  try {
112
127
  latestCtx = ctx;
113
128
  initConfigWithLegacyMigration(ctx.cwd);
@@ -309,6 +324,7 @@ export default function ommsPiExtension(pi) {
309
324
  }
310
325
  });
311
326
  pi.on("session_shutdown", async (_event, ctx) => {
327
+ selfUpdate.stop();
312
328
  clearStatus(ctx);
313
329
  recallRows.reset(ctx);
314
330
  sessionContext.reset();
@@ -0,0 +1,25 @@
1
+ import { watchHostUpdate, type HostSelfUpdateDeps } from "../../services/host-self-update.js";
2
+ /** What the update needs from one Pi session. */
3
+ export interface PiUpdateSession {
4
+ hasUI: boolean;
5
+ notify: (message: string, level: "info" | "warning") => void;
6
+ }
7
+ export interface PiSelfUpdate {
8
+ /** Check npm and start the update. Never rejects. The caller does not wait for it. */
9
+ start(session: PiUpdateSession, onSuffixChange: () => void): Promise<void>;
10
+ /** Footer text to add after the status glyph, or an empty string. */
11
+ suffix(): string;
12
+ /** Stop the marker poll and drop the session. Keeps the suffix for the next session. */
13
+ stop(): void;
14
+ }
15
+ export interface PiSelfUpdateOptions {
16
+ hostDeps?: () => HostSelfUpdateDeps;
17
+ watch?: typeof watchHostUpdate;
18
+ log?: (message: string, data: Record<string, unknown>, level?: "error") => void;
19
+ }
20
+ /** Production inputs for Pi: npm, the Pi marker file and a detached `pi update`. */
21
+ export declare function piHostSelfUpdateDeps(options?: {
22
+ env?: NodeJS.ProcessEnv;
23
+ modulePath?: string;
24
+ }): HostSelfUpdateDeps;
25
+ export declare function createPiSelfUpdate(options?: PiSelfUpdateOptions): PiSelfUpdate;
@@ -0,0 +1,146 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { hostSelfUpdateDeps, hostUpdateStatus, installedFromNpm, startHostSelfUpdate, unpinnedPackageSource, updateNotice, updateSuffix, watchHostUpdate, } from "../../services/host-self-update.js";
6
+ import { log } from "../../services/logger.js";
7
+ import { packageVersion } from "../../services/package-version.js";
8
+ // Pi does not update its packages by itself. At session start OMMS runs
9
+ // `pi update npm:om-memory-system` in the background when npm has a newer
10
+ // release, then tells the user to restart. The decision lives in
11
+ // `src/services/host-self-update.ts`; this file adds the Pi wording, the
12
+ // footer suffix and the install check.
13
+ const PI_UPDATE_ARGS = ["update", "npm:om-memory-system"];
14
+ const PI_UPDATE_COMMAND = `pi ${PI_UPDATE_ARGS.join(" ")}`;
15
+ /** Pi's settings file lists the package source. `PI_CODING_AGENT_DIR` moves Pi's folder. */
16
+ function readPiSettings(env) {
17
+ const dir = env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
18
+ try {
19
+ return readFileSync(join(dir, "settings.json"), "utf8");
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** Production inputs for Pi: npm, the Pi marker file and a detached `pi update`. */
26
+ export function piHostSelfUpdateDeps(options = {}) {
27
+ const env = options.env ?? process.env;
28
+ const modulePath = options.modulePath ?? fileURLToPath(import.meta.url);
29
+ const installSource = () => {
30
+ if (!installedFromNpm(modulePath))
31
+ return "local";
32
+ return unpinnedPackageSource(readPiSettings(env)) ? "npm" : "pinned";
33
+ };
34
+ return hostSelfUpdateDeps({
35
+ logLabel: "Pi plugin self-update",
36
+ currentVersion: packageVersion(),
37
+ command: "pi",
38
+ args: PI_UPDATE_ARGS,
39
+ markerPath: join(homedir(), ".omms", "pi-plugin-update.json"),
40
+ modulePath,
41
+ installSource,
42
+ log,
43
+ env,
44
+ });
45
+ }
46
+ export function createPiSelfUpdate(options = {}) {
47
+ const hostDeps = options.hostDeps ?? (() => piHostSelfUpdateDeps());
48
+ const watch = options.watch ?? watchHostUpdate;
49
+ const logError = options.log ?? log;
50
+ // One notification per state per version for the life of the Pi process.
51
+ const notified = new Set();
52
+ let suffix = "";
53
+ let stopWatch = null;
54
+ let generation = 0;
55
+ let current = null;
56
+ // Sessions that start while a check runs share it, so one release spawns one update.
57
+ let inFlight = null;
58
+ // Once installed, only a restart helps: a later session must not run the update again.
59
+ let installed = false;
60
+ const attempt = (action) => {
61
+ try {
62
+ action();
63
+ }
64
+ catch {
65
+ // A stale Pi context must not stop the update path.
66
+ }
67
+ };
68
+ const notifyOnce = (key, message, level) => {
69
+ if (!current || notified.has(key))
70
+ return;
71
+ notified.add(key);
72
+ const { session } = current;
73
+ attempt(() => session.notify(message, level));
74
+ };
75
+ const show = (deps, update, state) => {
76
+ if (state === "installed")
77
+ installed = true;
78
+ if (state !== "installing") {
79
+ const next = updateSuffix(update, state);
80
+ if (next !== suffix) {
81
+ suffix = next;
82
+ const change = current?.onSuffixChange;
83
+ if (change)
84
+ attempt(change);
85
+ }
86
+ }
87
+ const text = updateNotice({
88
+ hostName: "Pi",
89
+ command: PI_UPDATE_COMMAND,
90
+ current: deps.currentVersion,
91
+ update,
92
+ state,
93
+ });
94
+ notifyOnce(`${state}:${update}`, text, state === "available" ? "warning" : "info");
95
+ };
96
+ return {
97
+ async start(session, onSuffixChange) {
98
+ const mine = ++generation;
99
+ stopWatch?.();
100
+ stopWatch = null;
101
+ current = session.hasUI ? { session, onSuffixChange } : null;
102
+ if (installed)
103
+ return;
104
+ try {
105
+ const deps = hostDeps();
106
+ inFlight ??= startHostSelfUpdate(deps).finally(() => {
107
+ inFlight = null;
108
+ });
109
+ const { code, update } = await inFlight;
110
+ // No interface, no label or notice. A shutdown or newer session replaces this one.
111
+ if (!update || !session.hasUI || mine !== generation)
112
+ return;
113
+ if (code !== "started" && code !== "recent") {
114
+ show(deps, update, "available");
115
+ return;
116
+ }
117
+ const read = () => hostUpdateStatus(deps, update);
118
+ const status = read();
119
+ if (status === "installed")
120
+ show(deps, update, "installed");
121
+ else if (status === "failed")
122
+ show(deps, update, "available");
123
+ else {
124
+ show(deps, update, "installing");
125
+ stopWatch = watch({
126
+ read,
127
+ onSettled: (outcome) => {
128
+ if (mine === generation)
129
+ show(deps, update, outcome === "installed" ? "installed" : "available");
130
+ },
131
+ });
132
+ }
133
+ }
134
+ catch (error) {
135
+ logError("Pi plugin self-update failed", { code: error instanceof Error ? error.name : "unknown" }, "error");
136
+ }
137
+ },
138
+ suffix: () => suffix,
139
+ stop() {
140
+ generation++;
141
+ stopWatch?.();
142
+ stopWatch = null;
143
+ current = null;
144
+ },
145
+ };
146
+ }
@@ -107,6 +107,18 @@ function parseSpaceArgs(argv) {
107
107
  parsed.directory = parsed.values.get("directory");
108
108
  return parsed;
109
109
  }
110
+ /** One line per space for the list action: the display name first, the id after it. */
111
+ function spaceListLines(result) {
112
+ const spaces = (result.spaces ?? []);
113
+ return spaces.map((space) => {
114
+ let line = `${space.name} (${space.id})`;
115
+ if (space.active)
116
+ line += " [active]";
117
+ if (space.archived)
118
+ line += " [archived]";
119
+ return line;
120
+ });
121
+ }
110
122
  /** Run `om-memory-system space ...` and return the process exit code. */
111
123
  export async function runSpaceCommand(argv, io = {}) {
112
124
  const stdout = io.stdout ?? ((text) => console.log(text));
@@ -184,6 +196,14 @@ export async function runSpaceCommand(argv, io = {}) {
184
196
  catch (error) {
185
197
  result = { success: false, error: error instanceof Error ? error.message : String(error) };
186
198
  }
187
- stdout(JSON.stringify(result, null, 2));
199
+ if (action === "list" && result.success !== false) {
200
+ // The list action prints one text line per space; every other action prints JSON.
201
+ if (typeof result.problem === "string")
202
+ stderr(result.problem);
203
+ stdout(spaceListLines(result).join("\n"));
204
+ }
205
+ else {
206
+ stdout(JSON.stringify(result, null, 2));
207
+ }
188
208
  return result.success === false ? 1 : 0;
189
209
  }
@@ -156,7 +156,7 @@ export async function runWebCommand(args, options = {}, online, handover = {}, u
156
156
  const { existsSync } = await import("node:fs");
157
157
  // Every restart onto the new copy runs the launcher, the login item's included.
158
158
  const canRestart = () => existsSync(launcherPath(ommsDir()));
159
- server.setWebUpdate(startWebUpdate(() => powerAction("update"), canRestart));
159
+ server.setWebUpdate(startWebUpdate(() => powerAction("update"), canRestart, () => server.powerActionRunning));
160
160
  // A newer `web install` asked this web app to give up the port.
161
161
  server.setOnStepAside(async () => {
162
162
  clearInterval(keeper);
package/dist/config.js CHANGED
@@ -20,6 +20,7 @@ import { checkedKeywordBoost, checkedKeywordMode, } from "./utils/keyword-boost.
20
20
  import { runLegacyStoreMigration, legacyMigrationPaths } from "./services/legacy-migration.js";
21
21
  import { expandPath, storagePathFrom, withSkillStorage, } from "./services/spaces/storage-resolution.js";
22
22
  import { resolveActiveSpace } from "./services/spaces/active-space.js";
23
+ import { backfillSpaceLabelsFromDisk } from "./services/spaces/space-label.js";
23
24
  import { resetStoreHandles } from "./services/spaces/store-handles.js";
24
25
  const OMMS_CONFIG_DIR = join(homedir(), ".config", "omms");
25
26
  const DATA_DIR = join(homedir(), ".omms");
@@ -555,7 +556,16 @@ export function initConfig(directory, options = {}) {
555
556
  delete projectOverrides.webServerEnabled;
556
557
  const merged = { ...globalConfig, ...projectOverrides };
557
558
  // The active memory space picks the storage folder unless the project sets its own (ADR-041).
558
- activeSpace = resolveActiveSpace(DATA_DIR, storagePathFrom(globalConfig), "storagePath" in projectConfig);
559
+ const globalStoragePath = storagePathFrom(globalConfig);
560
+ activeSpace = resolveActiveSpace(DATA_DIR, globalStoragePath, "storagePath" in projectConfig);
561
+ // The label pass repairs space.json files at the first registry read. It is
562
+ // metadata only, so a failure must never block start-up.
563
+ try {
564
+ backfillSpaceLabelsFromDisk(DATA_DIR, globalStoragePath);
565
+ }
566
+ catch {
567
+ // Best effort: the registry stays the source of truth for names.
568
+ }
559
569
  if (activeSpace.storagePath)
560
570
  merged.storagePath = activeSpace.storagePath;
561
571
  const nextConfig = withSkillStorage(buildConfig(merged), globalConfig);
@@ -0,0 +1,52 @@
1
+ /** The clean-up leaves everything alone until the running copy is this old. */
2
+ export declare const CACHE_PRUNE_MIN_AGE_MS: number;
3
+ export interface HostCopy {
4
+ /** The package folder: `.../node_modules/om-memory-system`. */
5
+ root: string;
6
+ /** The `version` in its `package.json`, or null when that cannot be read. */
7
+ version: string | null;
8
+ /** The timestamp folder name as a number (install time in ms since 1970). */
9
+ stamp: number;
10
+ }
11
+ export interface HostCopies {
12
+ root: string;
13
+ version: string | null;
14
+ /** The running copy's timestamp, or null when its folder is not a timestamp folder in an OpenCode cache. */
15
+ stamp: number | null;
16
+ /** Every other timestamp folder in the same cache folder. Empty without a timestamp. */
17
+ siblings: HostCopy[];
18
+ }
19
+ /**
20
+ * The running copy and, when it sits in a timestamp folder, the other copies in
21
+ * the same cache folder. Null when `modulePath` is not inside
22
+ * `node_modules/om-memory-system/`. Never throws.
23
+ */
24
+ export declare function listHostCopies(modulePath: string): HostCopies | null;
25
+ /**
26
+ * The versions on disk: the running copy's and each sibling's. Pi updates in
27
+ * place, so its running copy shows the new version after `pi update`. OpenCode
28
+ * shows it in a new sibling folder. Never throws.
29
+ */
30
+ export declare function installedVersions(modulePath: string): string[];
31
+ export interface PruneOptions {
32
+ modulePath: string;
33
+ runningVersion: string;
34
+ /** Package folders that must stay, such as the copy `~/.omms/runtime.json` records. */
35
+ keepRoots: string[];
36
+ now: number;
37
+ /** Removes a folder and everything in it. Defaults to an asynchronous `rm`, so a large folder does not block the service. */
38
+ remove?: (folder: string) => void | Promise<void>;
39
+ log: (message: string, data: Record<string, unknown>) => void | Promise<void>;
40
+ }
41
+ /**
42
+ * Remove old OMMS copies from the running copy's cache folder. A copy goes only
43
+ * when its timestamp and its version are both older than the running copy's,
44
+ * it is not in `keepRoots`, and the running copy is at least
45
+ * `CACHE_PRUNE_MIN_AGE_MS` old. A copy that cannot be removed stays. Logs one
46
+ * `OpenCode cache clean-up` record with counts for each pass in a timestamp
47
+ * folder, also when the counts are zero. Never throws.
48
+ */
49
+ export declare function pruneOldCacheCopies(options: PruneOptions): Promise<{
50
+ removed: number;
51
+ kept: number;
52
+ }>;