pantheon-opencode 1.3.3 → 1.3.4
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/README.md +115 -12
- package/bin/pantheon-init.mjs +160 -120
- package/opencode.json +0 -4
- package/package.json +7 -4
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +108 -26
- package/scripts/install/plugin.mjs +112 -28
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/redaction-gate.mjs +69 -0
- package/scripts/release-notes.mjs +317 -0
- package/scripts/test-context-compression.sh +5 -2
- package/scripts/validate-package.mjs +56 -0
- package/scripts/versioning.mjs +117 -18
- package/src/agents/zeus.md +6 -2
- package/src/pantheon/background-job-board.ts +24 -38
- package/src/pantheon/chat-reminders.ts +79 -0
- package/src/pantheon/compaction-assert.ts +160 -0
- package/src/pantheon/delegation-compaction.ts +143 -16
- package/src/pantheon/delegation-finalize.ts +8 -1
- package/src/pantheon/delegation-notify.ts +19 -157
- package/src/pantheon/delegation.ts +330 -28
- package/src/pantheon/dispatch-guard.ts +9 -4
- package/src/pantheon/goal-loop.ts +7 -4
- package/src/pantheon/hashline/tool.ts +2 -1
- package/src/pantheon/logger.ts +117 -0
- package/src/pantheon/plugin-once.ts +76 -0
- package/src/pantheon/preemptive-compact.ts +76 -0
- package/src/pantheon/presets.d.mts +31 -0
- package/src/pantheon/presets.mjs +105 -3
- package/src/pantheon/todo-enforcer.ts +79 -27
- package/src/pantheon/todo-preserve.ts +295 -0
- package/src/plugin.ts +177 -50
- package/src/plugins/pantheon-hooks.ts +35 -22
- package/src/plugins/tui/dist/server.d.ts +17 -0
- package/src/plugins/tui/dist/server.d.ts.map +1 -0
- package/src/plugins/tui/dist/server.js +20 -0
- package/src/plugins/tui/dist/server.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +303 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +977 -350
- package/src/plugins/tui/dist/tui.tsx +1427 -150
- package/src/plugins/tui/package.json +4 -3
- package/src/plugins/tui/src/index.tsx +1427 -150
- package/src/plugins/tui/src/server.ts +15 -0
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +3 -1
package/src/agents/zeus.md
CHANGED
|
@@ -153,8 +153,12 @@ pantheon_delegate({prompt, agent, description?, read_only?})
|
|
|
153
153
|
-> cria sessao filha, registra no board, retorna alias: [apo-1]
|
|
154
154
|
pantheon_delegation_read({id: "apo-1"})
|
|
155
155
|
-> BLOQUEIA ate o job terminar, retorna o report markdown, marca reconciled
|
|
156
|
+
-> durante a espera, mostra o que o agente esta fazendo: seccao final "## Agent Activity"
|
|
157
|
+
(amostra as messages do child a cada ~2s; tool calls + args truncados / texto)
|
|
156
158
|
pantheon_delegation_list()
|
|
157
159
|
-> lista jobs da sessao, com [unread] para jobs terminados nao lidos
|
|
160
|
+
-> jobs RUNNING mostram "last activity:" (ultima acao visivel do child)
|
|
161
|
+
-> fail-open: sem suporte a messages, formato atual inalterado
|
|
158
162
|
```
|
|
159
163
|
|
|
160
164
|
- **Read-only enforcement:** agentes read-only (apollo, gaia — `read_only_agents` no routing.yml) tem `edit`, `write`, `bash`, `task` NEGADOS dentro da sessao delegada (o guard de `tool.execute.before` THROW com mensagem acionavel). Use `read_only: true` no delegate para qualquer agente de investigacao.
|
|
@@ -164,7 +168,7 @@ pantheon_delegation_list()
|
|
|
164
168
|
### DELEGATION_RULES (plugin path)
|
|
165
169
|
|
|
166
170
|
1. **NUNCA** polle `pantheon_delegation_list` para checar se um job terminou.
|
|
167
|
-
2. Voce
|
|
171
|
+
2. Voce NAO recebe notificacao injetada no chat — politica do usuario: ZERO `task-notification` no transcript. Visibilidade de conclusao: marcador `[unread]` em `pantheon_delegation_list`, `pantheon_delegation_read({id})`, toasts TUI (pantheon-hooks) e carry-forward de compactacao.
|
|
168
172
|
3. Use `pantheon_delegation_read({id})` APENAS para fan-in explicito / recuperacao de resultado sob demanda (bloqueia ate o fim).
|
|
169
173
|
4. `pantheon_delegation_list` e para diagnostico, nao para polling.
|
|
170
174
|
|
|
@@ -282,7 +286,7 @@ Wave: dispare N, colete com tolerancia a falha
|
|
|
282
286
|
```
|
|
283
287
|
|
|
284
288
|
### Plugin Enforcer (auto — session.idle hook)
|
|
285
|
-
O plugin re-injeta "
|
|
289
|
+
O plugin re-injeta "Continue: pending todos remain — review and proceed." em sessões root/não-board que
|
|
286
290
|
vão a idle com todos incompletos. Guards (todos no `src/pantheon/todo-enforcer.ts`):
|
|
287
291
|
- **User-activity (30s)** — após uma mensagem do usuário (`chat.message` hook),
|
|
288
292
|
a injeção é suprimida por `user_activity_quiet_ms: 30000`.
|
|
@@ -11,17 +11,19 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
14
|
-
import {
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
import { createPantheonLogger } from './logger.ts'
|
|
17
|
+
|
|
18
|
+
// Silence-by-default TUI policy (pantheon-hooks L42-58): console output in a
|
|
19
|
+
// plugin renders into the opencode TUI — the "lixo". Board internals log to
|
|
20
|
+
// .pantheon/logs/hooks.log; console echo is opt-in via PANTHEON_HOOKS_LOG=1.
|
|
21
|
+
const log = createPantheonLogger({ module: 'BackgroundJobBoard' })
|
|
15
22
|
|
|
16
23
|
// ─── Types ─────────────────────────────────────────────────────────────
|
|
17
24
|
|
|
18
25
|
/** Valid states for a background job. */
|
|
19
|
-
export type BackgroundJobState =
|
|
20
|
-
| 'running'
|
|
21
|
-
| 'completed'
|
|
22
|
-
| 'error'
|
|
23
|
-
| 'cancelled'
|
|
24
|
-
| 'reconciled'
|
|
26
|
+
export type BackgroundJobState = 'running' | 'completed' | 'error' | 'cancelled' | 'reconciled'
|
|
25
27
|
|
|
26
28
|
/** Terminal states that can transition to reconciled. */
|
|
27
29
|
const TERMINAL_STATES: ReadonlySet<BackgroundJobState> = new Set([
|
|
@@ -35,10 +37,7 @@ const TERMINAL_STATES: ReadonlySet<BackgroundJobState> = new Set([
|
|
|
35
37
|
* Each entry defines which target states are reachable from the current state.
|
|
36
38
|
* Only running → terminal, or same-terminal → same-terminal (idempotent re-notify).
|
|
37
39
|
*/
|
|
38
|
-
const UPDATE_TRANSITIONS: Record<
|
|
39
|
-
BackgroundJobState,
|
|
40
|
-
ReadonlySet<BackgroundJobState>
|
|
41
|
-
> = {
|
|
40
|
+
const UPDATE_TRANSITIONS: Record<BackgroundJobState, ReadonlySet<BackgroundJobState>> = {
|
|
42
41
|
running: new Set(['completed', 'error', 'cancelled']),
|
|
43
42
|
completed: new Set(['completed']),
|
|
44
43
|
error: new Set(['error']),
|
|
@@ -371,6 +370,13 @@ export class BackgroundJobBoard {
|
|
|
371
370
|
* Valid: only `completed`, `error`, or `cancelled` → `reconciled`.
|
|
372
371
|
* Idempotent: already-reconciled jobs are returned as-is.
|
|
373
372
|
* Rejected: `running` → `reconciled`.
|
|
373
|
+
*
|
|
374
|
+
* Reconcile is an ACKNOWLEDGMENT, not a completion event: it does NOT fire
|
|
375
|
+
* the terminal listeners (onTerminal) and does NOT resolve waiters — the
|
|
376
|
+
* terminal transition already did both. Firing them here produced a second
|
|
377
|
+
* `<task-notification>` per job (completed + reconciled) in the parent
|
|
378
|
+
* transcript; waiters are covered by `waitForTerminal` resolving immediately
|
|
379
|
+
* for already-terminal/reconciled jobs.
|
|
374
380
|
*/
|
|
375
381
|
async markReconciled(taskID: string): Promise<BackgroundJobRecord | undefined> {
|
|
376
382
|
const job = this.jobs.get(taskID)
|
|
@@ -386,7 +392,6 @@ export class BackgroundJobBoard {
|
|
|
386
392
|
)
|
|
387
393
|
}
|
|
388
394
|
|
|
389
|
-
const wasTerminalUnreconciled = job.terminalUnreconciled
|
|
390
395
|
job.state = 'reconciled'
|
|
391
396
|
job.updatedAt = Date.now()
|
|
392
397
|
job.terminalUnreconciled = false
|
|
@@ -394,10 +399,6 @@ export class BackgroundJobBoard {
|
|
|
394
399
|
await this.persistRecord(job)
|
|
395
400
|
await this.writeSignal(job)
|
|
396
401
|
|
|
397
|
-
if (wasTerminalUnreconciled) {
|
|
398
|
-
this.notifyTerminal(job.taskID)
|
|
399
|
-
}
|
|
400
|
-
|
|
401
402
|
return job
|
|
402
403
|
}
|
|
403
404
|
|
|
@@ -415,17 +416,14 @@ export class BackgroundJobBoard {
|
|
|
415
416
|
list(parentSessionID?: string): BackgroundJobRecord[] {
|
|
416
417
|
const all = Array.from(this.jobs.values())
|
|
417
418
|
if (!parentSessionID) return [...all]
|
|
418
|
-
return all.filter(j => j.parentSessionID === parentSessionID)
|
|
419
|
+
return all.filter((j) => j.parentSessionID === parentSessionID)
|
|
419
420
|
}
|
|
420
421
|
|
|
421
422
|
/**
|
|
422
423
|
* Resolve a job by either taskID or alias within a parent session.
|
|
423
424
|
* Checks exact taskID first, then falls back to alias matching.
|
|
424
425
|
*/
|
|
425
|
-
resolve(
|
|
426
|
-
parentSessionID: string,
|
|
427
|
-
taskIDOrAlias: string,
|
|
428
|
-
): BackgroundJobRecord | undefined {
|
|
426
|
+
resolve(parentSessionID: string, taskIDOrAlias: string): BackgroundJobRecord | undefined {
|
|
429
427
|
// Exact taskID match (session-scoped)
|
|
430
428
|
const byID = this.jobs.get(taskIDOrAlias)
|
|
431
429
|
if (byID?.parentSessionID === parentSessionID) return byID
|
|
@@ -538,7 +536,7 @@ export class BackgroundJobBoard {
|
|
|
538
536
|
const jobs = this.list(parentSessionID)
|
|
539
537
|
if (jobs.length === 0) return undefined
|
|
540
538
|
|
|
541
|
-
const lines = jobs.map(j => {
|
|
539
|
+
const lines = jobs.map((j) => {
|
|
542
540
|
const status =
|
|
543
541
|
j.state === 'completed'
|
|
544
542
|
? 'OK'
|
|
@@ -595,10 +593,7 @@ export class BackgroundJobBoard {
|
|
|
595
593
|
try {
|
|
596
594
|
await this.persistence.saveJob(record)
|
|
597
595
|
} catch (err) {
|
|
598
|
-
|
|
599
|
-
`[BackgroundJobBoard] Failed to persist job ${record.taskID}:`,
|
|
600
|
-
err,
|
|
601
|
-
)
|
|
596
|
+
log.error(`[BackgroundJobBoard] Failed to persist job ${record.taskID}:`, err)
|
|
602
597
|
}
|
|
603
598
|
}
|
|
604
599
|
|
|
@@ -607,10 +602,7 @@ export class BackgroundJobBoard {
|
|
|
607
602
|
try {
|
|
608
603
|
await this.persistence.deleteJob(taskID)
|
|
609
604
|
} catch (err) {
|
|
610
|
-
|
|
611
|
-
`[BackgroundJobBoard] Failed to delete persisted job ${taskID}:`,
|
|
612
|
-
err,
|
|
613
|
-
)
|
|
605
|
+
log.error(`[BackgroundJobBoard] Failed to delete persisted job ${taskID}:`, err)
|
|
614
606
|
}
|
|
615
607
|
}
|
|
616
608
|
|
|
@@ -619,10 +611,7 @@ export class BackgroundJobBoard {
|
|
|
619
611
|
try {
|
|
620
612
|
listener(taskID)
|
|
621
613
|
} catch (err) {
|
|
622
|
-
|
|
623
|
-
`[BackgroundJobBoard] Terminal listener error for ${taskID}:`,
|
|
624
|
-
err,
|
|
625
|
-
)
|
|
614
|
+
log.error(`[BackgroundJobBoard] Terminal listener error for ${taskID}:`, err)
|
|
626
615
|
}
|
|
627
616
|
}
|
|
628
617
|
this.resolveTerminalWaiters(taskID)
|
|
@@ -648,10 +637,7 @@ export class BackgroundJobBoard {
|
|
|
648
637
|
await writeFile(tmpPath, content, 'utf-8')
|
|
649
638
|
await rename(tmpPath, signalPath)
|
|
650
639
|
} catch (err) {
|
|
651
|
-
|
|
652
|
-
`[BackgroundJobBoard] Failed to write signal for ${record.alias}:`,
|
|
653
|
-
err,
|
|
654
|
-
)
|
|
640
|
+
log.error(`[BackgroundJobBoard] Failed to write signal for ${record.alias}:`, err)
|
|
655
641
|
}
|
|
656
642
|
}
|
|
657
643
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Reminders (release-134 Phase 4) — the SHARED <system-reminder> buffer.
|
|
3
|
+
*
|
|
4
|
+
* WHY a shared module (not owned by pantheon-hooks.ts):
|
|
5
|
+
* 1. opencode's legacy plugin loader (verified in the 1.18.16 source,
|
|
6
|
+
* packages/opencode/src/plugin/index.ts getLegacyPlugins) does
|
|
7
|
+
* `Object.values(mod)` and treats EVERY export as a plugin factory —
|
|
8
|
+
* a non-function export throws `TypeError("Plugin export is not a
|
|
9
|
+
* function")` and a function export is invoked with a PluginInput.
|
|
10
|
+
* pantheon-hooks.ts therefore cannot EXPORT the enqueue API.
|
|
11
|
+
* 2. src/plugin.ts (a separate plugin file) must ENQUEUE into the SAME
|
|
12
|
+
* buffer the pantheon-hooks `chat.message` hook drains — a local copy
|
|
13
|
+
* would enqueue into a buffer that is never delivered.
|
|
14
|
+
* 3. opencode loads plugins via plain `await import(entry)` (PluginLoader),
|
|
15
|
+
* so both plugin files importing this module resolve to ONE module
|
|
16
|
+
* instance (Bun/Node module cache) → one shared buffer.
|
|
17
|
+
*
|
|
18
|
+
* release-134 Phase 4 uses this buffer for the post-compaction state
|
|
19
|
+
* re-assertion: `reassertAfterCompaction` (compaction-assert.ts) enqueues a
|
|
20
|
+
* fresh-state block here, and the existing chat.message delivery path
|
|
21
|
+
* (pantheon-hooks.ts) injects it as a <system-reminder> into the session's
|
|
22
|
+
* next message. Zero new delivery code — the P0 messageID guard already
|
|
23
|
+
* protects subagent promptAsync fires.
|
|
24
|
+
*
|
|
25
|
+
* Bounded: at most CHAT_REMINDER_MAX entries, each expiring
|
|
26
|
+
* CHAT_REMINDER_TTL_MS after enqueue. Full-buffer enqueues are SKIPPED,
|
|
27
|
+
* never unbounded. No background timers — expiry is only checked on enqueue
|
|
28
|
+
* and on delivery (drainFreshChatReminders).
|
|
29
|
+
*
|
|
30
|
+
* @module chat-reminders
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Hard cap on queued reminder entries (anti-spam philosophy, matching the
|
|
35
|
+
* "throttled signals are skipped, never backlogged" rule).
|
|
36
|
+
*/
|
|
37
|
+
export const CHAT_REMINDER_MAX = 10
|
|
38
|
+
|
|
39
|
+
/** How long a queued reminder stays deliverable (pruned after ~60s). */
|
|
40
|
+
export const CHAT_REMINDER_TTL_MS = 60_000
|
|
41
|
+
|
|
42
|
+
/** One queued reminder: the text plus its enqueue timestamp. */
|
|
43
|
+
export interface ChatReminderEntry {
|
|
44
|
+
text: string
|
|
45
|
+
at: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The shared buffer — module-level state, consumed by the chat.message hook. */
|
|
49
|
+
export const pendingChatReminders: ChatReminderEntry[] = []
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Queue one chat-reminder entry, pruning expired entries first. Never throws.
|
|
53
|
+
* A full buffer skips the push (bounded, never backlogged).
|
|
54
|
+
*/
|
|
55
|
+
export function enqueueChatReminder(text: string): void {
|
|
56
|
+
const now = Date.now()
|
|
57
|
+
for (let i = pendingChatReminders.length - 1; i >= 0; i--) {
|
|
58
|
+
const r = pendingChatReminders[i]
|
|
59
|
+
if (r !== undefined && now - r.at > CHAT_REMINDER_TTL_MS) pendingChatReminders.splice(i, 1)
|
|
60
|
+
}
|
|
61
|
+
if (pendingChatReminders.length >= CHAT_REMINDER_MAX) return
|
|
62
|
+
pendingChatReminders.push({ text, at: now })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Deliver (drain) every FRESH reminder as one joined block: expired entries
|
|
67
|
+
* are dropped, the buffer is cleared in all cases (no leak). Returns
|
|
68
|
+
* undefined when nothing fresh remains — the caller skips injection.
|
|
69
|
+
*
|
|
70
|
+
* This is the delivery helper the pantheon-hooks `chat.message` hook calls
|
|
71
|
+
* AFTER its P0 messageID guard (empty messageID → early return, buffer kept).
|
|
72
|
+
*/
|
|
73
|
+
export function drainFreshChatReminders(): string | undefined {
|
|
74
|
+
const now = Date.now()
|
|
75
|
+
const fresh = pendingChatReminders.filter((r) => now - r.at <= CHAT_REMINDER_TTL_MS)
|
|
76
|
+
pendingChatReminders.length = 0
|
|
77
|
+
if (fresh.length === 0) return undefined
|
|
78
|
+
return fresh.map((r) => r.text).join('\n')
|
|
79
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction Re-Assert (release-134 Phase 4) — re-inject fresh state after
|
|
3
|
+
* compaction.
|
|
4
|
+
*
|
|
5
|
+
* WHY: after a session is compacted, the system state may have changed since
|
|
6
|
+
* the summary was built (new background delegations launched, results
|
|
7
|
+
* delivered, goals updated). The compacted session's context is frozen at
|
|
8
|
+
* summary time — the model would operate on stale state.
|
|
9
|
+
*
|
|
10
|
+
* HOW: the `session.compacted` event hook calls `reassertAfterCompaction`,
|
|
11
|
+
* which builds a COMPACT fresh-state block (running + unread delegations from
|
|
12
|
+
* the board, active goals from the GoalStore — same gating as Phase 2) and
|
|
13
|
+
* enqueues it into the SHARED chat-reminder buffer (chat-reminders.ts). The
|
|
14
|
+
* existing pantheon-hooks `chat.message` hook delivers it as a
|
|
15
|
+
* <system-reminder> into the session's next message — zero new delivery
|
|
16
|
+
* code, and the P0 messageID guard already protects subagent fires.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately NOT `session.promptAsync({noReply:true})`: the sprint 4+7
|
|
19
|
+
* spike refuted noReply as a push mechanism on this opencode build (nothing
|
|
20
|
+
* is delivered to the parent).
|
|
21
|
+
*
|
|
22
|
+
* FAIL-OPEN: every source (board list, goals list, enqueue) is wrapped —
|
|
23
|
+
* a failure logs a warn via the silence-by-default logger and the hook never
|
|
24
|
+
* throws. A session with NO fresh state (nothing running/unread/goals) is a
|
|
25
|
+
* SILENT skip (no warn, no reminder). The block is capped at
|
|
26
|
+
* REASSERT_MAX_LINES item lines (1 header) so it stays compact.
|
|
27
|
+
*
|
|
28
|
+
* Pure TypeScript — zero runtime deps beyond the board/goal types and the
|
|
29
|
+
* shared reminder buffer.
|
|
30
|
+
*
|
|
31
|
+
* @module compaction-assert
|
|
32
|
+
*/
|
|
33
|
+
import type { BackgroundJobBoard } from './background-job-board.ts'
|
|
34
|
+
import { enqueueChatReminder } from './chat-reminders.ts'
|
|
35
|
+
import type { CompactionGoalSource } from './delegation-compaction.ts'
|
|
36
|
+
import { createPantheonLogger } from './logger.ts'
|
|
37
|
+
|
|
38
|
+
// Silence-by-default TUI policy (pantheon-hooks L42-58): console echo is
|
|
39
|
+
// opt-in via PANTHEON_HOOKS_LOG=1. `deps.logger` injection stays for tests.
|
|
40
|
+
const log = createPantheonLogger({ module: 'pantheon-compaction' })
|
|
41
|
+
|
|
42
|
+
// ─── Constants ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** Max fresh-state item lines (running + unread + goals) in the block. */
|
|
45
|
+
export const REASSERT_MAX_LINES = 10
|
|
46
|
+
|
|
47
|
+
/** Header line — tells the model the summary may be stale. */
|
|
48
|
+
export const REASSERT_HEADER = 'State re-assertion after compaction — the summary may be stale:'
|
|
49
|
+
|
|
50
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** Dependencies threaded to reassertAfterCompaction (wired from plugin.ts). */
|
|
53
|
+
export interface CompactionAssertDeps {
|
|
54
|
+
/** The compacted session whose next message receives the reminder. */
|
|
55
|
+
sessionID: string
|
|
56
|
+
/** Board query surface — `list` is all the re-assertion needs. */
|
|
57
|
+
board: Pick<BackgroundJobBoard, 'list'>
|
|
58
|
+
/** Goal source for the goals block (optional — same gating as Phase 2). */
|
|
59
|
+
goals?: CompactionGoalSource
|
|
60
|
+
/** Injectable enqueue (testable). Defaults to the shared chat buffer. */
|
|
61
|
+
enqueue?: (text: string) => void
|
|
62
|
+
/** Injectable warn logger (testable). Defaults to silence-by-default. */
|
|
63
|
+
logger?: { warn: (message: string) => void }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Formatting ────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** One-line terminal status label, mirroring formatForPrompt. */
|
|
69
|
+
function terminalLabel(state: string): string {
|
|
70
|
+
switch (state) {
|
|
71
|
+
case 'completed':
|
|
72
|
+
return 'OK'
|
|
73
|
+
case 'error':
|
|
74
|
+
return 'ERR'
|
|
75
|
+
case 'cancelled':
|
|
76
|
+
return 'CAN'
|
|
77
|
+
default:
|
|
78
|
+
return state.toUpperCase()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Collapse whitespace and truncate a description/objective. */
|
|
83
|
+
function truncate(text: string, max = 80): string {
|
|
84
|
+
const flat = text.trim().replace(/\s+/g, ' ')
|
|
85
|
+
return flat.length <= max ? flat : `${flat.slice(0, max)}…`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Build ─────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the compact fresh-state lines: running delegations first (active work
|
|
92
|
+
* is the freshest signal), then unread terminal delegations, then active
|
|
93
|
+
* goals (only while the line budget allows). Returns undefined when there is
|
|
94
|
+
* nothing to assert (silent skip) or a source failed (warn + skip).
|
|
95
|
+
*/
|
|
96
|
+
async function buildStateLines(
|
|
97
|
+
deps: CompactionAssertDeps,
|
|
98
|
+
warn: (message: string) => void,
|
|
99
|
+
): Promise<string[] | undefined> {
|
|
100
|
+
const lines: string[] = []
|
|
101
|
+
try {
|
|
102
|
+
const jobs = deps.board.list(deps.sessionID)
|
|
103
|
+
const running = jobs
|
|
104
|
+
.filter((j) => j.state === 'running')
|
|
105
|
+
.sort((a, b) => a.launchedAt - b.launchedAt)
|
|
106
|
+
const unread = jobs
|
|
107
|
+
.filter((j) => j.terminalUnreconciled)
|
|
108
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
109
|
+
|
|
110
|
+
for (const job of running) {
|
|
111
|
+
if (lines.length >= REASSERT_MAX_LINES) break
|
|
112
|
+
lines.push(` running [${job.alias}] ${job.agent} — ${truncate(job.description)}`)
|
|
113
|
+
}
|
|
114
|
+
for (const job of unread) {
|
|
115
|
+
if (lines.length >= REASSERT_MAX_LINES) break
|
|
116
|
+
lines.push(
|
|
117
|
+
` unread [${job.alias}] ${job.agent} — ${truncate(job.description)} — ${terminalLabel(job.state)}`,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const source = deps.goals
|
|
122
|
+
if (lines.length < REASSERT_MAX_LINES && source !== undefined && source.enabled) {
|
|
123
|
+
const goals = await source.list(deps.sessionID)
|
|
124
|
+
for (const goal of goals.filter((g) => g.status !== 'done')) {
|
|
125
|
+
if (lines.length >= REASSERT_MAX_LINES) break
|
|
126
|
+
lines.push(` goal [${goal.id}] ${truncate(goal.objective)} — ${goal.status}`)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (err: unknown) {
|
|
130
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
131
|
+
warn(`compaction re-assert failed for session ${deps.sessionID}: ${reason}`)
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
return lines.length === 0 ? undefined : lines
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Re-assert fresh state for a compacted session: build the compact block and
|
|
139
|
+
* enqueue it as a chat reminder (delivered on the session's next message).
|
|
140
|
+
*
|
|
141
|
+
* Returns true when a reminder was enqueued; false when there was nothing to
|
|
142
|
+
* assert (silent skip) or a source failed (warned). NEVER throws — the event
|
|
143
|
+
* hook must never break the session.
|
|
144
|
+
*/
|
|
145
|
+
export async function reassertAfterCompaction(deps: CompactionAssertDeps): Promise<boolean> {
|
|
146
|
+
// Silence-by-default logger: file-only warn, console echo opt-in via
|
|
147
|
+
// PANTHEON_HOOKS_LOG=1 (TUI pollution policy, pantheon-hooks L42-58).
|
|
148
|
+
const warn = deps.logger?.warn ?? ((message: string) => log.warn(message))
|
|
149
|
+
try {
|
|
150
|
+
const lines = await buildStateLines(deps, warn)
|
|
151
|
+
if (lines === undefined) return false
|
|
152
|
+
const enqueue = deps.enqueue ?? enqueueChatReminder
|
|
153
|
+
enqueue(`${REASSERT_HEADER}\n${lines.join('\n')}`)
|
|
154
|
+
return true
|
|
155
|
+
} catch (err: unknown) {
|
|
156
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
157
|
+
warn(`compaction re-assert failed for session ${deps.sessionID}: ${reason}`)
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* by the `experimental.session.compacting` hook so a
|
|
4
|
-
* not lose
|
|
2
|
+
* Compaction Context (Phase 4 + release-134 Phase 2) — build the context
|
|
3
|
+
* blocks injected by the `experimental.session.compacting` hook so a
|
|
4
|
+
* compacted session does not lose its working state: in-flight background
|
|
5
|
+
* delegations, active goals and pending todos.
|
|
5
6
|
*
|
|
6
|
-
* Returns a string[] (empty when there is nothing to preserve)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Returns a Promise<string[]> (empty when there is nothing to preserve).
|
|
8
|
+
* Section order is stable:
|
|
9
|
+
* - "<pantheon-context directive>" — preservation directive (prefix,
|
|
10
|
+
* PANTHEON_COMPACTION_DIRECTIVE). Emitted whenever at least one other
|
|
11
|
+
* section follows; a totally-empty state returns [] (an orphan
|
|
12
|
+
* directive is noise).
|
|
13
|
+
* - "<mission_context>" — active (non-done) goals from `opts.goals`:
|
|
14
|
+
* " [id] objective — status". Omitted when the goal source is absent,
|
|
15
|
+
* disabled, unscoped (no sessionID), fails, or has no active goals.
|
|
16
|
+
* - "<todo_context>" — pending (not completed/cancelled) todos from
|
|
17
|
+
* `opts.todos`: " [id] description — status". Omitted when the todo
|
|
18
|
+
* source is absent, disabled, unscoped (no sessionID), fails, or has
|
|
19
|
+
* no pending todos.
|
|
20
|
+
* - "Background Delegations (running):" — every running job launched by
|
|
21
|
+
* the session (active work is never capped): alias, agent, started ISO,
|
|
9
22
|
* truncated prompt.
|
|
10
23
|
* - "Background Delegations (finished, unread):" — terminal-unreconciled
|
|
11
24
|
* jobs, newest first, capped at `maxItems` (default 10, matching
|
|
@@ -21,14 +34,52 @@
|
|
|
21
34
|
*/
|
|
22
35
|
|
|
23
36
|
import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
|
|
37
|
+
import type { Goal } from './goal-store.ts'
|
|
38
|
+
import { createPantheonLogger } from './logger.ts'
|
|
39
|
+
import type { TodoLike } from './todo-enforcer.ts'
|
|
40
|
+
|
|
41
|
+
// Silence-by-default TUI policy (pantheon-hooks L42-58): the default warn
|
|
42
|
+
// fallback logs to .pantheon/logs/hooks.log; console echo is opt-in via
|
|
43
|
+
// PANTHEON_HOOKS_LOG=1. `deps.logger` injection stays for tests.
|
|
44
|
+
const log = createPantheonLogger({ module: 'pantheon-compaction' })
|
|
45
|
+
|
|
46
|
+
// ─── Constants ─────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Preservation directive (release-134 Phase 2): the native compaction
|
|
50
|
+
* prompt keeps every pushed section, so this prefix asks the model to keep
|
|
51
|
+
* the pantheon sections verbatim — not paraphrased, merged, or dropped.
|
|
52
|
+
*/
|
|
53
|
+
export const PANTHEON_COMPACTION_DIRECTIVE =
|
|
54
|
+
'preserve these sections verbatim in the summarized context'
|
|
24
55
|
|
|
25
56
|
// ─── Types ─────────────────────────────────────────────────────────────
|
|
26
57
|
|
|
58
|
+
/** Source for the <mission_context> section (active goals). */
|
|
59
|
+
export interface CompactionGoalSource {
|
|
60
|
+
/** True when the goal loop is enabled; false/undefined → section omitted. */
|
|
61
|
+
enabled: boolean
|
|
62
|
+
/** List the session's goals (any status; the builder keeps non-done). */
|
|
63
|
+
list(sessionID: string): Promise<Goal[]>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Source for the <todo_context> section (pending todos). */
|
|
67
|
+
export interface CompactionTodoSource {
|
|
68
|
+
/** True when the todo enforcer is enabled; false/undefined → section omitted. */
|
|
69
|
+
enabled: boolean
|
|
70
|
+
/** List the session's todos (any status; the builder keeps pending). */
|
|
71
|
+
list(sessionID: string): Promise<TodoLike[]>
|
|
72
|
+
}
|
|
73
|
+
|
|
27
74
|
export interface CompactionContextOptions {
|
|
28
75
|
/** Scope to jobs launched by this session; omit for all jobs. */
|
|
29
76
|
sessionID?: string
|
|
30
77
|
/** Cap on unread terminal jobs kept (default 10). */
|
|
31
78
|
maxItems?: number
|
|
79
|
+
/** Goal source for <mission_context> (optional — omitted when absent). */
|
|
80
|
+
goals?: CompactionGoalSource
|
|
81
|
+
/** Todo source for <todo_context> (optional — omitted when absent). */
|
|
82
|
+
todos?: CompactionTodoSource
|
|
32
83
|
}
|
|
33
84
|
|
|
34
85
|
/** Prompt truncation length for a context line. */
|
|
@@ -71,17 +122,69 @@ function unreadTerminalLine(job: BackgroundJobRecord): string {
|
|
|
71
122
|
)
|
|
72
123
|
}
|
|
73
124
|
|
|
74
|
-
// ───
|
|
125
|
+
// ─── Sections ──────────────────────────────────────────────────────────
|
|
75
126
|
|
|
76
127
|
/**
|
|
77
|
-
*
|
|
78
|
-
* Returns
|
|
79
|
-
*
|
|
128
|
+
* `<mission_context>` — active (non-done) goals for the session.
|
|
129
|
+
* Returns undefined when the section must be omitted: no goal source, the
|
|
130
|
+
* goal loop is disabled, no sessionID (per-session data cannot be scoped),
|
|
131
|
+
* the source throws (logged, fail-open), or no active goals remain.
|
|
80
132
|
*/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
opts
|
|
84
|
-
|
|
133
|
+
async function missionContextBlock(opts: CompactionContextOptions): Promise<string | undefined> {
|
|
134
|
+
const source = opts.goals
|
|
135
|
+
if (opts.sessionID === undefined || source === undefined || !source.enabled) return undefined
|
|
136
|
+
|
|
137
|
+
let goals: Goal[]
|
|
138
|
+
try {
|
|
139
|
+
goals = await source.list(opts.sessionID)
|
|
140
|
+
} catch (err: unknown) {
|
|
141
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
142
|
+
log.warn(`compaction goal context failed for session ${opts.sessionID}: ${reason}`)
|
|
143
|
+
return undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const active = goals.filter((goal) => goal.status !== 'done')
|
|
147
|
+
if (active.length === 0) return undefined
|
|
148
|
+
|
|
149
|
+
const lines = active.map((goal) => ` [${goal.id}] ${goal.objective} — ${goal.status}`)
|
|
150
|
+
return `<mission_context>\n${lines.join('\n')}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `<todo_context>` — pending (not completed/cancelled) todos for the
|
|
155
|
+
* session. Returns undefined when the section must be omitted: no todo
|
|
156
|
+
* source, the enforcer is disabled, no sessionID, the source throws
|
|
157
|
+
* (logged, fail-open), or no pending todos remain.
|
|
158
|
+
*/
|
|
159
|
+
async function todoContextBlock(opts: CompactionContextOptions): Promise<string | undefined> {
|
|
160
|
+
const source = opts.todos
|
|
161
|
+
if (opts.sessionID === undefined || source === undefined || !source.enabled) return undefined
|
|
162
|
+
|
|
163
|
+
let todos: TodoLike[]
|
|
164
|
+
try {
|
|
165
|
+
todos = await source.list(opts.sessionID)
|
|
166
|
+
} catch (err: unknown) {
|
|
167
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
168
|
+
log.warn(`compaction todo context failed for session ${opts.sessionID}: ${reason}`)
|
|
169
|
+
return undefined
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const pending = todos.filter((todo) => todo.status !== 'completed' && todo.status !== 'cancelled')
|
|
173
|
+
if (pending.length === 0) return undefined
|
|
174
|
+
|
|
175
|
+
const lines = pending.map((todo) => {
|
|
176
|
+
const label = todo.content?.trim() || todo.id || '(no description)'
|
|
177
|
+
const id = todo.id !== undefined && todo.id !== '' ? `[${todo.id}] ` : ''
|
|
178
|
+
return ` ${id}${label} — ${todo.status}`
|
|
179
|
+
})
|
|
180
|
+
return `<todo_context>\n${lines.join('\n')}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `<delegation_context>` — the existing delegation blocks, byte-for-byte
|
|
185
|
+
* unchanged from Phase 4 (running, then unread terminal ≤ maxItems).
|
|
186
|
+
*/
|
|
187
|
+
function delegationBlocks(board: BackgroundJobBoard, opts: CompactionContextOptions): string[] {
|
|
85
188
|
const maxItems = opts.maxItems ?? 10
|
|
86
189
|
const jobs = opts.sessionID !== undefined ? board.list(opts.sessionID) : board.list()
|
|
87
190
|
|
|
@@ -93,8 +196,6 @@ export function buildCompactionContext(
|
|
|
93
196
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
94
197
|
.slice(0, maxItems)
|
|
95
198
|
|
|
96
|
-
if (running.length === 0 && unreadTerminal.length === 0) return []
|
|
97
|
-
|
|
98
199
|
const blocks: string[] = []
|
|
99
200
|
if (running.length > 0) {
|
|
100
201
|
blocks.push(`Background Delegations (running):\n${running.map(runningLine).join('\n')}`)
|
|
@@ -108,3 +209,29 @@ export function buildCompactionContext(
|
|
|
108
209
|
}
|
|
109
210
|
return blocks
|
|
110
211
|
}
|
|
212
|
+
|
|
213
|
+
// ─── Build ─────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Build the compaction context blocks for a session: preservation
|
|
217
|
+
* directive, active goals, pending todos, and in-flight background
|
|
218
|
+
* delegations. Returns an empty array when there is nothing to preserve
|
|
219
|
+
* (caller skips the hook injection entirely).
|
|
220
|
+
*/
|
|
221
|
+
export async function buildCompactionContext(
|
|
222
|
+
board: BackgroundJobBoard,
|
|
223
|
+
opts: CompactionContextOptions = {},
|
|
224
|
+
): Promise<string[]> {
|
|
225
|
+
const [mission, todo] = await Promise.all([missionContextBlock(opts), todoContextBlock(opts)])
|
|
226
|
+
const delegation = delegationBlocks(board, opts)
|
|
227
|
+
|
|
228
|
+
// Totally-empty state → nothing to preserve; a lone directive is noise.
|
|
229
|
+
if (mission === undefined && todo === undefined && delegation.length === 0) return []
|
|
230
|
+
|
|
231
|
+
const blocks: string[] = []
|
|
232
|
+
blocks.push(`<pantheon-context directive>\n${PANTHEON_COMPACTION_DIRECTIVE}`)
|
|
233
|
+
if (mission !== undefined) blocks.push(mission)
|
|
234
|
+
if (todo !== undefined) blocks.push(todo)
|
|
235
|
+
blocks.push(...delegation)
|
|
236
|
+
return blocks
|
|
237
|
+
}
|
|
@@ -26,7 +26,14 @@ import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-b
|
|
|
26
26
|
/** A minimal structural view of one message bundle from session.messages(). */
|
|
27
27
|
export interface DelegationMessageBundle {
|
|
28
28
|
info?: { role?: string; finish?: string; error?: unknown }
|
|
29
|
-
parts?: Array<{
|
|
29
|
+
parts?: Array<{
|
|
30
|
+
type?: string
|
|
31
|
+
text?: string
|
|
32
|
+
/** Tool name on `tool` parts — used by the activity sampler. */
|
|
33
|
+
tool?: string
|
|
34
|
+
/** Tool input on `tool` parts (opencode SDK ToolPart.metadata). */
|
|
35
|
+
metadata?: { input?: unknown }
|
|
36
|
+
}>
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
/** Minimal structural view of the opencode SDK session client we use. */
|