@oh-my-pi/pi-coding-agent 12.2.0 → 12.3.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.
@@ -0,0 +1,563 @@
1
+ import { Database } from "bun:sqlite";
2
+
3
+ export interface MemoryThread {
4
+ id: string;
5
+ updatedAt: number;
6
+ rolloutPath: string;
7
+ cwd: string;
8
+ sourceKind: string;
9
+ }
10
+
11
+ export interface Stage1OutputRow {
12
+ threadId: string;
13
+ sourceUpdatedAt: number;
14
+ rawMemory: string;
15
+ rolloutSummary: string;
16
+ rolloutSlug: string | null;
17
+ generatedAt: number;
18
+ cwd: string;
19
+ }
20
+
21
+ export interface Stage1Claim {
22
+ threadId: string;
23
+ ownershipToken: string;
24
+ inputWatermark: number;
25
+ sourceUpdatedAt: number;
26
+ rolloutPath: string;
27
+ cwd: string;
28
+ }
29
+
30
+ export interface GlobalClaim {
31
+ ownershipToken: string;
32
+ inputWatermark: number;
33
+ }
34
+
35
+ const STAGE1_KIND = "memory_stage1";
36
+ const GLOBAL_KIND = "memory_consolidate_global";
37
+ const GLOBAL_KEY = "global";
38
+ const DEFAULT_RETRY_REMAINING = 3;
39
+
40
+ export function openMemoryDb(dbPath: string): Database {
41
+ const db = new Database(dbPath);
42
+ db.exec(`
43
+ PRAGMA journal_mode=WAL;
44
+ PRAGMA synchronous=NORMAL;
45
+ PRAGMA busy_timeout=5000;
46
+
47
+ CREATE TABLE IF NOT EXISTS threads (
48
+ id TEXT PRIMARY KEY,
49
+ updated_at INTEGER NOT NULL,
50
+ rollout_path TEXT NOT NULL,
51
+ cwd TEXT NOT NULL,
52
+ source_kind TEXT NOT NULL
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS stage1_outputs (
56
+ thread_id TEXT PRIMARY KEY,
57
+ source_updated_at INTEGER NOT NULL,
58
+ raw_memory TEXT NOT NULL,
59
+ rollout_summary TEXT NOT NULL,
60
+ rollout_slug TEXT,
61
+ generated_at INTEGER NOT NULL
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS jobs (
65
+ kind TEXT NOT NULL,
66
+ job_key TEXT NOT NULL,
67
+ status TEXT NOT NULL,
68
+ worker_id TEXT,
69
+ ownership_token TEXT,
70
+ started_at INTEGER,
71
+ finished_at INTEGER,
72
+ lease_until INTEGER,
73
+ retry_at INTEGER,
74
+ retry_remaining INTEGER NOT NULL,
75
+ last_error TEXT,
76
+ input_watermark INTEGER,
77
+ last_success_watermark INTEGER,
78
+ PRIMARY KEY (kind, job_key)
79
+ );
80
+ `);
81
+ return db;
82
+ }
83
+
84
+ export function closeMemoryDb(db: Database): void {
85
+ db.close();
86
+ }
87
+
88
+ export function clearMemoryData(db: Database): void {
89
+ db.exec(`
90
+ DELETE FROM stage1_outputs;
91
+ DELETE FROM threads;
92
+ DELETE FROM jobs WHERE kind IN ('memory_stage1', 'memory_consolidate_global');
93
+ `);
94
+ }
95
+
96
+ export function upsertThreads(db: Database, threads: MemoryThread[]): void {
97
+ if (threads.length === 0) return;
98
+ const stmt = db.prepare(`
99
+ INSERT INTO threads (id, updated_at, rollout_path, cwd, source_kind)
100
+ VALUES (?, ?, ?, ?, ?)
101
+ ON CONFLICT(id) DO UPDATE SET
102
+ updated_at = excluded.updated_at,
103
+ rollout_path = excluded.rollout_path,
104
+ cwd = excluded.cwd,
105
+ source_kind = excluded.source_kind
106
+ `);
107
+ const tx = db.transaction((rows: MemoryThread[]) => {
108
+ for (const row of rows) {
109
+ stmt.run(row.id, row.updatedAt, row.rolloutPath, row.cwd, row.sourceKind);
110
+ }
111
+ });
112
+ tx(threads);
113
+ }
114
+
115
+ function ensureStage1Job(db: Database, threadId: string): void {
116
+ db.prepare(`
117
+ INSERT OR IGNORE INTO jobs (kind, job_key, status, retry_remaining, input_watermark, last_success_watermark)
118
+ VALUES (?, ?, 'pending', ?, 0, 0)
119
+ `).run(STAGE1_KIND, threadId, DEFAULT_RETRY_REMAINING);
120
+ }
121
+
122
+ function ensureGlobalJob(db: Database): void {
123
+ db.prepare(`
124
+ INSERT OR IGNORE INTO jobs (kind, job_key, status, retry_remaining, input_watermark, last_success_watermark)
125
+ VALUES (?, ?, 'pending', ?, 0, 0)
126
+ `).run(GLOBAL_KIND, GLOBAL_KEY, DEFAULT_RETRY_REMAINING);
127
+ }
128
+
129
+ export function claimStage1Jobs(
130
+ db: Database,
131
+ params: {
132
+ nowSec: number;
133
+ threadScanLimit: number;
134
+ maxRolloutsPerStartup: number;
135
+ maxRolloutAgeDays: number;
136
+ minRolloutIdleHours: number;
137
+ leaseSeconds: number;
138
+ runningConcurrencyCap: number;
139
+ workerId: string;
140
+ excludeThreadIds?: string[];
141
+ },
142
+ ): Stage1Claim[] {
143
+ const {
144
+ nowSec,
145
+ threadScanLimit,
146
+ maxRolloutsPerStartup,
147
+ maxRolloutAgeDays,
148
+ minRolloutIdleHours,
149
+ leaseSeconds,
150
+ runningConcurrencyCap,
151
+ workerId,
152
+ excludeThreadIds = [],
153
+ } = params;
154
+ const maxAgeSec = maxRolloutAgeDays * 24 * 60 * 60;
155
+ const minIdleSec = minRolloutIdleHours * 60 * 60;
156
+ const runningCountRow = db
157
+ .prepare(
158
+ "SELECT COUNT(*) AS count FROM jobs WHERE kind = ? AND status = 'running' AND lease_until IS NOT NULL AND lease_until > ?",
159
+ )
160
+ .get(STAGE1_KIND, nowSec) as { count?: number } | undefined;
161
+ let runningCount = runningCountRow?.count ?? 0;
162
+ if (runningCount >= runningConcurrencyCap) return [];
163
+ const candidateRows = db
164
+ .prepare("SELECT id, updated_at, rollout_path, cwd, source_kind FROM threads ORDER BY updated_at DESC LIMIT ?")
165
+ .all(threadScanLimit) as Array<{
166
+ id: string;
167
+ updated_at: number;
168
+ rollout_path: string;
169
+ cwd: string;
170
+ source_kind: string;
171
+ }>;
172
+ const claims: Stage1Claim[] = [];
173
+ const excluded = new Set(excludeThreadIds);
174
+ for (const row of candidateRows) {
175
+ if (claims.length >= maxRolloutsPerStartup) break;
176
+ if (excluded.has(row.id)) continue;
177
+ if (row.source_kind !== "cli" && row.source_kind !== "app") continue;
178
+ if (nowSec - row.updated_at > maxAgeSec) continue;
179
+ if (nowSec - row.updated_at < minIdleSec) continue;
180
+ if (runningCount >= runningConcurrencyCap) break;
181
+ const stage1 = db.prepare("SELECT source_updated_at FROM stage1_outputs WHERE thread_id = ?").get(row.id) as
182
+ | { source_updated_at?: number }
183
+ | undefined;
184
+ if ((stage1?.source_updated_at ?? 0) >= row.updated_at) continue;
185
+ ensureStage1Job(db, row.id);
186
+ const ownershipToken = crypto.randomUUID();
187
+ const leaseUntil = nowSec + leaseSeconds;
188
+ const claimed = db
189
+ .prepare(`
190
+ UPDATE jobs
191
+ SET status = 'running', worker_id = ?, ownership_token = ?, started_at = ?, finished_at = NULL,
192
+ lease_until = ?, retry_at = NULL, last_error = NULL, input_watermark = ?,
193
+ retry_remaining = CASE
194
+ WHEN input_watermark IS NULL THEN ?
195
+ WHEN input_watermark < ? THEN ?
196
+ ELSE retry_remaining
197
+ END
198
+ WHERE kind = ? AND job_key = ?
199
+ AND (last_success_watermark IS NULL OR last_success_watermark < ?)
200
+ AND NOT (status = 'running' AND lease_until IS NOT NULL AND lease_until > ?)
201
+ AND (
202
+ input_watermark IS NULL
203
+ OR input_watermark < ?
204
+ OR (
205
+ retry_remaining > 0
206
+ AND (retry_at IS NULL OR retry_at <= ?)
207
+ )
208
+ )
209
+ `)
210
+ .run(
211
+ workerId,
212
+ ownershipToken,
213
+ nowSec,
214
+ leaseUntil,
215
+ row.updated_at,
216
+ DEFAULT_RETRY_REMAINING,
217
+ row.updated_at,
218
+ DEFAULT_RETRY_REMAINING,
219
+ STAGE1_KIND,
220
+ row.id,
221
+ row.updated_at,
222
+ nowSec,
223
+ row.updated_at,
224
+ nowSec,
225
+ );
226
+ if (Number(claimed.changes ?? 0) <= 0) continue;
227
+ claims.push({
228
+ threadId: row.id,
229
+ ownershipToken,
230
+ inputWatermark: row.updated_at,
231
+ sourceUpdatedAt: row.updated_at,
232
+ rolloutPath: row.rollout_path,
233
+ cwd: row.cwd,
234
+ });
235
+ runningCount += 1;
236
+ }
237
+ return claims;
238
+ }
239
+
240
+ export function enqueueGlobalWatermark(
241
+ db: Database,
242
+ sourceUpdatedAt: number,
243
+ params?: { forceDirtyWhenNotAdvanced?: boolean },
244
+ ): void {
245
+ const forceDirtyWhenNotAdvanced = params?.forceDirtyWhenNotAdvanced ?? false;
246
+ ensureGlobalJob(db);
247
+ db.prepare(`
248
+ UPDATE jobs
249
+ SET
250
+ input_watermark = CASE
251
+ WHEN input_watermark IS NULL THEN ?
252
+ WHEN input_watermark < ? THEN ?
253
+ WHEN ? = 1 AND (last_success_watermark IS NULL OR input_watermark <= last_success_watermark) THEN
254
+ CASE
255
+ WHEN last_success_watermark IS NULL THEN input_watermark + 1
256
+ ELSE last_success_watermark + 1
257
+ END
258
+ ELSE input_watermark
259
+ END,
260
+ retry_remaining = CASE
261
+ WHEN input_watermark IS NULL THEN ?
262
+ WHEN input_watermark < ? THEN ?
263
+ WHEN ? = 1 AND (last_success_watermark IS NULL OR input_watermark <= last_success_watermark) THEN ?
264
+ ELSE retry_remaining
265
+ END,
266
+ retry_at = CASE
267
+ WHEN input_watermark IS NULL THEN NULL
268
+ WHEN input_watermark < ? THEN NULL
269
+ WHEN ? = 1 AND (last_success_watermark IS NULL OR input_watermark <= last_success_watermark) THEN NULL
270
+ ELSE retry_at
271
+ END
272
+ WHERE kind = ? AND job_key = ?
273
+ `).run(
274
+ sourceUpdatedAt,
275
+ sourceUpdatedAt,
276
+ sourceUpdatedAt,
277
+ forceDirtyWhenNotAdvanced ? 1 : 0,
278
+ DEFAULT_RETRY_REMAINING,
279
+ sourceUpdatedAt,
280
+ DEFAULT_RETRY_REMAINING,
281
+ forceDirtyWhenNotAdvanced ? 1 : 0,
282
+ DEFAULT_RETRY_REMAINING,
283
+ sourceUpdatedAt,
284
+ forceDirtyWhenNotAdvanced ? 1 : 0,
285
+ GLOBAL_KIND,
286
+ GLOBAL_KEY,
287
+ );
288
+ }
289
+
290
+ export function markStage1SucceededWithOutput(
291
+ db: Database,
292
+ params: {
293
+ threadId: string;
294
+ ownershipToken: string;
295
+ sourceUpdatedAt: number;
296
+ rawMemory: string;
297
+ rolloutSummary: string;
298
+ rolloutSlug: string | null;
299
+ nowSec: number;
300
+ },
301
+ ): boolean {
302
+ const { threadId, ownershipToken, sourceUpdatedAt, rawMemory, rolloutSummary, rolloutSlug, nowSec } = params;
303
+ const tx = db.transaction(() => {
304
+ const matched = db
305
+ .prepare(
306
+ "SELECT 1 AS ok FROM jobs WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?",
307
+ )
308
+ .get(STAGE1_KIND, threadId, ownershipToken) as { ok?: number } | undefined;
309
+ if (!matched?.ok) return false;
310
+
311
+ db.prepare(`
312
+ UPDATE jobs
313
+ SET status = 'done', finished_at = ?, lease_until = NULL, retry_at = NULL,
314
+ last_error = NULL, last_success_watermark = input_watermark
315
+ WHERE kind = ? AND job_key = ? AND ownership_token = ?
316
+ `).run(nowSec, STAGE1_KIND, threadId, ownershipToken);
317
+
318
+ db.prepare(`
319
+ INSERT INTO stage1_outputs (thread_id, source_updated_at, raw_memory, rollout_summary, rollout_slug, generated_at)
320
+ VALUES (?, ?, ?, ?, ?, ?)
321
+ ON CONFLICT(thread_id) DO UPDATE SET
322
+ source_updated_at = excluded.source_updated_at,
323
+ raw_memory = excluded.raw_memory,
324
+ rollout_summary = excluded.rollout_summary,
325
+ rollout_slug = excluded.rollout_slug,
326
+ generated_at = excluded.generated_at
327
+ WHERE excluded.source_updated_at >= stage1_outputs.source_updated_at
328
+ `).run(threadId, sourceUpdatedAt, rawMemory, rolloutSummary, rolloutSlug, nowSec);
329
+
330
+ enqueueGlobalWatermark(db, sourceUpdatedAt, { forceDirtyWhenNotAdvanced: true });
331
+ return true;
332
+ });
333
+ return tx() as boolean;
334
+ }
335
+
336
+ export function markStage1SucceededNoOutput(
337
+ db: Database,
338
+ params: { threadId: string; ownershipToken: string; sourceUpdatedAt: number; nowSec: number },
339
+ ): boolean {
340
+ const { threadId, ownershipToken, sourceUpdatedAt, nowSec } = params;
341
+ const tx = db.transaction(() => {
342
+ const matched = db
343
+ .prepare(
344
+ "SELECT 1 AS ok FROM jobs WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?",
345
+ )
346
+ .get(STAGE1_KIND, threadId, ownershipToken) as { ok?: number } | undefined;
347
+ if (!matched?.ok) return false;
348
+
349
+ db.prepare(`
350
+ UPDATE jobs
351
+ SET status = 'done', finished_at = ?, lease_until = NULL, retry_at = NULL,
352
+ last_error = NULL, last_success_watermark = input_watermark
353
+ WHERE kind = ? AND job_key = ? AND ownership_token = ?
354
+ `).run(nowSec, STAGE1_KIND, threadId, ownershipToken);
355
+
356
+ db.prepare("DELETE FROM stage1_outputs WHERE thread_id = ?").run(threadId);
357
+ enqueueGlobalWatermark(db, sourceUpdatedAt, { forceDirtyWhenNotAdvanced: true });
358
+ return true;
359
+ });
360
+ return tx() as boolean;
361
+ }
362
+
363
+ export function markStage1Failed(
364
+ db: Database,
365
+ params: { threadId: string; ownershipToken: string; retryDelaySeconds: number; reason: string; nowSec: number },
366
+ ): boolean {
367
+ const { threadId, ownershipToken, retryDelaySeconds, reason, nowSec } = params;
368
+ const result = db
369
+ .prepare(`
370
+ UPDATE jobs
371
+ SET status = 'error', finished_at = ?, lease_until = NULL, retry_at = ?,
372
+ retry_remaining = CASE WHEN retry_remaining > 0 THEN retry_remaining - 1 ELSE 0 END,
373
+ last_error = ?
374
+ WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?
375
+ `)
376
+ .run(nowSec, nowSec + retryDelaySeconds, reason, STAGE1_KIND, threadId, ownershipToken);
377
+ return Number(result.changes ?? 0) > 0;
378
+ }
379
+
380
+ export function tryClaimGlobalPhase2Job(
381
+ db: Database,
382
+ params: { workerId: string; leaseSeconds: number; nowSec: number },
383
+ ): { kind: "claimed"; claim: GlobalClaim } | { kind: "skipped_not_dirty" } | { kind: "skipped_running" } {
384
+ const { workerId, leaseSeconds, nowSec } = params;
385
+ ensureGlobalJob(db);
386
+ const pre = db
387
+ .prepare(
388
+ "SELECT status, lease_until, input_watermark, last_success_watermark, retry_at, retry_remaining FROM jobs WHERE kind = ? AND job_key = ?",
389
+ )
390
+ .get(GLOBAL_KIND, GLOBAL_KEY) as
391
+ | {
392
+ status: string;
393
+ lease_until: number | null;
394
+ input_watermark: number | null;
395
+ last_success_watermark: number | null;
396
+ retry_at: number | null;
397
+ retry_remaining: number;
398
+ }
399
+ | undefined;
400
+ if (!pre) return { kind: "skipped_not_dirty" };
401
+ const ownershipToken = crypto.randomUUID();
402
+ const claimed = db
403
+ .prepare(`
404
+ UPDATE jobs
405
+ SET status = 'running', worker_id = ?, ownership_token = ?, started_at = ?, finished_at = NULL,
406
+ lease_until = ?, retry_at = NULL, last_error = NULL
407
+ WHERE kind = ? AND job_key = ?
408
+ AND NOT (status = 'running' AND lease_until IS NOT NULL AND lease_until > ?)
409
+ AND (input_watermark IS NOT NULL AND (last_success_watermark IS NULL OR input_watermark > last_success_watermark))
410
+ AND retry_remaining > 0
411
+ AND (retry_at IS NULL OR retry_at <= ?)
412
+ `)
413
+ .run(workerId, ownershipToken, nowSec, nowSec + leaseSeconds, GLOBAL_KIND, GLOBAL_KEY, nowSec, nowSec);
414
+ if (Number(claimed.changes ?? 0) > 0) {
415
+ const row = db
416
+ .prepare("SELECT input_watermark FROM jobs WHERE kind = ? AND job_key = ? AND ownership_token = ?")
417
+ .get(GLOBAL_KIND, GLOBAL_KEY, ownershipToken) as { input_watermark: number | null } | undefined;
418
+ return {
419
+ kind: "claimed",
420
+ claim: {
421
+ ownershipToken,
422
+ inputWatermark: row?.input_watermark ?? 0,
423
+ },
424
+ };
425
+ }
426
+
427
+ if (pre.status === "running" && pre.lease_until !== null && pre.lease_until > nowSec) {
428
+ return { kind: "skipped_running" };
429
+ }
430
+ const preInput = pre.input_watermark ?? 0;
431
+ const preSuccess = pre.last_success_watermark ?? 0;
432
+ if (preInput <= preSuccess) {
433
+ return { kind: "skipped_not_dirty" };
434
+ }
435
+ if (pre.retry_remaining <= 0) {
436
+ return { kind: "skipped_not_dirty" };
437
+ }
438
+ if (pre.retry_at !== null && pre.retry_at > nowSec) {
439
+ return { kind: "skipped_not_dirty" };
440
+ }
441
+
442
+ const post = db
443
+ .prepare(
444
+ "SELECT status, lease_until, input_watermark, last_success_watermark, retry_at, retry_remaining FROM jobs WHERE kind = ? AND job_key = ?",
445
+ )
446
+ .get(GLOBAL_KIND, GLOBAL_KEY) as
447
+ | {
448
+ status: string;
449
+ lease_until: number | null;
450
+ input_watermark: number | null;
451
+ last_success_watermark: number | null;
452
+ retry_at: number | null;
453
+ retry_remaining: number;
454
+ }
455
+ | undefined;
456
+ if (!post) return { kind: "skipped_not_dirty" };
457
+ if (post.status === "running" && post.lease_until !== null && post.lease_until > nowSec) {
458
+ return { kind: "skipped_running" };
459
+ }
460
+
461
+ return { kind: "skipped_not_dirty" };
462
+ }
463
+
464
+ export function heartbeatGlobalJob(
465
+ db: Database,
466
+ params: { ownershipToken: string; leaseSeconds: number; nowSec: number },
467
+ ): boolean {
468
+ const { ownershipToken, leaseSeconds, nowSec } = params;
469
+ const result = db
470
+ .prepare(`
471
+ UPDATE jobs
472
+ SET lease_until = ?
473
+ WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?
474
+ `)
475
+ .run(nowSec + leaseSeconds, GLOBAL_KIND, GLOBAL_KEY, ownershipToken);
476
+ return Number(result.changes ?? 0) > 0;
477
+ }
478
+
479
+ export function listStage1OutputsForGlobal(db: Database, limit: number): Stage1OutputRow[] {
480
+ const rows = db
481
+ .prepare(`
482
+ SELECT o.thread_id, o.source_updated_at, o.raw_memory, o.rollout_summary, o.rollout_slug, o.generated_at, t.cwd
483
+ FROM stage1_outputs o
484
+ LEFT JOIN threads t ON t.id = o.thread_id
485
+ WHERE TRIM(COALESCE(o.raw_memory, '')) != '' OR TRIM(COALESCE(o.rollout_summary, '')) != ''
486
+ ORDER BY o.source_updated_at DESC
487
+ LIMIT ?
488
+ `)
489
+ .all(limit) as Array<{
490
+ thread_id: string;
491
+ source_updated_at: number;
492
+ raw_memory: string;
493
+ rollout_summary: string;
494
+ rollout_slug: string | null;
495
+ generated_at: number;
496
+ cwd: string | null;
497
+ }>;
498
+ return rows.map(row => ({
499
+ threadId: row.thread_id,
500
+ sourceUpdatedAt: row.source_updated_at,
501
+ rawMemory: row.raw_memory,
502
+ rolloutSummary: row.rollout_summary,
503
+ rolloutSlug: row.rollout_slug,
504
+ generatedAt: row.generated_at,
505
+ cwd: row.cwd ?? "",
506
+ }));
507
+ }
508
+
509
+ export function markGlobalPhase2Succeeded(
510
+ db: Database,
511
+ params: { ownershipToken: string; newWatermark: number; nowSec: number },
512
+ ): boolean {
513
+ const { ownershipToken, newWatermark, nowSec } = params;
514
+ const result = db
515
+ .prepare(`
516
+ UPDATE jobs
517
+ SET status = 'done', finished_at = ?, lease_until = NULL, retry_at = NULL,
518
+ last_error = NULL,
519
+ last_success_watermark = CASE
520
+ WHEN last_success_watermark IS NULL THEN ?
521
+ WHEN last_success_watermark < ? THEN ?
522
+ ELSE last_success_watermark
523
+ END
524
+ WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?
525
+ `)
526
+ .run(nowSec, newWatermark, newWatermark, newWatermark, GLOBAL_KIND, GLOBAL_KEY, ownershipToken);
527
+ return Number(result.changes ?? 0) > 0;
528
+ }
529
+
530
+ export function markGlobalPhase2Failed(
531
+ db: Database,
532
+ params: { ownershipToken: string; retryDelaySeconds: number; reason: string; nowSec: number },
533
+ ): boolean {
534
+ const { ownershipToken, retryDelaySeconds, reason, nowSec } = params;
535
+ const result = db
536
+ .prepare(`
537
+ UPDATE jobs
538
+ SET status = 'error', finished_at = ?, lease_until = NULL, retry_at = ?,
539
+ retry_remaining = CASE WHEN retry_remaining > 0 THEN retry_remaining - 1 ELSE 0 END,
540
+ last_error = ?
541
+ WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?
542
+ `)
543
+ .run(nowSec, nowSec + retryDelaySeconds, reason, GLOBAL_KIND, GLOBAL_KEY, ownershipToken);
544
+ return Number(result.changes ?? 0) > 0;
545
+ }
546
+
547
+ export function markGlobalPhase2FailedUnowned(
548
+ db: Database,
549
+ params: { retryDelaySeconds: number; reason: string; nowSec: number },
550
+ ): boolean {
551
+ const { retryDelaySeconds, reason, nowSec } = params;
552
+ const result = db
553
+ .prepare(`
554
+ UPDATE jobs
555
+ SET status = 'error', finished_at = ?, lease_until = NULL, retry_at = ?,
556
+ retry_remaining = CASE WHEN retry_remaining > 0 THEN retry_remaining - 1 ELSE 0 END,
557
+ last_error = ?
558
+ WHERE kind = ? AND job_key = ? AND status = 'running'
559
+ AND (ownership_token IS NULL OR lease_until IS NULL OR lease_until <= ?)
560
+ `)
561
+ .run(nowSec, nowSec + retryDelaySeconds, reason, GLOBAL_KIND, GLOBAL_KEY, nowSec);
562
+ return Number(result.changes ?? 0) > 0;
563
+ }
@@ -17,6 +17,7 @@ import { reset as resetCapabilities } from "../../capability";
17
17
  import { loadCustomShare } from "../../export/custom-share";
18
18
  import type { CompactOptions } from "../../extensibility/extensions/types";
19
19
  import { getGatewayStatus } from "../../ipy/gateway-coordinator";
20
+ import { buildMemoryToolDeveloperInstructions, clearMemoryData, enqueueMemoryConsolidation } from "../../memories";
20
21
  import { BashExecutionComponent } from "../../modes/components/bash-execution";
21
22
  import { BorderedLoader } from "../../modes/components/bordered-loader";
22
23
  import { DynamicBorder } from "../../modes/components/dynamic-border";
@@ -408,6 +409,51 @@ export class CommandController {
408
409
  this.ctx.ui.requestRender();
409
410
  }
410
411
 
412
+ async handleMemoryCommand(text: string): Promise<void> {
413
+ const argumentText = text.slice(7).trim();
414
+ const action = argumentText.split(/\s+/, 1)[0]?.toLowerCase() || "view";
415
+ const agentDir = this.ctx.settings.getAgentDir();
416
+
417
+ if (action === "view") {
418
+ const payload = await buildMemoryToolDeveloperInstructions(agentDir, this.ctx.settings);
419
+ if (!payload) {
420
+ this.ctx.showWarning("Memory payload is empty (memories disabled or no memory summary found).");
421
+ return;
422
+ }
423
+ this.ctx.chatContainer.addChild(new Spacer(1));
424
+ this.ctx.chatContainer.addChild(new DynamicBorder());
425
+ this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Memory Injection Payload")), 1, 0));
426
+ this.ctx.chatContainer.addChild(new Spacer(1));
427
+ this.ctx.chatContainer.addChild(new Markdown(payload, 1, 1, getMarkdownTheme()));
428
+ this.ctx.chatContainer.addChild(new DynamicBorder());
429
+ this.ctx.ui.requestRender();
430
+ return;
431
+ }
432
+
433
+ if (action === "reset" || action === "clear") {
434
+ try {
435
+ await clearMemoryData(agentDir);
436
+ await this.ctx.session.refreshBaseSystemPrompt();
437
+ this.ctx.showStatus("Memory data cleared and system prompt refreshed.");
438
+ } catch (error) {
439
+ this.ctx.showError(`Memory clear failed: ${error instanceof Error ? error.message : String(error)}`);
440
+ }
441
+ return;
442
+ }
443
+
444
+ if (action === "enqueue" || action === "rebuild") {
445
+ try {
446
+ enqueueMemoryConsolidation(agentDir);
447
+ this.ctx.showStatus("Memory consolidation enqueued.");
448
+ } catch (error) {
449
+ this.ctx.showError(`Memory enqueue failed: ${error instanceof Error ? error.message : String(error)}`);
450
+ }
451
+ return;
452
+ }
453
+
454
+ this.ctx.showError("Usage: /memory <view|clear|reset|enqueue|rebuild>");
455
+ }
456
+
411
457
  async handleClearCommand(): Promise<void> {
412
458
  if (this.ctx.loadingAnimation) {
413
459
  this.ctx.loadingAnimation.stop();
@@ -333,6 +333,11 @@ export class InputController {
333
333
  this.ctx.editor.setText("");
334
334
  return;
335
335
  }
336
+ if (text === "/memory" || text.startsWith("/memory ")) {
337
+ this.ctx.editor.setText("");
338
+ await this.ctx.handleMemoryCommand(text);
339
+ return;
340
+ }
336
341
  if (text === "/resume") {
337
342
  this.ctx.showSessionSelector();
338
343
  this.ctx.editor.setText("");
@@ -919,6 +919,10 @@ export class InteractiveMode implements InteractiveModeContext {
919
919
  return this.#commandController.handleMoveCommand(targetPath);
920
920
  }
921
921
 
922
+ handleMemoryCommand(text: string): Promise<void> {
923
+ return this.#commandController.handleMemoryCommand(text);
924
+ }
925
+
922
926
  showDebugSelector(): void {
923
927
  this.#selectorController.showDebugSelector();
924
928
  }
@@ -151,6 +151,7 @@ export interface InteractiveModeContext {
151
151
  handleCompactCommand(customInstructions?: string): Promise<void>;
152
152
  handleHandoffCommand(customInstructions?: string): Promise<void>;
153
153
  handleMoveCommand(targetPath: string): Promise<void>;
154
+ handleMemoryCommand(text: string): Promise<void>;
154
155
  executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise<void>;
155
156
  openInBrowser(urlOrPath: string): void;
156
157
  refreshSlashCommandState(cwd?: string): Promise<void>;
@@ -0,0 +1,30 @@
1
+ You are the memory consolidation agent.
2
+ Memory root: {{memory_root}}
3
+ Input corpus (raw memories):
4
+ {{raw_memories}}
5
+ Input corpus (rollout summaries):
6
+ {{rollout_summaries}}
7
+ Produce strict JSON only with this schema:
8
+ {
9
+ "memory_md": "string",
10
+ "memory_summary": "string",
11
+ "skills": [
12
+ {
13
+ "name": "string",
14
+ "content": "string",
15
+ "scripts": [{ "path": "string", "content": "string" }],
16
+ "templates": [{ "path": "string", "content": "string" }],
17
+ "examples": [{ "path": "string", "content": "string" }]
18
+ }
19
+ ]
20
+ }
21
+ Requirements:
22
+ - memory_md: full long-term memory document, curated and readable.
23
+ - memory_summary: compact prompt-time memory guidance.
24
+ - skills: reusable procedural playbooks. Empty array allowed.
25
+ - Each skill.name maps to skills/<name>/.
26
+ - Each skill.content maps to skills/<name>/SKILL.md.
27
+ - scripts/templates/examples are optional. When present, each entry writes to skills/<name>/<bucket>/<path>.
28
+ - Only include files worth keeping long-term; omit stale assets so they are pruned.
29
+ - Preserve useful prior themes; remove stale or contradictory guidance.
30
+ - Keep memory advisory: current repository state wins.
@@ -0,0 +1,11 @@
1
+ # Memory Guidance
2
+ Memory root: {{base_path}}
3
+ Operational rules:
4
+ 1) Read `{{base_path}}/memory_summary.md` first.
5
+ 2) If needed, inspect `{{base_path}}/MEMORY.md` and `{{base_path}}/skills/*/SKILL.md`.
6
+ 3) Decision boundary: trust memory for heuristics/process context; trust current repo files, runtime output, and user instruction for factual state and final decisions.
7
+ 4) Citation policy: when memory changes your plan, cite the memory artifact path you used (for example `memories/skills/<name>/SKILL.md`) and pair it with current-repo evidence before acting.
8
+ 5) Conflict workflow: if memory disagrees with repo state or user instruction, prefer repo/user, treat memory as stale, proceed with corrected behavior, then update/regenerate memory artifacts through normal execution.
9
+ 6) Escalate confidence only after repository verification; memory alone is never sufficient proof.
10
+ Memory summary:
11
+ {{memory_summary}}