From 7c52cfd025ff7aad7660fa67fed3d4e337a6cbe4 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 13 Mar 2026 05:26:06 +1000 Subject: [PATCH] feat: add lifecycle plugin prototype and hook validation notes --- README.md | 5 +- plugin/lifecycle-prototype.ts | 135 +++++++++++++++++++++++++++ references/phase2-hook-validation.md | 126 +++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 plugin/lifecycle-prototype.ts create mode 100644 references/phase2-hook-validation.md diff --git a/README.md b/README.md index 3541c88..5edcc07 100644 --- a/README.md +++ b/README.md @@ -147,9 +147,12 @@ memory-continuity/ ├── SKILL.md ├── README.md ├── LICENSE +├── plugin/ +│ └── lifecycle-prototype.ts # Phase 2 probe / not production yet ├── references/ │ ├── template.md -│ └── doctor-spec.md +│ ├── doctor-spec.md +│ └── phase2-hook-validation.md └── scripts/ └── continuity_doctor.py ``` diff --git a/plugin/lifecycle-prototype.ts b/plugin/lifecycle-prototype.ts new file mode 100644 index 0000000..f2e3014 --- /dev/null +++ b/plugin/lifecycle-prototype.ts @@ -0,0 +1,135 @@ +import fs from "node:fs"; +import path from "node:path"; + +const PLACEHOLDER_VALUES = new Set([ + "", + "none", + "n/a", + "na", + "idle", + "[one sentence: what are we trying to accomplish]", + "[exactly what should happen next]", +]); + +function resolveStatePath(runtime: { workspaceDir?: string }) { + const workspaceDir = runtime?.workspaceDir; + if (!workspaceDir) return null; + return path.join(workspaceDir, "memory", "CURRENT_STATE.md"); +} + +function readStateFile(filePath: string) { + try { + return fs.readFileSync(filePath, "utf8"); + } catch { + return null; + } +} + +function extractSection(markdown: string, heading: string) { + const re = new RegExp(`^## ${heading}\\n([\\s\\S]*?)(?=^## |^---$|\\Z)`, "m"); + const m = markdown.match(re); + return (m?.[1] ?? "").trim(); +} + +function normalizeValue(value: string) { + return value.trim().toLowerCase(); +} + +function isMeaningful(value: string) { + return !PLACEHOLDER_VALUES.has(normalizeValue(value)); +} + +function buildSnapshot(markdown: string) { + const objective = extractSection(markdown, "Objective"); + const currentStep = extractSection(markdown, "Current Step"); + const keyDecisions = extractSection(markdown, "Key Decisions"); + const nextAction = extractSection(markdown, "Next Action"); + const blockers = extractSection(markdown, "Blockers"); + const unsurfacedResults = extractSection(markdown, "Unsurfaced Results"); + const updated = markdown.match(/^> Last updated:\s*(.+)$/m)?.[1]?.trim() ?? "unknown"; + + if (!isMeaningful(objective)) return null; + + return [ + "CONTINUITY SNAPSHOT", + `Objective: ${objective}`, + `Current Step: ${currentStep || "unknown"}`, + `Key Decisions: ${keyDecisions || "None"}`, + `Next Action: ${nextAction || "unknown"}`, + `Blockers: ${blockers || "None"}`, + `Unsurfaced Results: ${unsurfacedResults || "None"}`, + `Updated: ${updated}`, + ].join("\n"); +} + +function ensureStateFile(filePath: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + if (fs.existsSync(filePath)) return; + fs.writeFileSync( + filePath, + `# Current State\n> Last updated: ${new Date().toISOString()}\n\n## Objective\nNone\n\n## Current Step\nNone\n\n## Key Decisions\n- None\n\n## Next Action\nNone\n\n## Blockers\nNone\n\n## Unsurfaced Results\nNone\n`, + "utf8", + ); +} + +function appendArchive(workspaceDir: string, markdown: string) { + const stamp = new Date().toISOString().replace(/[:]/g, "-"); + const archiveDir = path.join(workspaceDir, "memory", "session_archive"); + fs.mkdirSync(archiveDir, { recursive: true }); + fs.writeFileSync(path.join(archiveDir, `${stamp}.md`), markdown, "utf8"); +} + +export default function register(api: any) { + // Startup recovery hint: this is the most important Phase 2 check. + api.on( + "before_prompt_build", + async (_event: any, ctx: any) => { + const statePath = resolveStatePath(api.runtime ?? {}); + if (!statePath) return; + const markdown = readStateFile(statePath); + if (!markdown) return; + const snapshot = buildSnapshot(markdown); + if (!snapshot) return; + return { + prependSystemContext: + `${snapshot}\n\n` + + "If the user is clearly resuming/recovering prior work, surface the recovered state before generic greeting.", + }; + }, + { priority: 20 }, + ); + + // /new boundary: best-effort archive/checkpoint. + api.registerHook( + "command:new", + async () => { + const workspaceDir = api.runtime?.workspaceDir; + const statePath = resolveStatePath(api.runtime ?? {}); + if (!workspaceDir || !statePath) return; + ensureStateFile(statePath); + const markdown = readStateFile(statePath); + if (!markdown) return; + appendArchive(workspaceDir, markdown); + }, + { + name: "memory-continuity.command-new", + description: "Archive CURRENT_STATE.md before /new resets conversational continuity.", + }, + ); + + // End-of-run checkpoint stub: proves the lifecycle hook wiring exists. + api.on("agent_end", async () => { + const statePath = resolveStatePath(api.runtime ?? {}); + if (!statePath) return; + ensureStateFile(statePath); + }); + + // Compaction hook probe: v1 must confirm this path is actually synchronous enough. + api.on("before_compaction", async () => { + const statePath = resolveStatePath(api.runtime ?? {}); + if (!statePath) return; + ensureStateFile(statePath); + // Intentionally minimal for prototype. Real implementation must write a + // deterministic checkpoint and verify runtime waits for completion. + }); +} diff --git a/references/phase2-hook-validation.md b/references/phase2-hook-validation.md new file mode 100644 index 0000000..7460ba0 --- /dev/null +++ b/references/phase2-hook-validation.md @@ -0,0 +1,126 @@ +# Phase 2 Hook Validation Notes + +## Goal +Validate whether a **standard lifecycle plugin** can support the first practical +memory-continuity MVP without consuming the exclusive `contextEngine` slot. + +## Current evidence from local docs / SDK + +### Confirmed hook/event names seen in local OpenClaw install +From local docs and SDK/runtime sources, the following names are present: + +#### Typed plugin lifecycle hooks (`api.on`) +- `before_prompt_build` +- `before_agent_start` +- `agent_end` +- `before_compaction` +- `after_compaction` +- `subagent_ended` + +#### Event hooks (`api.registerHook`) +- `command:new` +- `command:reset` +- `session:compact:before` +- `session:compact:after` +- `agent:bootstrap` + +## Important finding: prompt injection is available to standard plugins +Local runtime code shows: +- `promptInjectionHookNameSet = new Set(["before_prompt_build", "before_agent_start"])` + +This matters because it means a standard plugin may be able to inject startup +continuity hints **without** using the exclusive ContextEngine slot. + +## Preferred v1 hook mapping + +### 1. Startup recovery hint +**Primary candidate:** `before_prompt_build` + +Why: +- explicitly documented as preferred over legacy `before_agent_start` +- supports prompt mutation fields such as: + - `prependContext` + - `prependSystemContext` + - `appendSystemContext` + - `systemPrompt` +- happens after session load, which is more practical for dynamic continuity state + +**Fallback candidate:** `before_agent_start` + +Why: +- present in runtime and treated as a prompt injection hook +- useful if `before_prompt_build` proves insufficient in some environments + +### 2. `/new` boundary checkpoint +**Primary candidate:** `command:new` + +Goal: +- save/archive working-state checkpoint before user-triggered reset of conversational continuity + +### 3. End-of-run safety checkpoint +**Primary candidate:** `agent_end` + +Goal: +- opportunistically preserve checkpoint state even if the agent was imperfectly disciplined mid-turn + +### 4. Compaction protection +**Candidates to test:** +- `before_compaction` +- `session:compact:before` + +This is the most important unresolved path. + +## Biggest unresolved technical risk +The lifecycle-plugin route still has one major unsolved problem: + +> How should the plugin expose a startup continuity hint robustly enough to +> satisfy “baseline recovery without read”, without relying on ContextEngine-only +> `systemPromptAddition`? + +Local evidence suggests `before_prompt_build` may be enough, but this must be +validated by a real plugin test. + +## Required Phase 2 experiments + +### Experiment A — startup prompt injection +Build a minimal plugin that: +- reads `memory/CURRENT_STATE.md` +- derives a compact snapshot +- injects it through `before_prompt_build` +- verifies the snapshot is actually visible to the agent in a fresh run + +### Experiment B — `/new` checkpoint timing +Build a minimal plugin that: +- handles `command:new` +- archives or checkpoints `CURRENT_STATE.md` +- verifies the write occurs before continuity is reset + +### Experiment C — compaction boundary guarantee +Build a minimal plugin that: +- hooks `before_compaction` and/or `session:compact:before` +- writes a deterministic marker/checkpoint +- verifies compaction waits for hook completion + +**This experiment is mandatory.** +If the hook does not reliably block until the write completes, the compaction +safety design must be revised. + +### Experiment D — end-of-run safety path +Build a minimal plugin that: +- hooks `agent_end` +- performs a trivial state write +- confirms the event fires reliably enough to be useful as a safety net + +## Prototype status +A local prototype skeleton exists at: +- `plugin/lifecycle-prototype.ts` + +That file is only a Phase 2 probe. It is **not** the final implementation. + +## Current conclusion +Based on local documentation and runtime inspection: +- a **standard lifecycle plugin** looks viable as the primary v1 architecture +- it likely supports startup prompt injection, `/new` handling, run-end checkpointing, and compaction-path interception +- the two most important items still requiring live proof are: + 1. prompt injection quality in `before_prompt_build` + 2. synchronous compaction safety in `before_compaction` / `session:compact:before`