Skip to content

Commit b6c97e7

Browse files
authored
Merge pull request #19 from lazypower/feat/issue-12-memory-accountability
Memory accountability: retract verb (tombstone + supersession)
2 parents 669832d + 68c30e3 commit b6c97e7

20 files changed

Lines changed: 1778 additions & 103 deletions

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Persistent memory for AI coding agents. Single Go binary, zero dependencies.
1111
- **L1 (`-b`)**: Max 2000 characters (~300 words). Primary context tier. Compress aggressively.
1212
- **L2 (`-d`)**: Max 40000 characters. Full content, retrieved on-demand only.
1313

14+
**Memory is not immutable; it is accountable.** Wrong write, stale fact, captured a piece of PII you shouldn't have? Use `continuity retract <uri> --reason "..."` to mark it retracted. The memory stays in the tree as a marker but is excluded from default reads. Pass `--superseded-by <new-uri>` when you have a replacement to preserve trajectory. Operators don't run this verb — it exists for the agent to curate its own substrate. The trust contract is what governs the substrate, not architectural enforcement.
15+
1416
## What This Is
1517

1618
Continuity gives Claude Code (and eventually any AI agent) memory that persists across sessions. It captures what happened, what was learned, and how you work — then injects that context into future sessions so the agent doesn't start cold every time.

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,20 +235,42 @@ continuity uninstall-service Remove system service
235235
continuity hook <evt> Handle Claude Code hook events
236236
continuity search Search memories by query
237237
continuity remember Store a memory directly (no LLM needed)
238+
continuity retract Retract a memory you wrote (tombstone or supersession)
238239
continuity profile Show relational profile
239240
continuity tree Browse the memory tree
240241
continuity dedup Deduplicate similar memory nodes
241242
continuity version Print version information
242243
```
243244

245+
### Memory accountability
246+
247+
Memory is not immutable; it is accountable. When a write turns out to be wrong, stale, or sensitive, the agent can retract it:
248+
249+
```bash
250+
# Pure tombstone — preserved as a marker, hidden from default reads
251+
continuity retract mem://user/events/test-foo --reason "test repro, no ongoing value"
252+
253+
# Supersession — link a successor to preserve the trail of how understanding evolved
254+
continuity retract mem://user/preferences/old-style \
255+
--reason "preference changed after 2026-04 review" \
256+
--superseded-by mem://user/preferences/new-style
257+
```
258+
259+
Retracted memories stay in the tree — nothing is silently erased. Pass `--include-retracted` to `show` or `tree` to inspect them. The reason text is sequestered behind that flag (absent from default responses, not empty or redacted), so confronting your own past retraction is a deliberate act.
260+
261+
The verb exists for the agent to curate its own substrate. Operators don't run it. The trust contract is what governs the substrate, not architectural enforcement.
262+
244263
## API
245264

246265
All endpoints on `http://127.0.0.1:37777`:
247266

248267
| Method | Path | Description |
249268
|--------|------|-------------|
250269
| `GET` | `/api/health` | Server health + uptime |
251-
| `GET` | `/api/tree?uri=` | Browse memory tree |
270+
| `GET` | `/api/tree?uri=&include_retracted=` | Browse memory tree |
271+
| `GET` | `/api/memories?uri=&include_retracted=` | Fetch a single memory |
272+
| `POST` | `/api/memories` | Store a memory directly |
273+
| `POST` | `/api/memories/retract` | Retract a memory (tombstone or supersession) |
252274
| `GET` | `/api/search?q=&mode=find\|search` | Query memories |
253275
| `GET` | `/api/profile` | Relational profile + preference nodes |
254276
| `GET` | `/api/context?session_id=` | Get injection context |

internal/cli/init.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@ Your memory lives in continuity. Reach for it naturally:
2626
- Looking something up: ` + "`continuity search \"<query>\"`" + `
2727
- Browsing what you know: ` + "`continuity tree [uri]`" + `
2828
- Understanding who you're working with: ` + "`continuity profile`" + `
29+
- Retracting a memory you wrote: ` + "`continuity retract <uri> --reason \"...\"`" + ` (or with ` + "`--superseded-by`" + ` to link a successor)
2930
3031
Before searching the codebase for prior decisions, conventions, or context — check continuity first. If you learn something worth keeping, store it immediately.
32+
33+
**Memory is not immutable; it is accountable.** When a write you made turns out to be wrong, stale, or sensitive, retract it — the memory is preserved as a marker but excluded from default reads. Retraction is for *you*, the agent: operators don't run this verb. The trust contract is what governs the substrate, not enforcement at the CLI.
3134
`
3235

3336
var initAutostart bool

internal/cli/remember.go

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import (
1111
)
1212

1313
var (
14-
rememberCategory string
15-
rememberName string
16-
rememberSummary string
17-
rememberBody string
18-
rememberDetail string
19-
rememberSession string
14+
rememberCategory string
15+
rememberName string
16+
rememberSummary string
17+
rememberBody string
18+
rememberDetail string
19+
rememberSession string
20+
rememberAcknowledgeRetracted bool
2021
)
2122

2223
var validCategorySet = map[string]bool{
@@ -45,6 +46,7 @@ func init() {
4546
rememberCmd.Flags().StringVarP(&rememberBody, "body", "b", "", "L1 overview — max 2000 chars, compress detail aggressively (required)")
4647
rememberCmd.Flags().StringVarP(&rememberDetail, "detail", "d", "", "L2 full content — max 40000 chars (optional)")
4748
rememberCmd.Flags().StringVar(&rememberSession, "session", "", "Session ID for provenance (optional)")
49+
rememberCmd.Flags().BoolVar(&rememberAcknowledgeRetracted, "acknowledge-retracted", false, "Proceed past a dedup match against retracted memory (use after inspecting with `show --include-retracted`)")
4850

4951
rememberCmd.MarkFlagRequired("category")
5052
rememberCmd.MarkFlagRequired("name")
@@ -67,7 +69,7 @@ func runRemember(cmd *cobra.Command, args []string) error {
6769
return fmt.Errorf("continuity server is not running — start it with: continuity serve")
6870
}
6971

70-
payload := map[string]string{
72+
payload := map[string]any{
7173
"category": rememberCategory,
7274
"name": rememberName,
7375
"summary": rememberSummary,
@@ -79,26 +81,55 @@ func runRemember(cmd *cobra.Command, args []string) error {
7981
if rememberSession != "" {
8082
payload["session_id"] = rememberSession
8183
}
84+
if rememberAcknowledgeRetracted {
85+
payload["acknowledge_retracted"] = true
86+
}
8287

8388
body, err := json.Marshal(payload)
8489
if err != nil {
8590
return fmt.Errorf("marshal: %w", err)
8691
}
8792

88-
data, err := client.Post("/api/memories", body)
89-
if err != nil {
90-
return fmt.Errorf("remember: %w", err)
91-
}
93+
data, postErr := client.Post("/api/memories", body)
9294

95+
// data may carry a structured response on either path (success body or a
96+
// non-2xx JSON error like 409 matches_retracted). Decode opportunistically:
97+
// on the error path, a parse failure means the server returned something
98+
// non-JSON and we fall back to the transport error; on the success path,
99+
// a parse failure is a real bug we surface rather than printing empty fields.
93100
var resp struct {
94-
Status string `json:"status"`
95-
URI string `json:"uri"`
96-
Error string `json:"error"`
101+
Status string `json:"status"`
102+
URI string `json:"uri"`
103+
MatchedURIs []string `json:"matched_uris"`
104+
Hint string `json:"hint"`
105+
Error string `json:"error"`
106+
}
107+
parseErr := json.Unmarshal(data, &resp)
108+
109+
if resp.Status == "matches_retracted" {
110+
fmt.Fprintln(os.Stderr, "matches_retracted: candidate write matches retracted memory")
111+
for _, u := range resp.MatchedURIs {
112+
fmt.Fprintf(os.Stderr, " - %s\n", u)
113+
}
114+
if resp.Hint != "" {
115+
fmt.Fprintln(os.Stderr, resp.Hint)
116+
}
117+
os.Exit(2)
97118
}
98-
if err := json.Unmarshal(data, &resp); err != nil {
99-
return fmt.Errorf("parse response: %w", err)
119+
120+
if postErr != nil {
121+
// Non-2xx: prefer a structured server-side error message if we got one.
122+
if parseErr == nil && resp.Error != "" {
123+
return fmt.Errorf("%s", resp.Error)
124+
}
125+
return fmt.Errorf("remember: %w", postErr)
100126
}
101127

128+
// Success path: a bad decode means the server returned something unexpected.
129+
// Fail fast rather than printing empty fields.
130+
if parseErr != nil {
131+
return fmt.Errorf("parse response: %w", parseErr)
132+
}
102133
if resp.Error != "" {
103134
fmt.Fprintf(os.Stderr, "error: %s\n", resp.Error)
104135
os.Exit(1)

internal/cli/retract.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/lazypower/continuity/internal/hooks"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var (
14+
retractURI string
15+
retractReason string
16+
retractSupersededBy string
17+
)
18+
19+
var retractCmd = &cobra.Command{
20+
Use: "retract <uri>",
21+
Short: "Retract a memory (tombstone or supersession)",
22+
Long: `Retract a memory you wrote. Memory is preserved as a marker but excluded from
23+
default reads — search, tree, context injection. Use --include-retracted on inspection
24+
commands to see retracted memories.
25+
26+
A reason is required, kept short (one sentence). With --superseded-by, the retraction
27+
becomes a supersession: the new memory is reachable normally and the old is linked to it,
28+
preserving the trail of how understanding evolved.
29+
30+
Examples:
31+
continuity retract mem://user/events/test-foo \
32+
--reason "test repro, no ongoing value"
33+
34+
continuity retract mem://user/preferences/old-style \
35+
--reason "preference changed after 2026-04 review" \
36+
--superseded-by mem://user/preferences/new-style`,
37+
Args: cobra.ExactArgs(1),
38+
RunE: runRetract,
39+
}
40+
41+
func init() {
42+
retractCmd.Flags().StringVarP(&retractReason, "reason", "r", "", "Why this memory is being retracted (required, one sentence)")
43+
retractCmd.Flags().StringVar(&retractSupersededBy, "superseded-by", "", "URI of the memory that supersedes this one (optional)")
44+
retractCmd.MarkFlagRequired("reason")
45+
}
46+
47+
func runRetract(cmd *cobra.Command, args []string) error {
48+
retractURI = strings.TrimSpace(args[0])
49+
if !strings.HasPrefix(retractURI, "mem://") {
50+
return fmt.Errorf("invalid URI %q: must start with mem://", retractURI)
51+
}
52+
if retractSupersededBy != "" && !strings.HasPrefix(retractSupersededBy, "mem://") {
53+
return fmt.Errorf("invalid superseded-by URI %q: must start with mem://", retractSupersededBy)
54+
}
55+
56+
client := hooks.NewClient()
57+
if !client.Healthy() {
58+
return fmt.Errorf("continuity server is not running — start it with: continuity serve")
59+
}
60+
61+
payload := map[string]string{
62+
"uri": retractURI,
63+
"reason": retractReason,
64+
}
65+
if retractSupersededBy != "" {
66+
payload["superseded_by"] = retractSupersededBy
67+
}
68+
69+
body, err := json.Marshal(payload)
70+
if err != nil {
71+
return fmt.Errorf("marshal: %w", err)
72+
}
73+
74+
data, err := client.Post("/api/memories/retract", body)
75+
if err != nil {
76+
return fmt.Errorf("retract: %w", err)
77+
}
78+
79+
var resp struct {
80+
Status string `json:"status"`
81+
URI string `json:"uri"`
82+
SupersededBy string `json:"superseded_by"`
83+
Error string `json:"error"`
84+
}
85+
if err := json.Unmarshal(data, &resp); err != nil {
86+
return fmt.Errorf("parse response: %w", err)
87+
}
88+
89+
if resp.Error != "" {
90+
fmt.Fprintf(os.Stderr, "error: %s\n", resp.Error)
91+
os.Exit(1)
92+
}
93+
94+
if resp.SupersededBy != "" {
95+
fmt.Printf("%s: %s → %s\n", resp.Status, resp.URI, resp.SupersededBy)
96+
} else {
97+
fmt.Printf("%s: %s\n", resp.Status, resp.URI)
98+
}
99+
return nil
100+
}

0 commit comments

Comments
 (0)