mirror of
https://github.com/dtzp555-max/memory-continuity.git
synced 2026-07-19 09:42:42 +00:00
feat: add lifecycle plugin prototype and hook validation notes
This commit is contained in:
@@ -147,9 +147,12 @@ memory-continuity/
|
|||||||
├── SKILL.md
|
├── SKILL.md
|
||||||
├── README.md
|
├── README.md
|
||||||
├── LICENSE
|
├── LICENSE
|
||||||
|
├── plugin/
|
||||||
|
│ └── lifecycle-prototype.ts # Phase 2 probe / not production yet
|
||||||
├── references/
|
├── references/
|
||||||
│ ├── template.md
|
│ ├── template.md
|
||||||
│ └── doctor-spec.md
|
│ ├── doctor-spec.md
|
||||||
|
│ └── phase2-hook-validation.md
|
||||||
└── scripts/
|
└── scripts/
|
||||||
└── continuity_doctor.py
|
└── continuity_doctor.py
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
Reference in New Issue
Block a user