sphica 0.6.18 → 0.6.21
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/db/migrations/0008.check.sql +33 -0
- package/db/migrations/0008.sql +53 -0
- package/db/schema.sql +16 -1
- package/dist/capture.js +58 -22
- package/dist/cli.js +1184 -619
- package/dist/deliver.js +56 -22
- package/dist/mcp-record.js +67 -33
- package/dist/mcp.js +694 -584
- package/package.json +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.6.
|
|
4
|
+
"version": "0.6.21",
|
|
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",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- Rows revision 8 cannot take and the migration must not change on its own. `sphica init` runs this first, inside the same transaction,
|
|
2
|
+
-- and stops with every row listed when there is any; nothing of revision 8 is applied.
|
|
3
|
+
|
|
4
|
+
create temp table sphica_migration_stop (rule text, item text);
|
|
5
|
+
|
|
6
|
+
-- Projects whose keys become one once normalized. Merging two projects that both hold rows would move sessions, whose ids derive from the
|
|
7
|
+
-- project id, so this revision merges only when at most one of them holds any row.
|
|
8
|
+
create temp table sphica_project_use as
|
|
9
|
+
select id, key,
|
|
10
|
+
case when lower(host) = 'github.com' then lower(key) else 'git:' || lower(host) || substr(key, length(host) + 5) end as normal,
|
|
11
|
+
(select count(*) from session where project_id = p.id) as sessions,
|
|
12
|
+
(select count(*) from source where project_id = p.id) as sources,
|
|
13
|
+
(select count(*) from artifact_link where project_id = p.id) as links,
|
|
14
|
+
(select count(*) from forget_batch where project_id = p.id) as forgets,
|
|
15
|
+
(select count(*) from source_forgotten where project_id = p.id) as forgotten,
|
|
16
|
+
(select count(*) from extraction_run where project_id = p.id) as runs,
|
|
17
|
+
(select count(*) from unit where project_id = p.id) as units,
|
|
18
|
+
(select count(*) from field_def where project_id = p.id) as fields,
|
|
19
|
+
(select count(*) from work where project_id = p.id) as works
|
|
20
|
+
from (select id, key, case when instr(substr(key, 5), '/') = 0 then substr(key, 5)
|
|
21
|
+
else substr(key, 5, instr(substr(key, 5), '/') - 1) end as host from project where key glob 'git:*') p;
|
|
22
|
+
|
|
23
|
+
insert into sphica_migration_stop
|
|
24
|
+
select 'projects with records whose keys become one once normalized', 'project ' || id || ' ' || key || ' (becomes ' || normal || '): '
|
|
25
|
+
|| sessions || ' sessions, ' || sources || ' sources, ' || links || ' artifact links, ' || forgets || ' forget batches, '
|
|
26
|
+
|| forgotten || ' forgotten sources, ' || runs || ' runs, ' || units || ' records, ' || fields || ' fields, ' || works || ' work items'
|
|
27
|
+
from sphica_project_use
|
|
28
|
+
where sessions + sources + links + forgets + forgotten + runs + units + fields + works > 0
|
|
29
|
+
and normal in (select normal from sphica_project_use
|
|
30
|
+
where sessions + sources + links + forgets + forgotten + runs + units + fields + works > 0 group by normal having count(*) > 1)
|
|
31
|
+
order by normal, id;
|
|
32
|
+
|
|
33
|
+
drop table temp.sphica_project_use;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- Revision 7 → 8 of generation 2: project keys are normalized, and triggers refuse a key that is not. Projects whose keys become one
|
|
2
|
+
-- merge only when at most one holds rows: the one with rows, or the oldest, keeps its id and takes the key, and the empty others go.
|
|
3
|
+
-- Every statement that stays is the same as in the schema at revision 8.
|
|
4
|
+
|
|
5
|
+
create temp table sphica_migration_note (rule text, item text, action text);
|
|
6
|
+
|
|
7
|
+
create temp table sphica_project_normal as
|
|
8
|
+
select id, key, normal,
|
|
9
|
+
coalesce(nullif(substr(normal, 5 + instr(substr(normal, 5), '/')), ''), substr(normal, 5)) as name,
|
|
10
|
+
exists (select 1 from session where project_id = p.id) or exists (select 1 from source where project_id = p.id)
|
|
11
|
+
or exists (select 1 from artifact_link where project_id = p.id) or exists (select 1 from forget_batch where project_id = p.id)
|
|
12
|
+
or exists (select 1 from source_forgotten where project_id = p.id) or exists (select 1 from extraction_run where project_id = p.id)
|
|
13
|
+
or exists (select 1 from unit where project_id = p.id) or exists (select 1 from field_def where project_id = p.id)
|
|
14
|
+
or exists (select 1 from work where project_id = p.id) as used
|
|
15
|
+
from (select id, key,
|
|
16
|
+
case when lower(host) = 'github.com' then lower(key) else 'git:' || lower(host) || substr(key, length(host) + 5) end as normal
|
|
17
|
+
from (select id, key, case when instr(substr(key, 5), '/') = 0 then substr(key, 5)
|
|
18
|
+
else substr(key, 5, instr(substr(key, 5), '/') - 1) end as host from project where key glob 'git:*')) p;
|
|
19
|
+
|
|
20
|
+
create temp table sphica_project_keep as
|
|
21
|
+
select normal, coalesce(max(case when used then id end), min(id)) as id from sphica_project_normal group by normal;
|
|
22
|
+
|
|
23
|
+
-- Delete the empty others first: one of them may hold the normalized key already
|
|
24
|
+
insert into sphica_migration_note
|
|
25
|
+
select 'an empty project whose key another project takes once normalized', 'project ' || n.id || ' ' || n.key,
|
|
26
|
+
'removed; project ' || k.id || ' takes ' || n.normal
|
|
27
|
+
from sphica_project_normal n join sphica_project_keep k on k.normal = n.normal where n.id <> k.id order by n.id;
|
|
28
|
+
delete from project where id in (select n.id from sphica_project_normal n join sphica_project_keep k on k.normal = n.normal where n.id <> k.id);
|
|
29
|
+
|
|
30
|
+
insert into sphica_migration_note
|
|
31
|
+
select 'a project key that was not normalized', 'project ' || n.id || ' ' || n.key, 'now ' || n.normal || ' (' || n.name || ')'
|
|
32
|
+
from sphica_project_normal n join sphica_project_keep k on k.id = n.id where n.key <> n.normal order by n.id;
|
|
33
|
+
update project set key = (select normal from sphica_project_normal n where n.id = project.id),
|
|
34
|
+
name = (select name from sphica_project_normal n where n.id = project.id)
|
|
35
|
+
where id in (select n.id from sphica_project_normal n join sphica_project_keep k on k.id = n.id where n.key <> n.normal);
|
|
36
|
+
|
|
37
|
+
drop table temp.sphica_project_keep;
|
|
38
|
+
drop table temp.sphica_project_normal;
|
|
39
|
+
|
|
40
|
+
create trigger project_key_normal_insert before insert on project when new.key glob 'git:*' begin
|
|
41
|
+
select raise(abort, 'the project key is not normalized')
|
|
42
|
+
from (select rest, case when instr(rest, '/') = 0 then rest else substr(rest, 1, instr(rest, '/') - 1) end as host
|
|
43
|
+
from (select substr(new.key, 5) as rest))
|
|
44
|
+
where new.key is not case when lower(host) = 'github.com' then lower(new.key) else 'git:' || lower(host) || substr(rest, length(host) + 1) end;
|
|
45
|
+
end;
|
|
46
|
+
create trigger project_key_normal_update before update of key on project when new.key glob 'git:*' begin
|
|
47
|
+
select raise(abort, 'the project key is not normalized')
|
|
48
|
+
from (select rest, case when instr(rest, '/') = 0 then rest else substr(rest, 1, instr(rest, '/') - 1) end as host
|
|
49
|
+
from (select substr(new.key, 5) as rest))
|
|
50
|
+
where new.key is not case when lower(host) = 'github.com' then lower(new.key) else 'git:' || lower(host) || substr(rest, length(host) + 1) end;
|
|
51
|
+
end;
|
|
52
|
+
|
|
53
|
+
pragma user_version = 8;
|
package/db/schema.sql
CHANGED
|
@@ -32,6 +32,21 @@ create table project (
|
|
|
32
32
|
check (strftime('%Y-%m-%dT%H:%M:%fZ', created_at) is created_at)
|
|
33
33
|
) strict;
|
|
34
34
|
|
|
35
|
+
-- A git key's host is lowercase, and on github.com the whole key is (owner and repository names are case-insensitive there), so one
|
|
36
|
+
-- repository has one key however its remote is written. lower() folds ASCII only, as normalizeKey does.
|
|
37
|
+
create trigger project_key_normal_insert before insert on project when new.key glob 'git:*' begin
|
|
38
|
+
select raise(abort, 'the project key is not normalized')
|
|
39
|
+
from (select rest, case when instr(rest, '/') = 0 then rest else substr(rest, 1, instr(rest, '/') - 1) end as host
|
|
40
|
+
from (select substr(new.key, 5) as rest))
|
|
41
|
+
where new.key is not case when lower(host) = 'github.com' then lower(new.key) else 'git:' || lower(host) || substr(rest, length(host) + 1) end;
|
|
42
|
+
end;
|
|
43
|
+
create trigger project_key_normal_update before update of key on project when new.key glob 'git:*' begin
|
|
44
|
+
select raise(abort, 'the project key is not normalized')
|
|
45
|
+
from (select rest, case when instr(rest, '/') = 0 then rest else substr(rest, 1, instr(rest, '/') - 1) end as host
|
|
46
|
+
from (select substr(new.key, 5) as rest))
|
|
47
|
+
where new.key is not case when lower(host) = 'github.com' then lower(new.key) else 'git:' || lower(host) || substr(rest, length(host) + 1) end;
|
|
48
|
+
end;
|
|
49
|
+
|
|
35
50
|
-- Identities bound to the owner of this machine (the owner's GitHub account id), set by the owner through the CLI.
|
|
36
51
|
-- An external source counts as the owner's words only when its author id matches; a login name alone never does.
|
|
37
52
|
create table owner_identity (
|
|
@@ -1055,4 +1070,4 @@ create trigger capture_delivery_prune_insert instead of insert on capture_delive
|
|
|
1055
1070
|
order by d.at, d.id limit 200);
|
|
1056
1071
|
end;
|
|
1057
1072
|
|
|
1058
|
-
pragma user_version =
|
|
1073
|
+
pragma user_version = 8;
|
package/dist/capture.js
CHANGED
|
@@ -10900,6 +10900,7 @@ var QUESTION = new Set([
|
|
|
10900
10900
|
...["add", "get", "make", "have", "has", "not", "no", "yes", "reason"],
|
|
10901
10901
|
...["理由", "仕組", "何", "方", "場合", "今", "件"]
|
|
10902
10902
|
]);
|
|
10903
|
+
var FOLDED_QUESTION = new Set([...QUESTION].flatMap((w) => terms(w)));
|
|
10903
10904
|
var sha256 = (s) => crypto.createHash("sha256").update(s).digest();
|
|
10904
10905
|
function uuidFrom(...parts) {
|
|
10905
10906
|
const b = crypto.createHash("sha256").update(parts.join("\x00")).digest().subarray(0, 16);
|
|
@@ -11109,7 +11110,7 @@ var HOST_MARKS = new Set([".orphaned_at", ".in_use"]);
|
|
|
11109
11110
|
|
|
11110
11111
|
// server/src/sqlite.ts
|
|
11111
11112
|
var SCHEMA_GENERATION = 2;
|
|
11112
|
-
var SCHEMA_REVISION =
|
|
11113
|
+
var SCHEMA_REVISION = 8;
|
|
11113
11114
|
var sphicaHome = () => process.env.SPHICA_HOME || path2.join(os.homedir(), ".sphica");
|
|
11114
11115
|
var dbFile = () => process.env.SPHICA_DB || path2.join(sphicaHome(), "sphica.db");
|
|
11115
11116
|
function requireRuntime() {
|
|
@@ -11469,7 +11470,7 @@ var leaves = (rel) => rel === ".." || rel.startsWith(`..${path3.sep}`) || path3.
|
|
|
11469
11470
|
// server/src/project.ts
|
|
11470
11471
|
var localFile = () => path4.join(sphicaHome(), "projects.json");
|
|
11471
11472
|
var LOCAL_KEY = /^[a-z0-9][a-z0-9._-]*$/;
|
|
11472
|
-
function
|
|
11473
|
+
function legacyRemote(url) {
|
|
11473
11474
|
const raw = String(url ?? "").trim();
|
|
11474
11475
|
if (!raw)
|
|
11475
11476
|
return null;
|
|
@@ -11486,6 +11487,15 @@ function normalizeRemote(url) {
|
|
|
11486
11487
|
return null;
|
|
11487
11488
|
}
|
|
11488
11489
|
}
|
|
11490
|
+
var fold = (s) => s.replace(/[A-Z]+/g, (m) => m.toLowerCase());
|
|
11491
|
+
function normalizeKey(key) {
|
|
11492
|
+
if (!key.startsWith("git:"))
|
|
11493
|
+
return key;
|
|
11494
|
+
const rest = key.slice(4);
|
|
11495
|
+
const cut = rest.indexOf("/");
|
|
11496
|
+
const host = fold(cut < 0 ? rest : rest.slice(0, cut));
|
|
11497
|
+
return host === "github.com" ? fold(key) : `git:${host}${cut < 0 ? "" : rest.slice(cut)}`;
|
|
11498
|
+
}
|
|
11489
11499
|
var git = (dir, ...args) => {
|
|
11490
11500
|
try {
|
|
11491
11501
|
return execFileSync("git", ["-C", dir, ...args], {
|
|
@@ -11520,14 +11530,17 @@ function identify(dir) {
|
|
|
11520
11530
|
const given = path4.resolve(dir);
|
|
11521
11531
|
const top = git(given, "rev-parse", "--show-toplevel");
|
|
11522
11532
|
const root = top || given;
|
|
11523
|
-
const
|
|
11524
|
-
if (
|
|
11525
|
-
|
|
11533
|
+
const legacy = top ? legacyRemote(git(root, "remote", "get-url", "origin")) : null;
|
|
11534
|
+
if (legacy) {
|
|
11535
|
+
const key = normalizeKey(`git:${legacy}`);
|
|
11536
|
+
const remote = key.slice(4);
|
|
11537
|
+
return { key, legacyKey: `git:${legacy}`, root, name: remote.split("/").slice(1).join("/") || remote };
|
|
11538
|
+
}
|
|
11526
11539
|
const map = localMap();
|
|
11527
11540
|
for (let d = root;; d = path4.dirname(d)) {
|
|
11528
11541
|
const local = map[d];
|
|
11529
11542
|
if (local && LOCAL_KEY.test(local))
|
|
11530
|
-
return { key: `local:${local}`, root: d, name: local };
|
|
11543
|
+
return { key: `local:${local}`, legacyKey: `local:${local}`, root: d, name: local };
|
|
11531
11544
|
if (top || path4.dirname(d) === d)
|
|
11532
11545
|
return null;
|
|
11533
11546
|
}
|
|
@@ -11619,6 +11632,8 @@ var HOLD_MAX = 1000;
|
|
|
11619
11632
|
var baselineDir = () => path6.join(sphicaHome(), "worktree");
|
|
11620
11633
|
function current(raw) {
|
|
11621
11634
|
const r = raw;
|
|
11635
|
+
if (typeof r.project !== "string")
|
|
11636
|
+
throw new Error("queue record without a project key");
|
|
11622
11637
|
if (r.v === 2)
|
|
11623
11638
|
return raw;
|
|
11624
11639
|
if (r.v !== 1)
|
|
@@ -11799,7 +11814,7 @@ function onHook(host, input) {
|
|
|
11799
11814
|
v: 2,
|
|
11800
11815
|
host,
|
|
11801
11816
|
session: String(input.session_id),
|
|
11802
|
-
project: place.
|
|
11817
|
+
project: place.legacyKey,
|
|
11803
11818
|
branch: branchOf(place.root),
|
|
11804
11819
|
turn,
|
|
11805
11820
|
at
|
|
@@ -11932,17 +11947,33 @@ var FLUSH_BUDGET_MS = 30000;
|
|
|
11932
11947
|
var TOOL_FLUSH_BUDGET_MS = 2000;
|
|
11933
11948
|
var ROWS = 1000;
|
|
11934
11949
|
var chunks = (xs, n = ROWS) => Array.from({ length: Math.ceil(xs.length / n) }, (_, i) => xs.slice(i * n, (i + 1) * n));
|
|
11935
|
-
async function
|
|
11950
|
+
async function projectsOf(trx, keys) {
|
|
11951
|
+
if (!keys.length)
|
|
11952
|
+
return new Map;
|
|
11953
|
+
const all = await trx.selectFrom("project").select(["id", "key"]).execute();
|
|
11954
|
+
const exact = new Map(all.map((p) => [p.key, p.id]));
|
|
11955
|
+
const normal = Map.groupBy(all, (p) => normalizeKey(p.key));
|
|
11956
|
+
const found = new Map;
|
|
11957
|
+
for (const k of keys) {
|
|
11958
|
+
const same = normal.get(normalizeKey(k));
|
|
11959
|
+
const id = exact.get(k) ?? (same?.length === 1 ? same[0]?.id : undefined);
|
|
11960
|
+
if (id !== undefined)
|
|
11961
|
+
found.set(k, id);
|
|
11962
|
+
}
|
|
11963
|
+
return found;
|
|
11964
|
+
}
|
|
11965
|
+
async function write(db, batch) {
|
|
11936
11966
|
return inTransaction(db, async (trx) => {
|
|
11967
|
+
const projects = await projectsOf(trx, batch.map((r) => r.project));
|
|
11937
11968
|
const sessions = new Map;
|
|
11938
11969
|
for (const r of batch) {
|
|
11939
11970
|
const p = projects.get(r.project);
|
|
11940
|
-
if (
|
|
11971
|
+
if (p === undefined)
|
|
11941
11972
|
continue;
|
|
11942
|
-
const id = sessionId(p
|
|
11973
|
+
const id = sessionId(p, r.host, r.session);
|
|
11943
11974
|
const prev = sessions.get(id);
|
|
11944
11975
|
if (!prev || Date.parse(r.at) < Date.parse(prev.at))
|
|
11945
|
-
sessions.set(id, { project: p
|
|
11976
|
+
sessions.set(id, { project: p, host: r.host, session: r.session, branch: r.branch, at: r.at });
|
|
11946
11977
|
}
|
|
11947
11978
|
for (const part of chunks([...sessions]))
|
|
11948
11979
|
await trx.insertInto("capture_session").values(part.map(([id, v]) => ({
|
|
@@ -11955,9 +11986,9 @@ async function write(db, batch, projects) {
|
|
|
11955
11986
|
}))).execute();
|
|
11956
11987
|
const messages = batch.flatMap((m) => {
|
|
11957
11988
|
const p = m.kind === "message" ? projects.get(m.project) : undefined;
|
|
11958
|
-
if (m.kind !== "message" ||
|
|
11989
|
+
if (m.kind !== "message" || p === undefined)
|
|
11959
11990
|
return [];
|
|
11960
|
-
return [{ m, session: sessionId(p
|
|
11991
|
+
return [{ m, session: sessionId(p, m.host, m.session) }];
|
|
11961
11992
|
});
|
|
11962
11993
|
const present = async () => {
|
|
11963
11994
|
let n = 0;
|
|
@@ -11985,11 +12016,11 @@ async function write(db, batch, projects) {
|
|
|
11985
12016
|
const after = await present();
|
|
11986
12017
|
const edits = batch.flatMap((r) => {
|
|
11987
12018
|
const p = r.kind === "edit" ? projects.get(r.project) : undefined;
|
|
11988
|
-
if (r.kind !== "edit" ||
|
|
12019
|
+
if (r.kind !== "edit" || p === undefined)
|
|
11989
12020
|
return [];
|
|
11990
12021
|
return [
|
|
11991
12022
|
{
|
|
11992
|
-
session_id: sessionId(p
|
|
12023
|
+
session_id: sessionId(p, r.host, r.session),
|
|
11993
12024
|
turn_id: r.turn,
|
|
11994
12025
|
tool_event_id: r.event,
|
|
11995
12026
|
path: r.path,
|
|
@@ -12000,7 +12031,7 @@ async function write(db, batch, projects) {
|
|
|
12000
12031
|
});
|
|
12001
12032
|
for (const part of chunks(edits))
|
|
12002
12033
|
await trx.insertInto("capture_edit").values(part).execute();
|
|
12003
|
-
return after - before;
|
|
12034
|
+
return { sent: after - before, strayed: new Set(batch.filter((r) => !projects.has(r.project))) };
|
|
12004
12035
|
});
|
|
12005
12036
|
}
|
|
12006
12037
|
var REJECTED = new Set([18, 19, 20, 25]);
|
|
@@ -12029,19 +12060,22 @@ async function sendBatch(db, names) {
|
|
|
12029
12060
|
else
|
|
12030
12061
|
fs5.rmSync(path6.join(from, name), { force: true });
|
|
12031
12062
|
}
|
|
12032
|
-
const projects = new Map((await db.selectFrom("project").select(["id", "key", "name"]).where("key", "in", [...new Set(records.map((x) => x.r.project))]).execute()).map((p) => [p.key, { id: p.id, name: p.name }]));
|
|
12033
|
-
const known = records.filter((x) => projects.has(x.r.project));
|
|
12034
|
-
const strayed = records.filter((x) => !projects.has(x.r.project));
|
|
12035
12063
|
let sent = 0;
|
|
12064
|
+
let strayed = [];
|
|
12036
12065
|
const bad = [];
|
|
12037
12066
|
try {
|
|
12038
|
-
|
|
12067
|
+
const out = await write(db, records.map((x) => x.r));
|
|
12068
|
+
sent = out.sent;
|
|
12069
|
+
strayed = records.filter((x) => out.strayed.has(x.r));
|
|
12039
12070
|
} catch (e) {
|
|
12040
12071
|
if (!rejected(e))
|
|
12041
12072
|
throw e;
|
|
12042
|
-
for (const x of
|
|
12073
|
+
for (const x of records) {
|
|
12043
12074
|
try {
|
|
12044
|
-
|
|
12075
|
+
const out = await write(db, [x.r]);
|
|
12076
|
+
sent += out.sent;
|
|
12077
|
+
if (out.strayed.size)
|
|
12078
|
+
strayed.push(x);
|
|
12045
12079
|
} catch (e2) {
|
|
12046
12080
|
if (!rejected(e2))
|
|
12047
12081
|
throw e2;
|
|
@@ -12162,6 +12196,8 @@ if (process.argv[1] && /capture\.(ts|js)$/.test(process.argv[1])) {
|
|
|
12162
12196
|
main().catch(() => {});
|
|
12163
12197
|
}
|
|
12164
12198
|
export {
|
|
12199
|
+
HOLD_DAYS,
|
|
12200
|
+
HOLD_MAX,
|
|
12165
12201
|
MAX_MESSAGE,
|
|
12166
12202
|
TOOL_FLUSH_BUDGET_MS,
|
|
12167
12203
|
answersOf,
|