@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.
- package/dist/image-digests.json +3 -3
- package/host-cp/compose.yaml +7 -0
- package/host-cp/src/plan-orchestrator.mjs +126 -13
- package/host-cp/src/server.mjs +42 -0
- package/package.json +1 -1
package/dist/image-digests.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"auth": "sha256:ee6b1d1046933c76acc8d28cd83ac3871bc69057d2401014252872dc5ec42db7",
|
|
3
|
-
"devbox": "sha256:
|
|
4
|
-
"host-cp": "sha256:
|
|
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.
|
|
7
|
+
"$published_version": "0.1.79",
|
|
8
8
|
"$registry": "ghcr.io/pleri"
|
|
9
9
|
}
|
package/host-cp/compose.yaml
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
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 {{
|
|
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(
|
|
101
|
-
|
|
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(
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
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(
|
|
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.
|
package/host-cp/src/server.mjs
CHANGED
|
@@ -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;
|