sphica 0.6.16 → 0.6.18

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.6.16",
4
+ "version": "0.6.18",
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.6.16",
3
+ "version": "0.6.18",
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",
@@ -0,0 +1,20 @@
1
+ -- Revision 5 → 6 of generation 2: the subagent a delivery ran in (existing rows keep null, the main conversation), and a capture view
2
+ -- that writes it while capture_delivery keeps its columns. `sphica init` runs this in one transaction with foreign keys off, then checks
3
+ -- foreign_key_check. Every statement matches db/schema.sql at revision 6; server/test/migrate.test.ts compares the two.
4
+
5
+ alter table delivery add column agent_id text check (length(agent_id) between 1 and 200);
6
+
7
+ create view capture_delivery_scoped as
8
+ select session_id, agent_id, event, outcome, reason, path, eligible, omitted, chars, at, null as units from delivery;
9
+ create trigger capture_delivery_scoped_insert instead of insert on capture_delivery_scoped begin
10
+ select raise(abort, 'the unit and the delivered session belong to different projects')
11
+ where exists (select 1 from json_each(coalesce(new.units, '[]')) j
12
+ where (select project_id from unit where id = j.value) is not (select project_id from session where id = new.session_id));
13
+ insert into delivery (session_id, agent_id, event, outcome, reason, path, eligible, omitted, chars, at)
14
+ values (new.session_id, new.agent_id, new.event, new.outcome, new.reason, new.path, coalesce(new.eligible, 0),
15
+ coalesce(new.omitted, 0), coalesce(new.chars, 0), new.at);
16
+ insert into delivery_unit (delivery_id, unit_id)
17
+ select last_insert_rowid(), j.value from json_each(coalesce(new.units, '[]')) j where true on conflict do nothing;
18
+ end;
19
+
20
+ pragma user_version = 6;
@@ -0,0 +1,49 @@
1
+ -- Revision 6 → 7 of generation 2: capture prunes the deliveries of sessions idle past a cutoff, so delivery_unit loses its cascade from
2
+ -- delivery (capture may not delete through one) and delivery_ad removes the units. Runs in one transaction with foreign keys off; every
3
+ -- statement matches db/schema.sql at revision 7, and server/test/migrate.test.ts compares the two.
4
+
5
+ drop trigger capture_delivery_insert;
6
+ drop trigger capture_delivery_scoped_insert;
7
+ create table delivery_unit_new (
8
+ delivery_id integer not null references delivery (id),
9
+ unit_id integer not null references unit (id) on delete cascade,
10
+ primary key (delivery_id, unit_id)
11
+ ) strict;
12
+ insert into delivery_unit_new (delivery_id, unit_id) select delivery_id, unit_id from delivery_unit;
13
+ drop table delivery_unit;
14
+ alter table delivery_unit_new rename to delivery_unit;
15
+ create index delivery_unit_unit on delivery_unit (unit_id);
16
+ create trigger delivery_ad after delete on delivery begin
17
+ delete from delivery_unit where delivery_id = old.id;
18
+ end;
19
+ create index delivery_at on delivery (at);
20
+ create trigger capture_delivery_insert instead of insert on capture_delivery begin
21
+ select raise(abort, 'the unit and the delivered session belong to different projects')
22
+ where exists (select 1 from json_each(coalesce(new.units, '[]')) j
23
+ where (select project_id from unit where id = j.value) is not (select project_id from session where id = new.session_id));
24
+ insert into delivery (session_id, event, outcome, reason, path, eligible, omitted, chars, at)
25
+ values (new.session_id, new.event, new.outcome, new.reason, new.path, coalesce(new.eligible, 0), coalesce(new.omitted, 0),
26
+ coalesce(new.chars, 0), new.at);
27
+ insert into delivery_unit (delivery_id, unit_id)
28
+ select last_insert_rowid(), j.value from json_each(coalesce(new.units, '[]')) j where true on conflict do nothing;
29
+ end;
30
+ create trigger capture_delivery_scoped_insert instead of insert on capture_delivery_scoped begin
31
+ select raise(abort, 'the unit and the delivered session belong to different projects')
32
+ where exists (select 1 from json_each(coalesce(new.units, '[]')) j
33
+ where (select project_id from unit where id = j.value) is not (select project_id from session where id = new.session_id));
34
+ insert into delivery (session_id, agent_id, event, outcome, reason, path, eligible, omitted, chars, at)
35
+ values (new.session_id, new.agent_id, new.event, new.outcome, new.reason, new.path, coalesce(new.eligible, 0),
36
+ coalesce(new.omitted, 0), coalesce(new.chars, 0), new.at);
37
+ insert into delivery_unit (delivery_id, unit_id)
38
+ select last_insert_rowid(), j.value from json_each(coalesce(new.units, '[]')) j where true on conflict do nothing;
39
+ end;
40
+ create view capture_delivery_prune as select null as cutoff, null as session_id;
41
+ create trigger capture_delivery_prune_insert instead of insert on capture_delivery_prune begin
42
+ delete from delivery where session_id = new.session_id
43
+ and not exists (select 1 from delivery n where n.session_id = new.session_id and n.at >= new.cutoff);
44
+ delete from delivery where id in (select d.id from delivery d where d.at < new.cutoff
45
+ and (d.session_id is null or not exists (select 1 from delivery n where n.session_id = d.session_id and n.at >= new.cutoff))
46
+ order by d.at, d.id limit 200);
47
+ end;
48
+
49
+ pragma user_version = 7;
package/db/schema.sql CHANGED
@@ -934,8 +934,9 @@ create table work (
934
934
  create index work_open on work (project_id, updated_at desc) where status in ('active', 'blocked', 'paused');
935
935
  create index work_run on work (run_id) where run_id is not null;
936
936
 
937
- -- What a delivery hook emitted or suppressed, and how many eligible units it left out. No source text is copied here.
938
- -- chars is the delivered length without the omission note (Sphica's own text), since the read budget adds it up.
937
+ -- What a delivery hook emitted or suppressed, and how many eligible units it left out (no source text). chars leaves out the omission note,
938
+ -- since the read budget adds it up. A session_start's reason is the start source or 'subagent'; agent_id is the subagent (null: main),
939
+ -- written the way SQLite stores a column ALTER TABLE added, so a migrated database has the same definition.
939
940
  create table delivery (
940
941
  id integer primary key autoincrement not null,
941
942
  session_id text references session (id) on delete cascade,
@@ -947,14 +948,19 @@ create table delivery (
947
948
  omitted integer not null default 0 check (omitted >= 0 and omitted <= eligible),
948
949
  chars integer not null default 0 check (chars >= 0),
949
950
  at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', at) is at)
950
- ) strict;
951
+ , agent_id text check (length(agent_id) between 1 and 200)) strict;
951
952
  create index delivery_session on delivery (session_id, at);
953
+ create index delivery_at on delivery (at);
954
+ -- A delivery's units go by delivery_ad, not a cascade: capture prunes deliveries, and its authorizer passes a delete only from a named trigger
952
955
  create table delivery_unit (
953
- delivery_id integer not null references delivery (id) on delete cascade,
956
+ delivery_id integer not null references delivery (id),
954
957
  unit_id integer not null references unit (id) on delete cascade,
955
958
  primary key (delivery_id, unit_id)
956
959
  ) strict;
957
960
  create index delivery_unit_unit on delivery_unit (unit_id);
961
+ create trigger delivery_ad after delete on delivery begin
962
+ delete from delivery_unit where delivery_id = old.id;
963
+ end;
958
964
 
959
965
  -- The one way the record server writes a source. It has no session columns, so it can never write a session message: those are the
960
966
  -- owner's own words, and only capture writes them. A view insert returns no id; the writer looks the row up by its item key
@@ -1024,5 +1030,29 @@ create trigger capture_delivery_insert instead of insert on capture_delivery beg
1024
1030
  insert into delivery_unit (delivery_id, unit_id)
1025
1031
  select last_insert_rowid(), j.value from json_each(coalesce(new.units, '[]')) j where true on conflict do nothing;
1026
1032
  end;
1033
+ -- The same as capture_delivery with the subagent the delivery ran in. capture_delivery keeps its columns, since an older plugin's
1034
+ -- capture writes through it into a database of this revision
1035
+ create view capture_delivery_scoped as
1036
+ select session_id, agent_id, event, outcome, reason, path, eligible, omitted, chars, at, null as units from delivery;
1037
+ create trigger capture_delivery_scoped_insert instead of insert on capture_delivery_scoped begin
1038
+ select raise(abort, 'the unit and the delivered session belong to different projects')
1039
+ where exists (select 1 from json_each(coalesce(new.units, '[]')) j
1040
+ where (select project_id from unit where id = j.value) is not (select project_id from session where id = new.session_id));
1041
+ insert into delivery (session_id, agent_id, event, outcome, reason, path, eligible, omitted, chars, at)
1042
+ values (new.session_id, new.agent_id, new.event, new.outcome, new.reason, new.path, coalesce(new.eligible, 0),
1043
+ coalesce(new.omitted, 0), coalesce(new.chars, 0), new.at);
1044
+ insert into delivery_unit (delivery_id, unit_id)
1045
+ select last_insert_rowid(), j.value from json_each(coalesce(new.units, '[]')) j where true on conflict do nothing;
1046
+ end;
1047
+ -- Deletes the deliveries of sessions whose last delivery is before cutoff: all of session_id's, then up to 200 of the oldest others (a row
1048
+ -- without a session goes by its own time). Capture inserts here before each delivery it logs, naming that delivery's session
1049
+ create view capture_delivery_prune as select null as cutoff, null as session_id;
1050
+ create trigger capture_delivery_prune_insert instead of insert on capture_delivery_prune begin
1051
+ delete from delivery where session_id = new.session_id
1052
+ and not exists (select 1 from delivery n where n.session_id = new.session_id and n.at >= new.cutoff);
1053
+ delete from delivery where id in (select d.id from delivery d where d.at < new.cutoff
1054
+ and (d.session_id is null or not exists (select 1 from delivery n where n.session_id = d.session_id and n.at >= new.cutoff))
1055
+ order by d.at, d.id limit 200);
1056
+ end;
1027
1057
 
1028
- pragma user_version = 5;
1058
+ pragma user_version = 7;
package/dist/capture.js CHANGED
@@ -11109,7 +11109,7 @@ var HOST_MARKS = new Set([".orphaned_at", ".in_use"]);
11109
11109
 
11110
11110
  // server/src/sqlite.ts
11111
11111
  var SCHEMA_GENERATION = 2;
11112
- var SCHEMA_REVISION = 5;
11112
+ var SCHEMA_REVISION = 7;
11113
11113
  var sphicaHome = () => process.env.SPHICA_HOME || path2.join(os.homedir(), ".sphica");
11114
11114
  var dbFile = () => process.env.SPHICA_DB || path2.join(sphicaHome(), "sphica.db");
11115
11115
  function requireRuntime() {
@@ -11245,16 +11245,29 @@ var DDL = () => ddl ??= new Set([
11245
11245
  return code;
11246
11246
  }));
11247
11247
  var readsDataVersion = (p1, p2) => p1 === "data_version" && p2 === null;
11248
- var CAPTURE_VIEWS = new Set(["capture_session", "capture_message", "capture_edit", "capture_delivery"]);
11248
+ var CAPTURE_VIEWS = new Set([
11249
+ "capture_session",
11250
+ "capture_message",
11251
+ "capture_edit",
11252
+ "capture_delivery",
11253
+ "capture_delivery_scoped",
11254
+ "capture_delivery_prune"
11255
+ ]);
11249
11256
  var TRIGGER_WRITES = {
11250
11257
  capture_session_insert: new Set(["session"]),
11251
11258
  capture_message_insert: new Set(["source"]),
11252
11259
  capture_edit_insert: new Set(["edit_observation"]),
11253
11260
  capture_delivery_insert: new Set(["delivery", "delivery_unit"]),
11261
+ capture_delivery_scoped_insert: new Set(["delivery", "delivery_unit"]),
11254
11262
  source_fts_ai: new Set(["source_fts"])
11255
11263
  };
11264
+ var TRIGGER_DELETES = {
11265
+ capture_delivery_prune_insert: new Set(["delivery"]),
11266
+ delivery_ad: new Set(["delivery_unit"])
11267
+ };
11256
11268
  var TRIGGER_FUNCTIONS = {
11257
- capture_delivery_insert: new Set(["coalesce", "json_each", "last_insert_rowid"])
11269
+ capture_delivery_insert: new Set(["coalesce", "json_each", "last_insert_rowid"]),
11270
+ capture_delivery_scoped_insert: new Set(["coalesce", "json_each", "last_insert_rowid"])
11258
11271
  };
11259
11272
  var CAPTURE_READS = {
11260
11273
  project: new Set(["id", "key", "name"]),
@@ -11271,6 +11284,8 @@ function captureAuthorizer(own, action, p1, p2, triggerOrView) {
11271
11284
  return C2.SQLITE_OK;
11272
11285
  return fts ? C2.SQLITE_OK : C2.SQLITE_DENY;
11273
11286
  }
11287
+ if (action === C2.SQLITE_DELETE && triggerOrView !== null && TRIGGER_DELETES[triggerOrView]?.has(table))
11288
+ return C2.SQLITE_OK;
11274
11289
  if (action === C2.SQLITE_UPDATE || action === C2.SQLITE_DELETE)
11275
11290
  return fts ? C2.SQLITE_OK : C2.SQLITE_DENY;
11276
11291
  if (action === C2.SQLITE_READ) {
@@ -11327,6 +11342,7 @@ var INGEST_TRIGGER_WRITES = {
11327
11342
  source_fts_ad: ["delete source_fts"],
11328
11343
  unit_state_apply: ["update unit"],
11329
11344
  unit_state_restore: ["insert unit_state"],
11345
+ delivery_ad: ["delete delivery_unit"],
11330
11346
  ...Object.fromEntries([
11331
11347
  "evidence_i",
11332
11348
  "evidence_u",
package/dist/cli.js CHANGED
@@ -3625,7 +3625,7 @@ ${indent}${caution(note)}` : ""}`);
3625
3625
 
3626
3626
  // server/src/sqlite.ts
3627
3627
  var SCHEMA_GENERATION = 2;
3628
- var SCHEMA_REVISION = 5;
3628
+ var SCHEMA_REVISION = 7;
3629
3629
  var sphicaHome = () => process.env.SPHICA_HOME || path3.join(os2.homedir(), ".sphica");
3630
3630
  var dbFile = () => process.env.SPHICA_DB || path3.join(sphicaHome(), "sphica.db");
3631
3631
  function requireRuntime() {
@@ -14712,16 +14712,29 @@ var DDL = () => ddl ??= new Set([
14712
14712
  return code;
14713
14713
  }));
14714
14714
  var readsDataVersion = (p1, p2) => p1 === "data_version" && p2 === null;
14715
- var CAPTURE_VIEWS = new Set(["capture_session", "capture_message", "capture_edit", "capture_delivery"]);
14715
+ var CAPTURE_VIEWS = new Set([
14716
+ "capture_session",
14717
+ "capture_message",
14718
+ "capture_edit",
14719
+ "capture_delivery",
14720
+ "capture_delivery_scoped",
14721
+ "capture_delivery_prune"
14722
+ ]);
14716
14723
  var TRIGGER_WRITES = {
14717
14724
  capture_session_insert: new Set(["session"]),
14718
14725
  capture_message_insert: new Set(["source"]),
14719
14726
  capture_edit_insert: new Set(["edit_observation"]),
14720
14727
  capture_delivery_insert: new Set(["delivery", "delivery_unit"]),
14728
+ capture_delivery_scoped_insert: new Set(["delivery", "delivery_unit"]),
14721
14729
  source_fts_ai: new Set(["source_fts"])
14722
14730
  };
14731
+ var TRIGGER_DELETES = {
14732
+ capture_delivery_prune_insert: new Set(["delivery"]),
14733
+ delivery_ad: new Set(["delivery_unit"])
14734
+ };
14723
14735
  var TRIGGER_FUNCTIONS = {
14724
- capture_delivery_insert: new Set(["coalesce", "json_each", "last_insert_rowid"])
14736
+ capture_delivery_insert: new Set(["coalesce", "json_each", "last_insert_rowid"]),
14737
+ capture_delivery_scoped_insert: new Set(["coalesce", "json_each", "last_insert_rowid"])
14725
14738
  };
14726
14739
  var CAPTURE_READS = {
14727
14740
  project: new Set(["id", "key", "name"]),
@@ -14738,6 +14751,8 @@ function captureAuthorizer(own, action, p1, p2, triggerOrView) {
14738
14751
  return C2.SQLITE_OK;
14739
14752
  return fts ? C2.SQLITE_OK : C2.SQLITE_DENY;
14740
14753
  }
14754
+ if (action === C2.SQLITE_DELETE && triggerOrView !== null && TRIGGER_DELETES[triggerOrView]?.has(table))
14755
+ return C2.SQLITE_OK;
14741
14756
  if (action === C2.SQLITE_UPDATE || action === C2.SQLITE_DELETE)
14742
14757
  return fts ? C2.SQLITE_OK : C2.SQLITE_DENY;
14743
14758
  if (action === C2.SQLITE_READ) {
@@ -14794,6 +14809,7 @@ var INGEST_TRIGGER_WRITES = {
14794
14809
  source_fts_ad: ["delete source_fts"],
14795
14810
  unit_state_apply: ["update unit"],
14796
14811
  unit_state_restore: ["insert unit_state"],
14812
+ delivery_ad: ["delete delivery_unit"],
14797
14813
  ...Object.fromEntries([
14798
14814
  "evidence_i",
14799
14815
  "evidence_u",
package/dist/deliver.js CHANGED
@@ -11134,7 +11134,7 @@ var HOST_MARKS = new Set([".orphaned_at", ".in_use"]);
11134
11134
 
11135
11135
  // server/src/sqlite.ts
11136
11136
  var SCHEMA_GENERATION = 2;
11137
- var SCHEMA_REVISION = 5;
11137
+ var SCHEMA_REVISION = 7;
11138
11138
  var sphicaHome = () => process.env.SPHICA_HOME || path3.join(os.homedir(), ".sphica");
11139
11139
  var dbFile = () => process.env.SPHICA_DB || path3.join(sphicaHome(), "sphica.db");
11140
11140
  function requireRuntime() {
@@ -11294,16 +11294,29 @@ var DDL = () => ddl ??= new Set([
11294
11294
  return code;
11295
11295
  }));
11296
11296
  var readsDataVersion = (p1, p2) => p1 === "data_version" && p2 === null;
11297
- var CAPTURE_VIEWS = new Set(["capture_session", "capture_message", "capture_edit", "capture_delivery"]);
11297
+ var CAPTURE_VIEWS = new Set([
11298
+ "capture_session",
11299
+ "capture_message",
11300
+ "capture_edit",
11301
+ "capture_delivery",
11302
+ "capture_delivery_scoped",
11303
+ "capture_delivery_prune"
11304
+ ]);
11298
11305
  var TRIGGER_WRITES = {
11299
11306
  capture_session_insert: new Set(["session"]),
11300
11307
  capture_message_insert: new Set(["source"]),
11301
11308
  capture_edit_insert: new Set(["edit_observation"]),
11302
11309
  capture_delivery_insert: new Set(["delivery", "delivery_unit"]),
11310
+ capture_delivery_scoped_insert: new Set(["delivery", "delivery_unit"]),
11303
11311
  source_fts_ai: new Set(["source_fts"])
11304
11312
  };
11313
+ var TRIGGER_DELETES = {
11314
+ capture_delivery_prune_insert: new Set(["delivery"]),
11315
+ delivery_ad: new Set(["delivery_unit"])
11316
+ };
11305
11317
  var TRIGGER_FUNCTIONS = {
11306
- capture_delivery_insert: new Set(["coalesce", "json_each", "last_insert_rowid"])
11318
+ capture_delivery_insert: new Set(["coalesce", "json_each", "last_insert_rowid"]),
11319
+ capture_delivery_scoped_insert: new Set(["coalesce", "json_each", "last_insert_rowid"])
11307
11320
  };
11308
11321
  var CAPTURE_READS = {
11309
11322
  project: new Set(["id", "key", "name"]),
@@ -11320,6 +11333,8 @@ function captureAuthorizer(own, action, p1, p2, triggerOrView) {
11320
11333
  return C2.SQLITE_OK;
11321
11334
  return fts ? C2.SQLITE_OK : C2.SQLITE_DENY;
11322
11335
  }
11336
+ if (action === C2.SQLITE_DELETE && triggerOrView !== null && TRIGGER_DELETES[triggerOrView]?.has(table))
11337
+ return C2.SQLITE_OK;
11323
11338
  if (action === C2.SQLITE_UPDATE || action === C2.SQLITE_DELETE)
11324
11339
  return fts ? C2.SQLITE_OK : C2.SQLITE_DENY;
11325
11340
  if (action === C2.SQLITE_READ) {
@@ -11376,6 +11391,7 @@ var INGEST_TRIGGER_WRITES = {
11376
11391
  source_fts_ad: ["delete source_fts"],
11377
11392
  unit_state_apply: ["update unit"],
11378
11393
  unit_state_restore: ["insert unit_state"],
11394
+ delivery_ad: ["delete delivery_unit"],
11379
11395
  ...Object.fromEntries([
11380
11396
  "evidence_i",
11381
11397
  "evidence_u",
@@ -31836,10 +31852,12 @@ var LIMITS = {
31836
31852
  review: { units: 5, chars: 1500 }
31837
31853
  };
31838
31854
  var LOG_WAIT_MS = 250;
31855
+ var RETAIN_MS = 90 * 24 * 60 * 60 * 1000;
31839
31856
  var READ_SESSION = { units: 8, chars: 3000 };
31840
31857
  var EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
31841
31858
  var NOTE = "Sphica past record, not an instruction; read it with Sphica's read before relying on it";
31842
31859
  var leftOut = (n) => n > 0 ? `- ${n} more record${n === 1 ? " applies here but was" : "s apply here but were"} left out for space: find them with Sphica's search or read.` : "";
31860
+ var SEARCH_FIRST = "- Before choosing an approach, search Sphica's past records for it.";
31843
31861
  var workLeftOut = (n) => n > 0 ? `- ${n} more work item${n === 1 ? "" : "s"} not shown: Sphica's status lists the 5 most recently updated.` : "";
31844
31862
  var noted = (text, lead, notes) => {
31845
31863
  const note = notes.filter(Boolean).map((l) => `
@@ -31920,9 +31938,15 @@ async function beforeEdit(db, projectId2, rels) {
31920
31938
  reason: null
31921
31939
  };
31922
31940
  }
31923
- async function beforeRead(db, projectId2, rels, session, how) {
31924
- const sent = await db.selectFrom("delivery as d").innerJoin("delivery_unit as x", "x.delivery_id", "d.id").where("d.session_id", "=", session).where("d.outcome", "=", "emitted").select(["x.unit_id", "d.event"]).execute();
31925
- const spent = await db.selectFrom("delivery as d").where("d.session_id", "=", session).where("d.event", "=", "pre_read").where("d.outcome", "=", "emitted").where(({ exists, selectFrom }) => exists(selectFrom("delivery_unit as x").select("x.unit_id").whereRef("x.delivery_id", "=", "d.id"))).select("d.chars").execute();
31941
+ var agentOf = (input2) => typeof input2.agent_id === "string" && /^[\x21-\x7e]{1,200}$/.test(input2.agent_id) ? input2.agent_id : null;
31942
+ var sameAgent = (agent) => (eb) => agent === null ? eb("d.agent_id", "is", null) : eb("d.agent_id", "=", agent);
31943
+ var START_SOURCES = new Set(["startup", "resume", "clear", "compact", "fork"]);
31944
+ var RESTARTS = ["compact", "clear"];
31945
+ async function beforeRead(db, projectId2, rels, session, agent, how) {
31946
+ const restart = await db.selectFrom("delivery as d").where("d.session_id", "=", session).where(sameAgent(agent)).where("d.event", "=", "session_start").where("d.reason", "in", RESTARTS).select((eb) => eb.fn.max("d.id").as("id")).executeTakeFirst();
31947
+ const since = restart?.id ?? 0;
31948
+ const sent = await db.selectFrom("delivery as d").innerJoin("delivery_unit as x", "x.delivery_id", "d.id").where("d.session_id", "=", session).where(sameAgent(agent)).where("d.id", ">", since).where("d.outcome", "=", "emitted").select(["x.unit_id", "d.event"]).execute();
31949
+ const spent = await db.selectFrom("delivery as d").where("d.session_id", "=", session).where(sameAgent(agent)).where("d.id", ">", since).where("d.event", "=", "pre_read").where("d.outcome", "=", "emitted").where(({ exists, selectFrom }) => exists(selectFrom("delivery_unit as x").select("x.unit_id").whereRef("x.delivery_id", "=", "d.id"))).select("d.chars").execute();
31926
31950
  const seen = new Set(sent.map((r) => r.unit_id));
31927
31951
  const readUnits = sent.filter((r) => r.event === "pre_read").length;
31928
31952
  const rows = (await anchoredTo(db, projectId2, rels).where("u.kind", "in", ["decision", "constraint"]).execute()).filter((u) => !seen.has(u.id));
@@ -32052,7 +32076,12 @@ async function atStart(db, projectId2, branch, place) {
32052
32076
  const workLeft = Math.max((workTotal ?? 0) - f.kept.filter((i) => i < work.length).length, 0);
32053
32077
  const broadLeft = Math.max((broadTotal ?? 0) - shownUnits.length, 0);
32054
32078
  return {
32055
- ...noted(f.text, lead, [leftOut(broadLeft), workLeftOut(workLeft), await waiting(db, projectId2, place)]),
32079
+ ...noted(f.text, lead, [
32080
+ leftOut(broadLeft),
32081
+ workLeftOut(workLeft),
32082
+ await waiting(db, projectId2, place),
32083
+ place.subagent ? SEARCH_FIRST : ""
32084
+ ]),
32056
32085
  units: shownUnits,
32057
32086
  eligible: (workTotal ?? 0) + (broadTotal ?? 0),
32058
32087
  omitted: workLeft + broadLeft,
@@ -32120,34 +32149,64 @@ function markOnce(kind, key) {
32120
32149
  function onceUnavailable(session) {
32121
32150
  return markOnce("unavailable", session);
32122
32151
  }
32123
- async function log(file2, projectId2, host, external2, event, plan, outcome, branch) {
32152
+ async function write2(trx, e, plan) {
32153
+ const id = sessionId(e.projectId, e.host, e.external);
32154
+ const t = Date.now();
32155
+ const now = iso(t);
32156
+ await trx.insertInto("capture_delivery_prune").values({ cutoff: iso(t - RETAIN_MS), session_id: id }).execute();
32157
+ await trx.insertInto("capture_session").values({
32158
+ id,
32159
+ project_id: e.projectId,
32160
+ host: e.host,
32161
+ external_id: e.external,
32162
+ branch: e.branch,
32163
+ started_at: now
32164
+ }).execute();
32165
+ await trx.insertInto("capture_delivery_scoped").values({
32166
+ session_id: id,
32167
+ agent_id: e.agent,
32168
+ event: e.event,
32169
+ outcome: plan.text ? "emitted" : "nothing",
32170
+ reason: plan.reason,
32171
+ path: plan.path,
32172
+ eligible: plan.eligible,
32173
+ omitted: plan.omitted,
32174
+ chars: plan.text.length - plan.note.length,
32175
+ at: now,
32176
+ units: JSON.stringify(plan.units)
32177
+ }).execute();
32178
+ }
32179
+ async function log(file2, e, plan) {
32124
32180
  const cap = openWriter("capture", file2, LOG_WAIT_MS);
32125
32181
  try {
32126
- const id = sessionId(projectId2, host, external2);
32127
- const now = iso(Date.now());
32182
+ await inTransaction(cap, (trx) => write2(trx, e, plan));
32183
+ } finally {
32184
+ await cap.destroy().catch(() => {});
32185
+ }
32186
+ }
32187
+ async function lockedPlan(file2, e, make, keep) {
32188
+ const cap = openWriter("capture", file2, LOG_WAIT_MS);
32189
+ let held = false;
32190
+ let plan = null;
32191
+ try {
32128
32192
  await inTransaction(cap, async (trx) => {
32129
- await trx.insertInto("capture_session").values({ id, project_id: projectId2, host, external_id: external2, branch, started_at: now }).execute();
32130
- await trx.insertInto("capture_delivery").values({
32131
- session_id: id,
32132
- event,
32133
- outcome,
32134
- reason: plan.reason,
32135
- path: plan.path,
32136
- eligible: plan.eligible,
32137
- omitted: plan.omitted,
32138
- chars: plan.text.length - plan.note.length,
32139
- at: now,
32140
- units: JSON.stringify(plan.units)
32141
- }).execute();
32193
+ held = true;
32194
+ plan = await make();
32195
+ if (keep(plan))
32196
+ await write2(trx, e, plan);
32142
32197
  });
32198
+ } catch (err) {
32199
+ if (held && !plan)
32200
+ throw err;
32143
32201
  } finally {
32144
32202
  await cap.destroy().catch(() => {});
32145
32203
  }
32204
+ return plan ?? make();
32146
32205
  }
32147
32206
  async function deliver(input2, host = "claude-code", file2 = dbFile()) {
32148
32207
  const name = input2.hook_event_name;
32149
32208
  const call = reviewCall(input2);
32150
- const event = call ? "review" : name === "SessionStart" ? "session_start" : name === "UserPromptSubmit" ? "prompt" : name === "PreToolUse" ? input2.tool_name === "Read" || input2.tool_name === "Bash" && !(host === "codex" && shellPatch(input2)) ? "pre_read" : "pre_edit" : null;
32209
+ const event = call ? "review" : name === "SessionStart" || name === "SubagentStart" ? "session_start" : name === "UserPromptSubmit" ? "prompt" : name === "PreToolUse" ? input2.tool_name === "Read" || input2.tool_name === "Bash" && !(host === "codex" && shellPatch(input2)) ? "pre_read" : "pre_edit" : null;
32151
32210
  if (!event || !input2.session_id)
32152
32211
  return "";
32153
32212
  if (event === "prompt" && !isOwnerTurn(input2, undefined, undefined, host === "codex" ? process.env.CODEX_THREAD_ID : undefined))
@@ -32181,21 +32240,38 @@ async function deliver(input2, host = "claude-code", file2 = dbFile()) {
32181
32240
  return "";
32182
32241
  }
32183
32242
  if (event === "session_start" && input2.source === "resume") {
32184
- const said = await db.selectFrom("delivery").select("id").where("session_id", "=", sessionId(pid, host, input2.session_id)).where("event", "=", "session_start").where("outcome", "=", "emitted").executeTakeFirst();
32243
+ const said = await db.selectFrom("delivery").select("id").where("session_id", "=", sessionId(pid, host, input2.session_id)).where("agent_id", "is", null).where("event", "=", "session_start").where((eb) => eb.or([eb("reason", "is", null), eb("reason", "!=", "subagent")])).where("outcome", "=", "emitted").executeTakeFirst();
32185
32244
  if (said)
32186
32245
  return "";
32187
32246
  }
32188
- const plan = event === "pre_edit" ? await beforeEdit(db, pid, rels) : event === "pre_read" ? await beforeRead(db, pid, rels, sessionId(pid, host, input2.session_id), shell ? "named" : "reading") : event === "prompt" ? await onPrompt(db, pid, place.root, input2.prompt ?? "") : call ? await beforeReview(db, pid, place.root, call) : await atStart(db, pid, branchOf(place.root), {
32189
- file: file2,
32190
- key: place.key,
32247
+ const reader = db;
32248
+ const session = input2.session_id;
32249
+ const make = async () => event === "pre_edit" ? await beforeEdit(reader, pid, rels) : event === "pre_read" ? await beforeRead(reader, pid, rels, sessionId(pid, host, session), agentOf(input2), shell ? "named" : "reading") : event === "prompt" ? await onPrompt(reader, pid, place.root, input2.prompt ?? "") : call ? await beforeReview(reader, pid, place.root, call) : {
32250
+ ...await atStart(reader, pid, branchOf(place.root), {
32251
+ file: file2,
32252
+ key: place.key,
32253
+ host,
32254
+ subagent: name === "SubagentStart",
32255
+ owner: name !== "SubagentStart" && isOwnerTurn(input2, undefined, undefined, host === "codex" ? process.env.CODEX_THREAD_ID : undefined)
32256
+ }),
32257
+ reason: name === "SubagentStart" ? "subagent" : input2.source && START_SOURCES.has(input2.source) ? input2.source : null
32258
+ };
32259
+ const entry = {
32260
+ projectId: pid,
32191
32261
  host,
32192
- owner: isOwnerTurn(input2, undefined, undefined, host === "codex" ? process.env.CODEX_THREAD_ID : undefined)
32193
- });
32262
+ external: session,
32263
+ agent: agentOf(input2),
32264
+ event,
32265
+ branch: branchOf(place.root)
32266
+ };
32267
+ const keep = (p) => !(onPath && !p.text);
32268
+ if (event === "pre_read" || event === "session_start")
32269
+ return (await lockedPlan(file2, entry, make, keep)).text;
32270
+ const plan = await make();
32194
32271
  if (call && plan.text && toldBefore(`${host}\x00${input2.session_id}`, plan.once ?? plan.text))
32195
32272
  return "";
32196
- if (onPath && !plan.text)
32197
- return "";
32198
- await log(file2, pid, host, input2.session_id, event, plan, plan.text ? "emitted" : "nothing", branchOf(place.root)).catch(() => {});
32273
+ if (keep(plan))
32274
+ await log(file2, entry, plan).catch(() => {});
32199
32275
  return plan.text;
32200
32276
  } catch (e) {
32201
32277
  if (e instanceof RevisionMismatch)
@@ -45869,7 +45869,7 @@ var HOST_MARKS = new Set([".orphaned_at", ".in_use"]);
45869
45869
 
45870
45870
  // server/src/sqlite.ts
45871
45871
  var SCHEMA_GENERATION = 2;
45872
- var SCHEMA_REVISION = 5;
45872
+ var SCHEMA_REVISION = 7;
45873
45873
  var sphicaHome = () => process.env.SPHICA_HOME || path2.join(os.homedir(), ".sphica");
45874
45874
  var dbFile = () => process.env.SPHICA_DB || path2.join(sphicaHome(), "sphica.db");
45875
45875
  function requireRuntime() {
@@ -46005,16 +46005,29 @@ var DDL = () => ddl ??= new Set([
46005
46005
  return code;
46006
46006
  }));
46007
46007
  var readsDataVersion = (p1, p2) => p1 === "data_version" && p2 === null;
46008
- var CAPTURE_VIEWS = new Set(["capture_session", "capture_message", "capture_edit", "capture_delivery"]);
46008
+ var CAPTURE_VIEWS = new Set([
46009
+ "capture_session",
46010
+ "capture_message",
46011
+ "capture_edit",
46012
+ "capture_delivery",
46013
+ "capture_delivery_scoped",
46014
+ "capture_delivery_prune"
46015
+ ]);
46009
46016
  var TRIGGER_WRITES = {
46010
46017
  capture_session_insert: new Set(["session"]),
46011
46018
  capture_message_insert: new Set(["source"]),
46012
46019
  capture_edit_insert: new Set(["edit_observation"]),
46013
46020
  capture_delivery_insert: new Set(["delivery", "delivery_unit"]),
46021
+ capture_delivery_scoped_insert: new Set(["delivery", "delivery_unit"]),
46014
46022
  source_fts_ai: new Set(["source_fts"])
46015
46023
  };
46024
+ var TRIGGER_DELETES = {
46025
+ capture_delivery_prune_insert: new Set(["delivery"]),
46026
+ delivery_ad: new Set(["delivery_unit"])
46027
+ };
46016
46028
  var TRIGGER_FUNCTIONS = {
46017
- capture_delivery_insert: new Set(["coalesce", "json_each", "last_insert_rowid"])
46029
+ capture_delivery_insert: new Set(["coalesce", "json_each", "last_insert_rowid"]),
46030
+ capture_delivery_scoped_insert: new Set(["coalesce", "json_each", "last_insert_rowid"])
46018
46031
  };
46019
46032
  var CAPTURE_READS = {
46020
46033
  project: new Set(["id", "key", "name"]),
@@ -46031,6 +46044,8 @@ function captureAuthorizer(own2, action, p1, p2, triggerOrView) {
46031
46044
  return C2.SQLITE_OK;
46032
46045
  return fts ? C2.SQLITE_OK : C2.SQLITE_DENY;
46033
46046
  }
46047
+ if (action === C2.SQLITE_DELETE && triggerOrView !== null && TRIGGER_DELETES[triggerOrView]?.has(table))
46048
+ return C2.SQLITE_OK;
46034
46049
  if (action === C2.SQLITE_UPDATE || action === C2.SQLITE_DELETE)
46035
46050
  return fts ? C2.SQLITE_OK : C2.SQLITE_DENY;
46036
46051
  if (action === C2.SQLITE_READ) {
@@ -46087,6 +46102,7 @@ var INGEST_TRIGGER_WRITES = {
46087
46102
  source_fts_ad: ["delete source_fts"],
46088
46103
  unit_state_apply: ["update unit"],
46089
46104
  unit_state_restore: ["insert unit_state"],
46105
+ delivery_ad: ["delete delivery_unit"],
46090
46106
  ...Object.fromEntries([
46091
46107
  "evidence_i",
46092
46108
  "evidence_u",
package/dist/mcp.js CHANGED
@@ -46030,7 +46030,7 @@ var HOST_MARKS = new Set([".orphaned_at", ".in_use"]);
46030
46030
 
46031
46031
  // server/src/sqlite.ts
46032
46032
  var SCHEMA_GENERATION = 2;
46033
- var SCHEMA_REVISION = 5;
46033
+ var SCHEMA_REVISION = 7;
46034
46034
  var sphicaHome = () => process.env.SPHICA_HOME || path2.join(os.homedir(), ".sphica");
46035
46035
  var dbFile = () => process.env.SPHICA_DB || path2.join(sphicaHome(), "sphica.db");
46036
46036
  function requireRuntime() {
package/hooks/codex.json CHANGED
@@ -18,6 +18,18 @@
18
18
  ]
19
19
  }
20
20
  ],
21
+ "SubagentStart": [
22
+ {
23
+ "hooks": [
24
+ {
25
+ "type": "command",
26
+ "command": "node \"${PLUGIN_ROOT}/dist/deliver.js\" codex",
27
+ "commandWindows": "powershell.exe -NoProfile -NonInteractive -Command node $env:PLUGIN_ROOT/dist/deliver.js codex",
28
+ "timeout": 10
29
+ }
30
+ ]
31
+ }
32
+ ],
21
33
  "UserPromptSubmit": [
22
34
  {
23
35
  "hooks": [
package/hooks/hooks.json CHANGED
@@ -20,6 +20,17 @@
20
20
  ]
21
21
  }
22
22
  ],
23
+ "SubagentStart": [
24
+ {
25
+ "hooks": [
26
+ {
27
+ "type": "command",
28
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/deliver.js\"",
29
+ "timeout": 5
30
+ }
31
+ ]
32
+ }
33
+ ],
23
34
  "UserPromptSubmit": [
24
35
  {
25
36
  "hooks": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sphica",
3
- "version": "0.6.16",
3
+ "version": "0.6.18",
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
  "license": "MIT",
6
6
  "type": "module",