sphica 0.5.7 → 0.6.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "sphica",
4
- "version": "0.5.7",
4
+ "version": "0.6.1",
5
5
  "description": "Records Claude Code and Codex sessions on your machine and keeps past implementation and decisions, with their sources, for your agent to find.",
6
6
  "author": {
7
7
  "name": "iroha924",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sphica",
3
- "version": "0.5.7",
3
+ "version": "0.6.1",
4
4
  "description": "Records Claude Code and Codex sessions on your machine and keeps past implementation and decisions, with their sources, for your agent to find.",
5
5
  "author": {
6
6
  "name": "iroha924",
package/README.md CHANGED
@@ -22,6 +22,7 @@ The database is a single SQLite file on your machine.
22
22
  - **Records with their sources.** `/sphica:trace` turns a session into records: decisions with the options rejected and why, constraints, implementations, findings, dead ends, and open questions. Every record quotes the exact words it came from, and a decision counts as adopted only when you said so.
23
23
  - **Pull requests too.** `/sphica:harvest <number>` keeps a GitHub pull request (body, comments, reviews, review comments, commits, and up to five issues the body says it closes) and records what it decided. A reviewer's suggestion stays a proposal unless the owner or a maintainer adopted it; a merge alone adopts nothing.
24
24
  - **Evidence found later.** `/sphica:glean` adds evidence and corrections to existing records. It asks you for the source (an issue URL, the file and line, meeting notes) before saving; a claim without one is kept only as unsourced and never used as fact.
25
+ - **Forget what should not have been kept.** `/sphica:forget` removes the messages, pull request items, or file excerpts you pick, with their search entries and the bytes left in the database file, after you confirm in a dialog (if another session is reading the database, it asks you to run it again to finish clearing the bytes). Records that cited them are judged again and leave active when nothing else supports them; a record's own text stays as it was.
25
26
  - **Shown when it matters.** At session start, the current work; before the agent reads or edits a file, or runs a shell command that names it, the decisions tied to that file; when your prompt names a recorded option or code symbol, that record. Works in both Claude Code and Codex.
26
27
  - **Search in Japanese and English.** Records are made with search words in both languages, so a question in either language is more likely to find them.
27
28
  - **Reviews check past decisions.** `/sphica:review` runs a reviewer per focus (correctness, security, written conventions, and past decisions by default; redundancy with `full`), and checks the diff against the records it touches.
@@ -118,14 +119,14 @@ The agent searches with Sphica's `search` and opens full records with `read`. `s
118
119
  - **What.** Your prompts, the agent's final reply for each turn, and the paths of changed files. Background-task notifications and messages from other agents are skipped when Sphica recognizes their format. Replies in the middle of a turn are not kept, nor are files created and deleted within one turn without the edit tools.
119
120
  - **What was shown.** Each automatic delivery is logged by which records it showed, not their text.
120
121
  - **Unregistered repositories.** Sessions in a repository you have not registered stay in the queue and are written after you register it. Held sessions are dropped after 30 days, and when more than 1,000 are waiting the oldest go first.
121
- - **Secrets.** In your prompts, pull request text, and the file lines `/sphica:glean` cites, only secrets with a recognizable shape are masked (excerpts saved before 0.5.7 stay as they were):
122
+ - **Secrets.** In your prompts, pull request text, and the file lines `/sphica:glean` cites, only secrets with a recognizable shape are masked (excerpts saved before 0.5.7 stay as they were until you forget them):
122
123
  - keys with known prefixes
123
124
  - `KEY=…` and `"password": …` assignments
124
125
  - credentials in URLs
125
126
  - authorization headers
126
127
  - `mysql -p`
127
128
 
128
- **Anything else is stored as typed, so do not paste secrets into a session.**
129
+ **Anything else is stored as typed, so do not paste secrets into a session.** If one got in, remove the source holding it with `/sphica:forget` (Claude Code; the confirmation dialog it needs may not appear in Codex). A record that repeated it keeps its own text.
129
130
  - **Network.** Sphica has no account, no hosted service, and no telemetry, and makes no network connections itself. `/sphica:harvest` and `/sphica:glean` run `gh api` with your credentials to read pull requests and issues, `sphica init` runs `gh api user` to read which GitHub account is yours, and `sphica doctor` runs `npm` and `claude` to check installed versions. `gh api` is always sent to github.com.
130
131
  - **Text written by others.** Pull request and issue text may come from anyone. It is kept as a source and passed to the agent as data, never as instructions, and only your words, or those of the repository's owner or a maintainer, can adopt a decision.
131
132
 
@@ -160,7 +161,7 @@ codex plugin marketplace upgrade sphica
160
161
  codex plugin add sphica@sphica
161
162
  ```
162
163
 
163
- Restart open sessions afterwards.
164
+ Restart open sessions afterwards. When an update changes the database (0.6.0 does), update the CLI first, then run `sphica init` once: it migrates the database in place and keeps your records. Until then Sphica says it is unavailable and tells you so. An older CLI cannot read the migrated database: if one still says to move it aside, update that CLI instead.
164
165
 
165
166
  ## Uninstalling
166
167
 
@@ -0,0 +1,145 @@
1
+ -- Revision 1 → 2 of generation 2: the owner can forget chosen sources (issue #187).
2
+ -- `sphica init` runs this in one transaction with foreign keys off (set outside the transaction), then checks foreign_key_check before
3
+ -- committing. Every statement matches db/schema.sql at revision 2; server/test/migrate.test.ts compares a migrated database with a fresh one.
4
+
5
+ -- One owner-confirmed deletion of chosen sources. It stands in for an extraction run on the state changes the deletion causes.
6
+ create table forget_batch (
7
+ id integer primary key autoincrement not null,
8
+ project_id integer not null references project (id) on delete cascade,
9
+ at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', at) is at)
10
+ ) strict;
11
+
12
+ -- A source the owner forgot: its original id and identity, never its text. Capture, harvest, and glean skip an item matching one,
13
+ -- so the same words are not stored again; changed text is new speech and is stored. source_id has no foreign key: the row is gone.
14
+ create table source_forgotten (
15
+ source_id integer primary key not null,
16
+ project_id integer not null references project (id) on delete cascade,
17
+ artifact text not null,
18
+ kind text not null,
19
+ external_id text not null,
20
+ -- An older revision left behind is not the item's current text, and new text numbers after the forgotten one
21
+ revision integer not null check (revision > 0),
22
+ content_hash blob not null check (length(content_hash) = 32),
23
+ batch_id integer not null references forget_batch (id) on delete cascade
24
+ ) strict;
25
+ -- Not unique: an edited item that returns to earlier text has two revisions with the same hash, and both can be forgotten
26
+ create index source_forgotten_item on source_forgotten (project_id, artifact, kind, external_id, content_hash);
27
+
28
+ -- Rebuild unit_state (https://www.sqlite.org/lang_altertable.html#otheralter). The triggers on other tables that name it are dropped
29
+ -- first, or the rename fails on them, and are created again after it.
30
+ drop trigger unit_lifecycle_via_state;
31
+ drop trigger unit_option_sealed;
32
+ create table unit_state_new (
33
+ id integer primary key autoincrement not null,
34
+ unit_id integer not null references unit (id) on delete cascade,
35
+ from_state text check (from_state in ('candidate', 'active', 'superseded', 'withdrawn')),
36
+ to_state text not null check (to_state in ('candidate', 'active', 'superseded', 'withdrawn')),
37
+ at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', at) is at),
38
+ reason text not null check (reason <> ''),
39
+ source_id integer references source (id) on delete set null,
40
+ run_id integer references extraction_run (id),
41
+ forget_id integer references forget_batch (id),
42
+ check ((run_id is null) <> (forget_id is null))
43
+ ) strict;
44
+ insert into unit_state_new (id, unit_id, from_state, to_state, at, reason, source_id, run_id)
45
+ select id, unit_id, from_state, to_state, at, reason, source_id, run_id from unit_state;
46
+ -- AUTOINCREMENT never reuses an id: carry the counter over (dropping unit_state drops its row, and the rename moves this one)
47
+ delete from sqlite_sequence where name = 'unit_state_new';
48
+ insert into sqlite_sequence (name, seq) select 'unit_state_new', seq from sqlite_sequence where name = 'unit_state';
49
+ drop table unit_state;
50
+ alter table unit_state_new rename to unit_state;
51
+ create index unit_state_order on unit_state (unit_id, id);
52
+ create trigger unit_state_rules before insert on unit_state begin
53
+ select raise(abort, 'from_state must be the current lifecycle')
54
+ where new.from_state is not (select lifecycle from unit where id = new.unit_id)
55
+ and exists (select 1 from unit_state where unit_id = new.unit_id);
56
+ select raise(abort, 'a quarantined or unsourced unit cannot become active')
57
+ where new.to_state = 'active' and exists (select 1 from unit where id = new.unit_id and (extraction <> 'supported' or unsourced = 1));
58
+ select raise(abort, 'an active decision or constraint needs unretracted evidence and adoption')
59
+ where new.to_state = 'active' and exists (select 1 from unit u where u.id = new.unit_id and u.kind in ('decision', 'constraint') and (
60
+ not exists (select 1 from unit_evidence e where e.unit_id = u.id and e.option_id is null and e.retracted_at is null)
61
+ or not exists (select 1 from unit_adoption a where a.unit_id = u.id and a.retracted_at is null)));
62
+ select raise(abort, 'an active implementation needs code or commit evidence')
63
+ where new.to_state = 'active' and exists (select 1 from unit u where u.id = new.unit_id and u.kind = 'implementation' and not (
64
+ exists (select 1 from unit_evidence e join source s on s.id = e.source_id where e.unit_id = u.id and e.option_id is null
65
+ and e.retracted_at is null and e.role = 'implements' and s.kind in ('commit_message', 'file_excerpt'))
66
+ or exists (select 1 from unit_anchor a where a.unit_id = u.id and a.retired_at is null and a.role = 'evidence'
67
+ and (a.commit_sha is not null or (a.edit_observation_id is not null and exists (select 1 from unit_evidence e
68
+ join source s on s.id = e.source_id join edit_observation o on o.id = a.edit_observation_id
69
+ where e.unit_id = u.id and e.option_id is null and e.retracted_at is null and e.role = 'implements'
70
+ and s.session_id = o.session_id))))));
71
+ select raise(abort, 'an active unit needs unretracted evidence')
72
+ where new.to_state = 'active' and exists (select 1 from unit u where u.id = new.unit_id and u.kind in ('finding', 'dead_end', 'question')
73
+ and not exists (select 1 from unit_evidence e where e.unit_id = u.id and e.option_id is null and e.retracted_at is null));
74
+ select raise(abort, 'superseded needs a supersedes link from its successor')
75
+ where new.to_state = 'superseded' and not exists (select 1 from unit_link where to_unit = new.unit_id and kind = 'supersedes');
76
+ end;
77
+ create trigger unit_state_append_only before update on unit_state
78
+ when not (new.source_id is null and old.source_id is not null and not exists (select 1 from source where id = old.source_id)
79
+ and new.id is old.id and new.unit_id is old.unit_id and new.from_state is old.from_state and new.to_state is old.to_state
80
+ and new.at is old.at and new.reason is old.reason and new.run_id is old.run_id and new.forget_id is old.forget_id) begin
81
+ select raise(abort, 'state history is append-only');
82
+ end;
83
+ create trigger unit_state_no_delete before delete on unit_state when exists (select 1 from unit where id = old.unit_id) begin
84
+ select raise(abort, 'state history is append-only');
85
+ end;
86
+ create trigger unit_state_apply after insert on unit_state begin
87
+ update unit set lifecycle = new.to_state, revision = revision + 1 where id = new.unit_id;
88
+ end;
89
+ create trigger unit_state_project before insert on unit_state begin
90
+ select raise(abort, 'state and unit belong to different projects')
91
+ where (new.run_id is not null
92
+ and (select project_id from unit where id = new.unit_id) is not (select project_id from extraction_run where id = new.run_id))
93
+ or (new.forget_id is not null
94
+ and (select project_id from unit where id = new.unit_id) is not (select project_id from forget_batch where id = new.forget_id))
95
+ or (new.source_id is not null
96
+ and (select project_id from unit where id = new.unit_id) is not (select project_id from source where id = new.source_id));
97
+ end;
98
+ create trigger unit_lifecycle_via_state before update of lifecycle on unit
99
+ when new.lifecycle is not (select to_state from unit_state where unit_id = new.id order by id desc limit 1) begin
100
+ select raise(abort, 'lifecycle changes only through unit_state');
101
+ end;
102
+ create trigger unit_option_sealed before insert on unit_option
103
+ when exists (select 1 from unit_state where unit_id = new.unit_id) or exists (select 1 from unit_alias where unit_id = new.unit_id) begin
104
+ select raise(abort, 'options are written with the unit, before its first state; record a successor instead');
105
+ end;
106
+
107
+ drop trigger unit_evidence_no_delete;
108
+ create trigger unit_evidence_no_delete before delete on unit_evidence
109
+ when exists (select 1 from unit where id = old.unit_id) and exists (select 1 from source where id = old.source_id)
110
+ and not (old.retracted_at is not null and exists (select 1 from source_forgotten where source_id = old.retraction_source_id)) begin
111
+ select raise(abort, 'evidence is retracted, never deleted');
112
+ end;
113
+ drop trigger unit_adoption_no_delete;
114
+ create trigger unit_adoption_no_delete before delete on unit_adoption
115
+ when exists (select 1 from unit where id = old.unit_id) and exists (select 1 from source where id = old.source_id)
116
+ and not (old.retracted_at is not null and exists (select 1 from source_forgotten where source_id = old.retraction_source_id)) begin
117
+ select raise(abort, 'adoption is retracted, never deleted');
118
+ end;
119
+ drop trigger capture_message_insert;
120
+ create trigger capture_message_insert instead of insert on capture_message begin
121
+ select raise(abort, 'unknown session') where not exists (select 1 from session where id = new.session_id);
122
+ select raise(abort, 'speaker must be owner or assistant') where new.speaker not in ('owner', 'assistant');
123
+ select raise(abort, 'the message already exists with different content')
124
+ where exists (select 1 from source where kind = 'session_message' and session_id = new.session_id and external_id = new.external_id
125
+ and (text is not new.text or author_kind is not new.speaker or turn_id is not new.turn_id or created_at is not new.created_at
126
+ or truncated is not new.truncated or redacted is not new.redacted or original_bytes is not new.original_bytes
127
+ or content_hash is not new.content_hash));
128
+ insert into source (project_id, kind, artifact, external_id, revision, session_id, turn_id, author_kind, created_at, available_at,
129
+ captured_at, text, truncated, redacted, original_bytes, content_hash, indexed)
130
+ select s.project_id, 'session_message', 'session:' || s.id, new.external_id, 1, s.id, new.turn_id, new.speaker, new.created_at,
131
+ new.created_at, new.captured_at, new.text, new.truncated, new.redacted, new.original_bytes, new.content_hash,
132
+ new.speaker = 'owner'
133
+ from session s where s.id = new.session_id
134
+ and not exists (select 1 from source where kind = 'session_message' and session_id = new.session_id and external_id = new.external_id)
135
+ and not exists (select 1 from source_forgotten f where f.project_id = s.project_id and f.artifact = 'session:' || s.id
136
+ and f.kind = 'session_message' and f.external_id = new.external_id and f.content_hash = new.content_hash);
137
+ end;
138
+ create trigger unit_rev_evidence_d after delete on unit_evidence when exists (select 1 from unit where id = old.unit_id) begin
139
+ update unit set revision = revision + 1 where id = old.unit_id;
140
+ end;
141
+ create trigger unit_rev_adoption_d after delete on unit_adoption when exists (select 1 from unit where id = old.unit_id) begin
142
+ update unit set revision = revision + 1 where id = old.unit_id;
143
+ end;
144
+
145
+ pragma user_version = 2;
package/db/schema.sql CHANGED
@@ -4,6 +4,7 @@
4
4
  --
5
5
  -- Four boundaries:
6
6
  -- captured sources session, source, artifact_link, edit_observation, external_reference: what was said or written, never rewritten
7
+ -- (the owner can forget chosen sources: forget_batch and source_forgotten keep what was removed, without its text)
7
8
  -- extracted units unit and its option, evidence, adoption, link, state, anchor, alias tables: what was decided or implemented
8
9
  -- processing extraction_run, source_processing: what has been looked at and saved, so gaps are counted
9
10
  -- work and delivery work, delivery, delivery_unit: the current work status and what the hooks injected
@@ -145,6 +146,29 @@ create table external_reference (
145
146
  added_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', added_at) is added_at)
146
147
  ) strict;
147
148
 
149
+ -- One owner-confirmed deletion of chosen sources. It stands in for an extraction run on the state changes the deletion causes.
150
+ create table forget_batch (
151
+ id integer primary key autoincrement not null,
152
+ project_id integer not null references project (id) on delete cascade,
153
+ at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', at) is at)
154
+ ) strict;
155
+
156
+ -- A source the owner forgot: its original id and identity, never its text. Capture, harvest, and glean skip an item matching one,
157
+ -- so the same words are not stored again; changed text is new speech and is stored. source_id has no foreign key: the row is gone.
158
+ create table source_forgotten (
159
+ source_id integer primary key not null,
160
+ project_id integer not null references project (id) on delete cascade,
161
+ artifact text not null,
162
+ kind text not null,
163
+ external_id text not null,
164
+ -- An older revision left behind is not the item's current text, and new text numbers after the forgotten one
165
+ revision integer not null check (revision > 0),
166
+ content_hash blob not null check (length(content_hash) = 32),
167
+ batch_id integer not null references forget_batch (id) on delete cascade
168
+ ) strict;
169
+ -- Not unique: an edited item that returns to earlier text has two revisions with the same hash, and both can be forgotten
170
+ create index source_forgotten_item on source_forgotten (project_id, artifact, kind, external_id, content_hash);
171
+
148
172
  -- A file path an edit tool reported, or that a turn-boundary git status snapshot found. It is not an implementation record.
149
173
  create table edit_observation (
150
174
  id integer primary key autoincrement not null,
@@ -352,6 +376,7 @@ create trigger unit_link_supersedes_acyclic before insert on unit_link when new.
352
376
  end;
353
377
 
354
378
  -- Lifecycle history and the only route for lifecycle changes. The trigger checks the rules and then sets unit.lifecycle.
379
+ -- A change comes from an extraction run, or from the owner forgetting sources (forget_id). source_id becomes null when its source is forgotten.
355
380
  create table unit_state (
356
381
  id integer primary key autoincrement not null,
357
382
  unit_id integer not null references unit (id) on delete cascade,
@@ -359,8 +384,10 @@ create table unit_state (
359
384
  to_state text not null check (to_state in ('candidate', 'active', 'superseded', 'withdrawn')),
360
385
  at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', at) is at),
361
386
  reason text not null check (reason <> ''),
362
- source_id integer references source (id),
363
- run_id integer not null references extraction_run (id)
387
+ source_id integer references source (id) on delete set null,
388
+ run_id integer references extraction_run (id),
389
+ forget_id integer references forget_batch (id),
390
+ check ((run_id is null) <> (forget_id is null))
364
391
  ) strict;
365
392
  create index unit_state_order on unit_state (unit_id, id);
366
393
  create trigger unit_state_rules before insert on unit_state begin
@@ -388,7 +415,11 @@ create trigger unit_state_rules before insert on unit_state begin
388
415
  select raise(abort, 'superseded needs a supersedes link from its successor')
389
416
  where new.to_state = 'superseded' and not exists (select 1 from unit_link where to_unit = new.unit_id and kind = 'supersedes');
390
417
  end;
391
- create trigger unit_state_append_only before update on unit_state begin
418
+ -- The one update allowed is the foreign key action clearing source_id after its source was forgotten
419
+ create trigger unit_state_append_only before update on unit_state
420
+ when not (new.source_id is null and old.source_id is not null and not exists (select 1 from source where id = old.source_id)
421
+ and new.id is old.id and new.unit_id is old.unit_id and new.from_state is old.from_state and new.to_state is old.to_state
422
+ and new.at is old.at and new.reason is old.reason and new.run_id is old.run_id and new.forget_id is old.forget_id) begin
392
423
  select raise(abort, 'state history is append-only');
393
424
  end;
394
425
  create trigger unit_state_no_delete before delete on unit_state when exists (select 1 from unit where id = old.unit_id) begin
@@ -475,13 +506,16 @@ create trigger unit_evidence_retract before update on unit_evidence begin
475
506
  and s.project_id = (select project_id from unit where id = new.unit_id)
476
507
  and new.retraction_span_end <= length(cast(s.text as blob)));
477
508
  end;
478
- -- Deleting a project (or its unit or source) cascades; only a direct delete of a live link is refused
509
+ -- Deleting a project (or its unit or source) cascades; only a direct delete of a live link is refused.
510
+ -- A retracted row whose retraction reason cites a forgotten source is removed with that source (the reason cannot outlive it).
479
511
  create trigger unit_evidence_no_delete before delete on unit_evidence
480
- when exists (select 1 from unit where id = old.unit_id) and exists (select 1 from source where id = old.source_id) begin
512
+ when exists (select 1 from unit where id = old.unit_id) and exists (select 1 from source where id = old.source_id)
513
+ and not (old.retracted_at is not null and exists (select 1 from source_forgotten where source_id = old.retraction_source_id)) begin
481
514
  select raise(abort, 'evidence is retracted, never deleted');
482
515
  end;
483
516
  create trigger unit_adoption_no_delete before delete on unit_adoption
484
- when exists (select 1 from unit where id = old.unit_id) and exists (select 1 from source where id = old.source_id) begin
517
+ when exists (select 1 from unit where id = old.unit_id) and exists (select 1 from source where id = old.source_id)
518
+ and not (old.retracted_at is not null and exists (select 1 from source_forgotten where source_id = old.retraction_source_id)) begin
485
519
  select raise(abort, 'adoption is retracted, never deleted');
486
520
  end;
487
521
  -- A retraction that would leave an active unit without its required support must first move it back to candidate
@@ -519,7 +553,10 @@ create trigger unit_link_check before insert on unit_link begin
519
553
  end;
520
554
  create trigger unit_state_project before insert on unit_state begin
521
555
  select raise(abort, 'state and unit belong to different projects')
522
- where (select project_id from unit where id = new.unit_id) is not (select project_id from extraction_run where id = new.run_id)
556
+ where (new.run_id is not null
557
+ and (select project_id from unit where id = new.unit_id) is not (select project_id from extraction_run where id = new.run_id))
558
+ or (new.forget_id is not null
559
+ and (select project_id from unit where id = new.unit_id) is not (select project_id from forget_batch where id = new.forget_id))
523
560
  or (new.source_id is not null
524
561
  and (select project_id from unit where id = new.unit_id) is not (select project_id from source where id = new.source_id));
525
562
  end;
@@ -549,6 +586,12 @@ create trigger unit_rev_evidence_i after insert on unit_evidence begin update un
549
586
  create trigger unit_rev_evidence_u after update on unit_evidence begin update unit set revision = revision + 1 where id = new.unit_id; end;
550
587
  create trigger unit_rev_adoption_i after insert on unit_adoption begin update unit set revision = revision + 1 where id = new.unit_id; end;
551
588
  create trigger unit_rev_adoption_u after update on unit_adoption begin update unit set revision = revision + 1 where id = new.unit_id; end;
589
+ create trigger unit_rev_evidence_d after delete on unit_evidence when exists (select 1 from unit where id = old.unit_id) begin
590
+ update unit set revision = revision + 1 where id = old.unit_id;
591
+ end;
592
+ create trigger unit_rev_adoption_d after delete on unit_adoption when exists (select 1 from unit where id = old.unit_id) begin
593
+ update unit set revision = revision + 1 where id = old.unit_id;
594
+ end;
552
595
  create trigger unit_rev_link_i after insert on unit_link begin
553
596
  update unit set revision = revision + 1 where id in (new.from_unit, new.to_unit);
554
597
  end;
@@ -671,7 +714,9 @@ create trigger capture_message_insert instead of insert on capture_message begin
671
714
  new.created_at, new.captured_at, new.text, new.truncated, new.redacted, new.original_bytes, new.content_hash,
672
715
  new.speaker = 'owner'
673
716
  from session s where s.id = new.session_id
674
- and not exists (select 1 from source where kind = 'session_message' and session_id = new.session_id and external_id = new.external_id);
717
+ and not exists (select 1 from source where kind = 'session_message' and session_id = new.session_id and external_id = new.external_id)
718
+ and not exists (select 1 from source_forgotten f where f.project_id = s.project_id and f.artifact = 'session:' || s.id
719
+ and f.kind = 'session_message' and f.external_id = new.external_id and f.content_hash = new.content_hash);
675
720
  end;
676
721
  create view capture_edit as select session_id, turn_id, tool_event_id, path, via, observed_at from edit_observation;
677
722
  create trigger capture_edit_insert instead of insert on capture_edit begin
@@ -693,4 +738,4 @@ create trigger capture_delivery_insert instead of insert on capture_delivery beg
693
738
  select last_insert_rowid(), j.value from json_each(coalesce(new.units, '[]')) j where true on conflict do nothing;
694
739
  end;
695
740
 
696
- pragma user_version = 1;
741
+ pragma user_version = 2;