sphica 0.5.6 → 0.6.0
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +4 -3
- package/db/migrations/0002.sql +145 -0
- package/db/schema.sql +54 -9
- package/dist/capture.js +61 -19
- package/dist/cli.js +95 -22
- package/dist/deliver.js +300 -258
- package/dist/mcp-record.js +450 -53
- package/dist/mcp.js +7 -8
- package/package.json +1 -1
- package/skills/forget/SKILL.md +53 -0
- package/skills/forget/agents/openai.yaml +2 -0
- package/skills/glean/SKILL.md +2 -2
- package/skills/harvest/SKILL.md +1 -1
- package/skills/trace/SKILL.md +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "sphica",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
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",
|
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.**
|
|
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
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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 (
|
|
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 =
|
|
741
|
+
pragma user_version = 2;
|
package/dist/capture.js
CHANGED
|
@@ -10813,7 +10813,7 @@ import os from "node:os";
|
|
|
10813
10813
|
import path from "node:path";
|
|
10814
10814
|
import { constants as C, DatabaseSync } from "node:sqlite";
|
|
10815
10815
|
var SCHEMA_GENERATION = 2;
|
|
10816
|
-
var SCHEMA_REVISION =
|
|
10816
|
+
var SCHEMA_REVISION = 2;
|
|
10817
10817
|
var sphicaHome = () => process.env.SPHICA_HOME || path.join(os.homedir(), ".sphica");
|
|
10818
10818
|
var dbFile = () => process.env.SPHICA_DB || path.join(sphicaHome(), "sphica.db");
|
|
10819
10819
|
function requireRuntime() {
|
|
@@ -10837,7 +10837,7 @@ function prepare(raw, check) {
|
|
|
10837
10837
|
const got = raw.prepare("pragma user_version").get()?.user_version;
|
|
10838
10838
|
if (got === SCHEMA_REVISION)
|
|
10839
10839
|
return;
|
|
10840
|
-
throw new Error(`The database schema is revision ${got}, but this Sphica expects revision ${SCHEMA_REVISION}. ` + ((got ?? 0) < SCHEMA_REVISION ? "
|
|
10840
|
+
throw new Error(`The database schema is revision ${got}, but this Sphica expects revision ${SCHEMA_REVISION}. ` + ((got ?? 0) < SCHEMA_REVISION ? "Update the sphica CLI (`npm i -g sphica`), then run `sphica init` to migrate it (records are kept)." : "Update sphica."));
|
|
10841
10841
|
}
|
|
10842
10842
|
function generationOf(raw) {
|
|
10843
10843
|
const has = raw.prepare("select 1 from sqlite_schema where type = 'table' and name = 'sphica_generation'").get();
|
|
@@ -11108,24 +11108,32 @@ var MYSQL_PASSWORD = /(\s-p)(?:'[^'\n]*'|"[^"\n]*"|(?=[^\s-])\S+)/;
|
|
|
11108
11108
|
var URL_CREDENTIALS = /\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?|amqps?|https?):\/\/[^:\s/@]*:)[^\s/]*@([^@\s/?#]+)/g;
|
|
11109
11109
|
var KEY_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/g;
|
|
11110
11110
|
var KEY_END = /-----END [A-Z ]*PRIVATE KEY-----/g;
|
|
11111
|
-
function
|
|
11111
|
+
function keyBlocks(text) {
|
|
11112
11112
|
const ends = [...text.matchAll(KEY_END)].map((m) => [m.index, m.index + m[0].length]);
|
|
11113
|
-
|
|
11114
|
-
return text;
|
|
11115
|
-
let out = "";
|
|
11113
|
+
const out = [];
|
|
11116
11114
|
let last = 0;
|
|
11117
11115
|
let e = 0;
|
|
11118
|
-
|
|
11119
|
-
const
|
|
11120
|
-
|
|
11121
|
-
|
|
11122
|
-
|
|
11123
|
-
e
|
|
11124
|
-
|
|
11125
|
-
|
|
11126
|
-
|
|
11127
|
-
|
|
11128
|
-
|
|
11116
|
+
if (ends.length > 0)
|
|
11117
|
+
for (const m of text.matchAll(KEY_BEGIN)) {
|
|
11118
|
+
const after = m.index + m[0].length;
|
|
11119
|
+
if (m.index < last)
|
|
11120
|
+
continue;
|
|
11121
|
+
while (e < ends.length && (ends[e]?.[0] ?? 0) < after)
|
|
11122
|
+
e++;
|
|
11123
|
+
const end = ends[e];
|
|
11124
|
+
if (!end)
|
|
11125
|
+
break;
|
|
11126
|
+
out.push([m.index, end[1]]);
|
|
11127
|
+
last = end[1];
|
|
11128
|
+
}
|
|
11129
|
+
return out;
|
|
11130
|
+
}
|
|
11131
|
+
function maskPrivateKeys(text) {
|
|
11132
|
+
let out = "";
|
|
11133
|
+
let last = 0;
|
|
11134
|
+
for (const [a, b] of keyBlocks(text)) {
|
|
11135
|
+
out += `${text.slice(last, a)}[redacted: private key]`;
|
|
11136
|
+
last = b;
|
|
11129
11137
|
}
|
|
11130
11138
|
return out + text.slice(last);
|
|
11131
11139
|
}
|
|
@@ -11226,22 +11234,54 @@ function captureAuthorizer(own, action, p1, p2, triggerOrView) {
|
|
|
11226
11234
|
return C2.SQLITE_DENY;
|
|
11227
11235
|
}
|
|
11228
11236
|
var writes = (action) => action === C2.SQLITE_INSERT || action === C2.SQLITE_UPDATE || action === C2.SQLITE_DELETE;
|
|
11237
|
+
var FORGET_ONLY = new Set(["forget_batch", "source_forgotten"]);
|
|
11238
|
+
var KEPT_BY_INGEST = new Set(["source", "unit_evidence", "unit_adoption"]);
|
|
11229
11239
|
function ingestAuthorizer(action, p1, p2) {
|
|
11230
11240
|
if (DDL().has(action))
|
|
11231
11241
|
return C2.SQLITE_DENY;
|
|
11232
|
-
if (writes(action) && p1 === "owner_identity")
|
|
11242
|
+
if (writes(action) && (p1 === "owner_identity" || FORGET_ONLY.has(p1 ?? "")))
|
|
11243
|
+
return C2.SQLITE_DENY;
|
|
11244
|
+
if (action === C2.SQLITE_DELETE && KEPT_BY_INGEST.has(p1 ?? ""))
|
|
11233
11245
|
return C2.SQLITE_DENY;
|
|
11234
11246
|
if (action === C2.SQLITE_PRAGMA)
|
|
11235
11247
|
return readsDataVersion(p1, p2) ? C2.SQLITE_OK : C2.SQLITE_DENY;
|
|
11236
11248
|
return C2.SQLITE_OK;
|
|
11237
11249
|
}
|
|
11250
|
+
var FORGET_WRITES = {
|
|
11251
|
+
[C2.SQLITE_INSERT]: new Set(["forget_batch", "source_forgotten", "unit_state", "source_fts"]),
|
|
11252
|
+
[C2.SQLITE_DELETE]: new Set([
|
|
11253
|
+
"source",
|
|
11254
|
+
"external_reference",
|
|
11255
|
+
"unit_evidence",
|
|
11256
|
+
"unit_adoption",
|
|
11257
|
+
"source_processing",
|
|
11258
|
+
"source_fts"
|
|
11259
|
+
])
|
|
11260
|
+
};
|
|
11261
|
+
var FORGET_UPDATES = {
|
|
11262
|
+
unit: new Set(["lifecycle", "revision"]),
|
|
11263
|
+
unit_state: new Set(["source_id"])
|
|
11264
|
+
};
|
|
11265
|
+
function forgetAuthorizer(action, p1, p2) {
|
|
11266
|
+
if (SHADOW.test(p1 ?? "") && writes(action))
|
|
11267
|
+
return C2.SQLITE_OK;
|
|
11268
|
+
if (action === C2.SQLITE_UPDATE)
|
|
11269
|
+
return FORGET_UPDATES[p1 ?? ""]?.has(p2 ?? "") ? C2.SQLITE_OK : C2.SQLITE_DENY;
|
|
11270
|
+
if (action === C2.SQLITE_INSERT || action === C2.SQLITE_DELETE)
|
|
11271
|
+
return FORGET_WRITES[action]?.has(p1 ?? "") ? C2.SQLITE_OK : C2.SQLITE_DENY;
|
|
11272
|
+
if (action === C2.SQLITE_PRAGMA)
|
|
11273
|
+
return readsDataVersion(p1, p2) || p1 === "secure_delete" && ["on", "1", "true"].includes((p2 ?? "").toLowerCase()) || p1 === "wal_checkpoint" ? C2.SQLITE_OK : C2.SQLITE_DENY;
|
|
11274
|
+
if (action === C2.SQLITE_READ || action === C2.SQLITE_SELECT || action === C2.SQLITE_FUNCTION || action === C2.SQLITE_TRANSACTION || action === C2.SQLITE_SAVEPOINT)
|
|
11275
|
+
return C2.SQLITE_OK;
|
|
11276
|
+
return C2.SQLITE_DENY;
|
|
11277
|
+
}
|
|
11238
11278
|
function connectWriter(role, file = dbFile(), create = false) {
|
|
11239
11279
|
requireRuntime();
|
|
11240
11280
|
if (!create)
|
|
11241
11281
|
requireFile(file);
|
|
11242
11282
|
const raw = new DatabaseSync2(file);
|
|
11243
11283
|
try {
|
|
11244
|
-
prepare(raw, create ? "none" : role === "ingest" ? "revision" : "generation");
|
|
11284
|
+
prepare(raw, create ? "none" : role === "ingest" || role === "forget" ? "revision" : "generation");
|
|
11245
11285
|
raw.function("sphica_terms", { deterministic: true }, (text) => terms(String(text ?? "")).join(" "));
|
|
11246
11286
|
} catch (e) {
|
|
11247
11287
|
raw.close();
|
|
@@ -11249,6 +11289,8 @@ function connectWriter(role, file = dbFile(), create = false) {
|
|
|
11249
11289
|
}
|
|
11250
11290
|
if (role === "ingest")
|
|
11251
11291
|
raw.setAuthorizer(ingestAuthorizer);
|
|
11292
|
+
else if (role === "forget")
|
|
11293
|
+
raw.setAuthorizer(forgetAuthorizer);
|
|
11252
11294
|
else if (role === "capture") {
|
|
11253
11295
|
let own = false;
|
|
11254
11296
|
raw.setAuthorizer((action, p1, p2, _db, triggerOrView) => captureAuthorizer(own, action, p1, p2, triggerOrView));
|