@pleri/olam-cli 0.1.78 → 0.1.79

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.
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "auth": "sha256:ee6b1d1046933c76acc8d28cd83ac3871bc69057d2401014252872dc5ec42db7",
3
- "devbox": "sha256:d68dc8b55aecdedd2f2af6eb535673b68733f262e882be008d74fb1b99f93c43",
4
- "host-cp": "sha256:9aa95a49f058da39e402862bcb6e9a9a8272c5246577cfa58770165e2ee84b31",
3
+ "devbox": "sha256:33f96024c3627ecc41592f4ef777b38c72537d9fc3674ee7e37f0074015bf514",
4
+ "host-cp": "sha256:bd752b9631b1f593573c51e76bd7488d86527e822a9f4f53d1f16d43ecab4c75",
5
5
  "mcp-auth": "sha256:e47169ad3fbc9cab216248fecbc56874343a5daab84b1f18d67529b7d415cf95",
6
6
  "$schema_version": 1,
7
- "$published_version": "0.1.78",
7
+ "$published_version": "0.1.79",
8
8
  "$registry": "ghcr.io/pleri"
9
9
  }
@@ -132,6 +132,13 @@ services:
132
132
  # currently-running host-cp image (which has the olam CLI +
133
133
  # docker CLI + gh CLI baked in by Dockerfile).
134
134
  OLAM_UPGRADER_IMAGE: "${OLAM_UPGRADER_IMAGE:-ghcr.io/pleri/olam-host-cp:latest}"
135
+ # Plan DB persistence fix (Bug 1): os.homedir() inside the container is
136
+ # /root, but ~/.olam is bind-mounted to /data — not /root/.olam. Without
137
+ # these overrides, plan.db lands in the container's ephemeral layer and is
138
+ # destroyed by every `docker compose up --force-recreate` (i.e. olam upgrade).
139
+ # Pointing to /data/ routes all writes through the bind-mount to the host.
140
+ OLAM_PLAN_DB_PATH: "/data/plan.db"
141
+ OLAM_PLAN_DIR: "/data/plan"
135
142
  volumes:
136
143
  # ~/.olam/ from operator's home → /data/ inside container. B4
137
144
  # writes the startup token here (chmod 600). B6 reads workspaces
@@ -23,9 +23,29 @@ import { RopeEngine } from './plan/rope-engine.mjs';
23
23
  import { loadAuthorityConfig } from './plan/authority-config.mjs';
24
24
 
25
25
  // ── Paths ─────────────────────────────────────────────────────────────────────
26
+ //
27
+ // Inside the Docker container, os.homedir() → /root, but compose.yaml mounts
28
+ // ${HOME}/.olam → /data. Without env overrides, plan.db would be written to
29
+ // /root/.olam/plan.db (container ephemeral layer) and lost on every
30
+ // `docker compose up --force-recreate` (i.e. every `olam upgrade`).
31
+ //
32
+ // OLAM_PLAN_DB_PATH and OLAM_PLAN_DIR are set to /data/plan.db and /data/plan
33
+ // in compose.yaml so all writes land in the bind-mounted host directory.
34
+ // On bare-host installs (no container) neither env var is set and the paths
35
+ // fall back to the original ~/.olam locations — no behaviour change.
36
+ //
37
+ // Paths are resolved at construction time (not module load) so tests can pass
38
+ // explicit paths via constructor opts without any module re-import tricks.
39
+
40
+ /** @returns {string} */
41
+ function defaultPlanDbPath() {
42
+ return process.env.OLAM_PLAN_DB_PATH ?? path.join(os.homedir(), '.olam', 'plan.db');
43
+ }
26
44
 
27
- const PLAN_DB_PATH = path.join(os.homedir(), '.olam', 'plan.db');
28
- const PLAN_DIR = path.join(os.homedir(), '.olam', 'plan');
45
+ /** @returns {string} */
46
+ function defaultPlanDir() {
47
+ return process.env.OLAM_PLAN_DIR ?? path.join(os.homedir(), '.olam', 'plan');
48
+ }
29
49
 
30
50
  // ── Helpers ───────────────────────────────────────────────────────────────────
31
51
 
@@ -60,6 +80,7 @@ export function deriveTitle(content, maxLen = 40) {
60
80
 
61
81
  export class PlanOrchestrator {
62
82
  #db;
83
+ #planDir;
63
84
  #authServiceUrl;
64
85
  #authServiceSecret;
65
86
  #registry;
@@ -88,17 +109,44 @@ export class PlanOrchestrator {
88
109
  #currentChunkRefs = new Map();
89
110
 
90
111
  /**
91
- * @param {{ authServiceUrl: string, authServiceSecret: string }} opts
112
+ * @param {{
113
+ * authServiceUrl: string,
114
+ * authServiceSecret: string,
115
+ * planDbPath?: string,
116
+ * planDirPath?: string,
117
+ * }} opts
118
+ *
119
+ * planDbPath / planDirPath default to OLAM_PLAN_DB_PATH / OLAM_PLAN_DIR env vars,
120
+ * falling back to ~/.olam/plan.db and ~/.olam/plan. Pass explicitly in tests to
121
+ * avoid touching real home-dir paths.
92
122
  */
93
- constructor({ authServiceUrl, authServiceSecret }) {
123
+ constructor({ authServiceUrl, authServiceSecret, planDbPath, planDirPath } = {}) {
94
124
  this.#authServiceUrl = authServiceUrl;
95
125
  this.#authServiceSecret = authServiceSecret;
96
126
 
127
+ const legacyDbPath = path.join(os.homedir(), '.olam', 'plan.db');
128
+ const resolvedDbPath = planDbPath ?? defaultPlanDbPath();
129
+ this.#planDir = planDirPath ?? defaultPlanDir();
130
+
97
131
  this.#registry = new AgentRegistry({ authServiceUrl, authServiceSecret });
98
132
  this.#handoffEngine = new HandoffEngine(this.#registry);
99
133
 
100
- fs.mkdirSync(path.dirname(PLAN_DB_PATH), { recursive: true });
101
- this.#db = new Database(PLAN_DB_PATH);
134
+ fs.mkdirSync(path.dirname(resolvedDbPath), { recursive: true });
135
+
136
+ // One-time migration: if the resolved DB path differs from the legacy default and
137
+ // the target doesn't exist yet, copy any existing DB from the old location.
138
+ // This preserves conversations on a hot-restart after deploying the compose.yaml fix.
139
+ // On full container recreate the legacy path is already gone — this is a no-op.
140
+ if (resolvedDbPath !== legacyDbPath && !fs.existsSync(resolvedDbPath) && fs.existsSync(legacyDbPath)) {
141
+ try {
142
+ fs.copyFileSync(legacyDbPath, resolvedDbPath);
143
+ console.info('[plan] Migrated plan.db from legacy path to', resolvedDbPath);
144
+ } catch (err) {
145
+ console.warn('[plan] plan.db migration failed (non-fatal):', err.message);
146
+ }
147
+ }
148
+
149
+ this.#db = new Database(resolvedDbPath);
102
150
  this.#db.exec(`
103
151
  CREATE TABLE IF NOT EXISTS plan_conversations (
104
152
  id TEXT PRIMARY KEY,
@@ -152,6 +200,12 @@ export class PlanOrchestrator {
152
200
  ON plan_sidebar_signals(chunk_id);
153
201
  `);
154
202
 
203
+ // Migration guard: add pinned column if the table predates this feature.
204
+ const planConvCols = this.#db.prepare(`PRAGMA table_info(plan_conversations)`).all();
205
+ if (!planConvCols.some(c => c.name === 'pinned')) {
206
+ this.#db.exec(`ALTER TABLE plan_conversations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`);
207
+ }
208
+
155
209
  const authorityConfig = loadAuthorityConfig();
156
210
  this.#ropeEngine = new RopeEngine({
157
211
  registry: this.#registry,
@@ -194,7 +248,7 @@ export class PlanOrchestrator {
194
248
  const id = randomUUID();
195
249
  const created_at = Date.now();
196
250
 
197
- const sessionDir = path.join(PLAN_DIR, id);
251
+ const sessionDir = path.join(this.#planDir, id);
198
252
  fs.mkdirSync(sessionDir, { recursive: true });
199
253
  initSessionFile(path.join(sessionDir, 'session.jsonl'), id);
200
254
 
@@ -210,17 +264,76 @@ export class PlanOrchestrator {
210
264
  return { id, title: title ?? null, persona: DEFAULT_PERSONA_ID, created_at };
211
265
  }
212
266
 
213
- /** @returns {Array<{id, title, created_at, last_turn_at, persona}>} */
267
+ /** @returns {Array<{id, title, pinned, created_at, last_turn_at, persona, snippet}>} */
214
268
  listConversations() {
215
269
  return this.#db
216
270
  .prepare(
217
- `SELECT id, title, created_at, last_turn_at, persona
218
- FROM plan_conversations
219
- ORDER BY created_at DESC, rowid DESC`,
271
+ `SELECT
272
+ c.id, c.title, c.pinned, c.created_at, c.last_turn_at, c.persona,
273
+ (SELECT pt.content FROM plan_turns pt
274
+ WHERE pt.conversation_id = c.id
275
+ ORDER BY pt.created_at DESC LIMIT 1) AS snippet
276
+ FROM plan_conversations c
277
+ ORDER BY c.pinned DESC, COALESCE(c.last_turn_at, c.created_at) DESC`,
220
278
  )
221
279
  .all();
222
280
  }
223
281
 
282
+ /**
283
+ * Patch a conversation's title and/or pinned state.
284
+ * @param {string} id
285
+ * @param {{ title?: string, pinned?: boolean }} updates
286
+ * @returns {object|null} Updated row, or null if not found.
287
+ */
288
+ patchConversation(id, updates) {
289
+ const parts = [];
290
+ const values = [];
291
+ if (updates.title !== undefined) {
292
+ parts.push('title = ?');
293
+ values.push(updates.title || null);
294
+ }
295
+ if (updates.pinned !== undefined) {
296
+ parts.push('pinned = ?');
297
+ values.push(updates.pinned ? 1 : 0);
298
+ }
299
+ if (parts.length === 0) return null;
300
+ values.push(id);
301
+ const changed = this.#db
302
+ .prepare(`UPDATE plan_conversations SET ${parts.join(', ')} WHERE id = ?`)
303
+ .run(...values);
304
+ if (changed.changes === 0) return null;
305
+ return this.#db
306
+ .prepare(`SELECT id, title, pinned, created_at, last_turn_at, persona FROM plan_conversations WHERE id = ?`)
307
+ .get(id) ?? null;
308
+ }
309
+
310
+ /**
311
+ * Delete a conversation and all its associated data.
312
+ * @param {string} id
313
+ * @returns {boolean} true if deleted, false if not found.
314
+ */
315
+ deleteConversation(id) {
316
+ const exists = this.#db
317
+ .prepare(`SELECT 1 FROM plan_conversations WHERE id = ?`)
318
+ .get(id);
319
+ if (!exists) return false;
320
+
321
+ this.#db.prepare(`DELETE FROM plan_turns WHERE conversation_id = ?`).run(id);
322
+ this.#db.prepare(`DELETE FROM plan_lookout_agents WHERE conversation_id = ?`).run(id);
323
+ this.#db.prepare(`DELETE FROM plan_sidebar_signals WHERE conversation_id = ?`).run(id);
324
+ this.#db.prepare(`DELETE FROM plan_conversations WHERE id = ?`).run(id);
325
+
326
+ this.#activePersona.delete(id);
327
+ this.#sinks.delete(id);
328
+ this.#activeTurns.delete(id);
329
+ this.#currentChunkRefs.delete(id);
330
+
331
+ const sessionDir = path.join(PLAN_DIR, id);
332
+ try { fs.rmSync(sessionDir, { recursive: true }); } catch { /* ok if missing */ }
333
+
334
+ return true;
335
+ }
336
+
224
337
  /**
225
338
  * @param {string} id
226
339
  * @returns {{ id, title, persona, created_at, last_turn_at, tree } | null}
@@ -235,10 +348,10 @@ export class PlanOrchestrator {
235
348
 
236
349
  if (!row) return null;
237
350
 
238
- const sessionFile = path.join(PLAN_DIR, id, 'session.jsonl');
351
+ const sessionFile = path.join(this.#planDir, id, 'session.jsonl');
239
352
  let tree = [];
240
353
  try {
241
- const mgr = SessionManager.open(sessionFile, path.join(PLAN_DIR, id));
354
+ const mgr = SessionManager.open(sessionFile, path.join(this.#planDir, id));
242
355
  tree = mgr.getTree();
243
356
  } catch {
244
357
  // Session file missing or corrupt — return empty tree.
@@ -114,6 +114,12 @@ const OLAM_REPO_HOST_PATH = process.env.OLAM_REPO_HOST_PATH ?? '';
114
114
  const OLAM_GH_CONFIG_HOST_PATH = process.env.OLAM_GH_CONFIG_HOST_PATH ?? '';
115
115
  const OLAM_UPGRADER_IMAGE = process.env.OLAM_UPGRADER_IMAGE ?? 'ghcr.io/pleri/olam-host-cp:latest';
116
116
  const WORKSPACES_DIR = process.env.OLAM_WORKSPACES_DIR ?? '/data/workspaces';
117
+ // Bug 1 fix: plan.db must land in the bind-mounted /data dir, not the
118
+ // container-ephemeral /root/.olam. compose.yaml sets these to /data/plan.db
119
+ // and /data/plan. On bare-host installs the env vars are unset and the
120
+ // PlanOrchestrator falls back to ~/.olam defaults.
121
+ const PLAN_DB_PATH = process.env.OLAM_PLAN_DB_PATH ?? null;
122
+ const PLAN_DIR_PATH = process.env.OLAM_PLAN_DIR ?? null;
117
123
  const WORLD_NAMES_PATH =
118
124
  process.env.OLAM_WORLD_NAMES_PATH ??
119
125
  (HOST_CP_MODE === 'container'
@@ -386,6 +392,8 @@ const worldsDbReconciler = startWorldsDbReconciler({
386
392
  const planOrchestrator = new PlanOrchestrator({
387
393
  authServiceUrl: AUTH_SERVICE_URL,
388
394
  authServiceSecret: AUTH_SERVICE_SECRET,
395
+ ...(PLAN_DB_PATH ? { planDbPath: PLAN_DB_PATH } : {}),
396
+ ...(PLAN_DIR_PATH ? { planDirPath: PLAN_DIR_PATH } : {}),
389
397
  });
390
398
 
391
399
  /**
@@ -1444,6 +1452,40 @@ const server = http.createServer(async (req, res) => {
1444
1452
  }
1445
1453
  }
1446
1454
 
1455
+ if (planConvMatch && req.method === 'PATCH') {
1456
+ if (!await requirePlanCredential(res)) return;
1457
+ const id = decodeURIComponent(planConvMatch[1]);
1458
+ let body;
1459
+ try { body = await readRequestBody(req); } catch (err) {
1460
+ return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
1461
+ }
1462
+ const updates = {};
1463
+ if (body && typeof body.title === 'string') updates.title = body.title.trim();
1464
+ if (body && typeof body.pinned === 'boolean') updates.pinned = body.pinned;
1465
+ if (Object.keys(updates).length === 0) {
1466
+ return jsonReply(res, 400, { error: 'no_valid_fields' });
1467
+ }
1468
+ try {
1469
+ const conv = planOrchestrator.patchConversation(id, updates);
1470
+ if (!conv) return jsonReply(res, 404, { error: 'not_found', id });
1471
+ return jsonReply(res, 200, conv);
1472
+ } catch (err) {
1473
+ return jsonReply(res, 500, { error: 'patch_failed', message: err.message });
1474
+ }
1475
+ }
1476
+
1477
+ if (planConvMatch && req.method === 'DELETE') {
1478
+ if (!await requirePlanCredential(res)) return;
1479
+ const id = decodeURIComponent(planConvMatch[1]);
1480
+ try {
1481
+ const deleted = planOrchestrator.deleteConversation(id);
1482
+ if (!deleted) return jsonReply(res, 404, { error: 'not_found', id });
1483
+ return jsonReply(res, 204, null);
1484
+ } catch (err) {
1485
+ return jsonReply(res, 500, { error: 'delete_failed', message: err.message });
1486
+ }
1487
+ }
1488
+
1447
1489
  const planTurnMatch = /^\/api\/plan\/conversations\/([^/?#]+)\/turns$/.exec(url.pathname);
1448
1490
  if (planTurnMatch && req.method === 'POST') {
1449
1491
  if (!await requirePlanCredential(res)) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pleri/olam-cli",
3
- "version": "0.1.78",
3
+ "version": "0.1.79",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "olam": "./bin/olam.cjs"