sphica 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "sphica",
4
- "version": "0.5.5",
4
+ "version": "0.5.7",
5
5
  "description": "Records Claude Code and Codex sessions on your machine and keeps past implementation and decisions, with their sources, for your agent to find.",
6
6
  "author": {
7
7
  "name": "iroha924",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sphica",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "Records Claude Code and Codex sessions on your machine and keeps past implementation and decisions, with their sources, for your agent to find.",
5
5
  "author": {
6
6
  "name": "iroha924",
package/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
  [![SLSA Build L2](https://img.shields.io/badge/SLSA-Build%20L2-green)](https://www.npmjs.com/package/sphica#provenance)
8
8
  [![Dependabot: GitHub Actions](https://img.shields.io/badge/Dependabot-GitHub%20Actions-025E8C?logo=dependabot)](https://github.com/iroha924/sphica/blob/main/.github/dependabot.yml)
9
9
 
10
+ What the badges cover: CI runs the checks in [Contributing](https://github.com/iroha924/sphica#contributing), and how each release is built and published is in [Security](https://github.com/iroha924/sphica#security). A passing badge does not mean the code is free of bugs or vulnerabilities.
11
+
10
12
  English | [日本語](https://github.com/iroha924/sphica/blob/main/README.ja.md)
11
13
 
12
14
  **Local memory of past implementation and decisions for Claude Code and Codex.**
@@ -116,7 +118,7 @@ The agent searches with Sphica's `search` and opens full records with `read`. `s
116
118
  - **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.
117
119
  - **What was shown.** Each automatic delivery is logged by which records it showed, not their text.
118
120
  - **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.
119
- - **Secrets.** Only secrets with a recognizable shape are masked:
121
+ - **Secrets.** In your prompts, pull request text, and the file lines `/sphica:glean` cites, only secrets with a recognizable shape are masked (excerpts saved before 0.5.7 stay as they were):
120
122
  - keys with known prefixes
121
123
  - `KEY=…` and `"password": …` assignments
122
124
  - credentials in URLs
package/dist/capture.js CHANGED
@@ -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 maskPrivateKeys(text) {
11111
+ function keyBlocks(text) {
11112
11112
  const ends = [...text.matchAll(KEY_END)].map((m) => [m.index, m.index + m[0].length]);
11113
- if (ends.length === 0)
11114
- return text;
11115
- let out = "";
11113
+ const out = [];
11116
11114
  let last = 0;
11117
11115
  let e = 0;
11118
- for (const m of text.matchAll(KEY_BEGIN)) {
11119
- const after = m.index + m[0].length;
11120
- if (m.index < last)
11121
- continue;
11122
- while (e < ends.length && (ends[e]?.[0] ?? 0) < after)
11123
- e++;
11124
- const end = ends[e];
11125
- if (!end)
11126
- break;
11127
- out += `${text.slice(last, m.index)}[redacted: private key]`;
11128
- last = end[1];
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
  }
package/dist/cli.js CHANGED
@@ -3193,24 +3193,32 @@ var MYSQL_PASSWORD = /(\s-p)(?:'[^'\n]*'|"[^"\n]*"|(?=[^\s-])\S+)/;
3193
3193
  var URL_CREDENTIALS = /\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?|amqps?|https?):\/\/[^:\s/@]*:)[^\s/]*@([^@\s/?#]+)/g;
3194
3194
  var KEY_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/g;
3195
3195
  var KEY_END = /-----END [A-Z ]*PRIVATE KEY-----/g;
3196
- function maskPrivateKeys(text) {
3196
+ function keyBlocks(text) {
3197
3197
  const ends = [...text.matchAll(KEY_END)].map((m) => [m.index, m.index + m[0].length]);
3198
- if (ends.length === 0)
3199
- return text;
3200
- let out = "";
3198
+ const out = [];
3201
3199
  let last = 0;
3202
3200
  let e = 0;
3203
- for (const m of text.matchAll(KEY_BEGIN)) {
3204
- const after = m.index + m[0].length;
3205
- if (m.index < last)
3206
- continue;
3207
- while (e < ends.length && (ends[e]?.[0] ?? 0) < after)
3208
- e++;
3209
- const end = ends[e];
3210
- if (!end)
3211
- break;
3212
- out += `${text.slice(last, m.index)}[redacted: private key]`;
3213
- last = end[1];
3201
+ if (ends.length > 0)
3202
+ for (const m of text.matchAll(KEY_BEGIN)) {
3203
+ const after = m.index + m[0].length;
3204
+ if (m.index < last)
3205
+ continue;
3206
+ while (e < ends.length && (ends[e]?.[0] ?? 0) < after)
3207
+ e++;
3208
+ const end = ends[e];
3209
+ if (!end)
3210
+ break;
3211
+ out.push([m.index, end[1]]);
3212
+ last = end[1];
3213
+ }
3214
+ return out;
3215
+ }
3216
+ function maskPrivateKeys(text) {
3217
+ let out = "";
3218
+ let last = 0;
3219
+ for (const [a2, b] of keyBlocks(text)) {
3220
+ out += `${text.slice(last, a2)}[redacted: private key]`;
3221
+ last = b;
3214
3222
  }
3215
3223
  return out + text.slice(last);
3216
3224
  }
package/dist/deliver.js CHANGED
@@ -21,6 +21,255 @@ import path7 from "node:path";
21
21
 
22
22
  // server/src/anchors.ts
23
23
  import path from "node:path";
24
+
25
+ // server/src/text.ts
26
+ import crypto from "node:crypto";
27
+ var segmenter = new Intl.Segmenter("ja", { granularity: "word" });
28
+ var HIRAGANA_ONLY = /^[\p{Script=Hiragana}ー]+$/u;
29
+ var STOP = new Set(["the", "a", "an", "of", "to", "in", "is", "and", "or", "for", "on", "it", "be"]);
30
+ var IDENT = /#\d+|[a-z0-9][a-z0-9_./#-]*[a-z0-9]/g;
31
+ var MAX_TERM = 100;
32
+ var OKURIGANA = /^(\p{Script=Han}+)[\p{Script=Hiragana}ー]+$/u;
33
+ function singular(w) {
34
+ if (!/^[a-z]{4,}$/.test(w))
35
+ return w;
36
+ if (w.endsWith("ies"))
37
+ return `${w.slice(0, -3)}y`;
38
+ if (w.endsWith("ie"))
39
+ return `${w.slice(0, -2)}y`;
40
+ if (/(?:ss|us|x|z|ch|sh)es$/.test(w))
41
+ return w.slice(0, -2);
42
+ if (/(?:ch|sh|us)e$/.test(w))
43
+ return w.slice(0, -1);
44
+ return w.endsWith("s") && !/(?:ss|us|is)$/.test(w) ? w.slice(0, -1) : w;
45
+ }
46
+ function terms(text) {
47
+ const norm = text.normalize("NFKC").toLowerCase();
48
+ const out = [];
49
+ const keep = (w) => {
50
+ if (w.length > MAX_TERM || STOP.has(w) || HIRAGANA_ONLY.test(w))
51
+ return;
52
+ out.push(OKURIGANA.exec(w)?.[1] ?? singular(w));
53
+ };
54
+ for (const s of segmenter.segment(norm))
55
+ if (s.isWordLike)
56
+ keep(s.segment.trim());
57
+ for (const m of norm.matchAll(IDENT))
58
+ if (m[0].length >= 3)
59
+ keep(m[0]);
60
+ return out.filter(Boolean);
61
+ }
62
+ var QUESTION = new Set([
63
+ ...[
64
+ "why",
65
+ "what",
66
+ "which",
67
+ "when",
68
+ "where",
69
+ "who",
70
+ "how",
71
+ "do",
72
+ "does",
73
+ "did",
74
+ "we",
75
+ "our",
76
+ "us",
77
+ "you",
78
+ "i",
79
+ "are",
80
+ "was",
81
+ "were"
82
+ ],
83
+ ...[
84
+ "will",
85
+ "would",
86
+ "should",
87
+ "can",
88
+ "could",
89
+ "there",
90
+ "this",
91
+ "that",
92
+ "with",
93
+ "from",
94
+ "any",
95
+ "ever",
96
+ "long",
97
+ "use",
98
+ "used",
99
+ "using"
100
+ ],
101
+ ...["add", "get", "make", "have", "has", "not", "no", "yes", "reason"],
102
+ ...["理由", "仕組", "何", "方", "場合", "今", "件"]
103
+ ]);
104
+ var sha256 = (s) => crypto.createHash("sha256").update(s).digest();
105
+ function uuidFrom(...parts) {
106
+ const b = crypto.createHash("sha256").update(parts.join("\x00")).digest().subarray(0, 16);
107
+ b[6] = (b[6] ?? 0) & 15 | 128;
108
+ b[8] = (b[8] ?? 0) & 63 | 128;
109
+ const h = b.toString("hex");
110
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
111
+ }
112
+ var bytes = (s) => Buffer.byteLength(s, "utf8");
113
+ function head(s, n) {
114
+ if (bytes(s) <= n)
115
+ return s;
116
+ let out = "";
117
+ let used = 0;
118
+ for (const ch of s) {
119
+ const b = bytes(ch);
120
+ if (used + b > n)
121
+ break;
122
+ out += ch;
123
+ used += b;
124
+ }
125
+ return out;
126
+ }
127
+ function tail(s, n) {
128
+ if (bytes(s) <= n)
129
+ return s;
130
+ const chars = [...s];
131
+ let used = 0;
132
+ let i = chars.length;
133
+ while (i > 0) {
134
+ const b = bytes(chars[i - 1] ?? "");
135
+ if (used + b > n)
136
+ break;
137
+ used += b;
138
+ i--;
139
+ }
140
+ return chars.slice(i).join("");
141
+ }
142
+ var clean = (s) => s.replaceAll("\x00", "");
143
+ var visible = (s) => s.replace(/(?!\p{Join_Control}|\p{Variation_Selector})\p{Default_Ignorable_Code_Point}/gu, "");
144
+ var SECRETS = [
145
+ [/\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{20,}/g, "API key"],
146
+ [/\b[srp]k_(?:live|test)_[A-Za-z0-9]{16,}/g, "API key"],
147
+ [/\bwhsec_[A-Za-z0-9+/=]{16,}/g, "webhook signing secret"],
148
+ [/\bpa-[A-Za-z0-9_-]{20,}/g, "API key"],
149
+ [/\bAIza[0-9A-Za-z_-]{35}/g, "API key"],
150
+ [/\bnpg_[A-Za-z0-9]{12,}/g, "database password"],
151
+ [/\bnapi_[A-Za-z0-9]{30,}/g, "API key"],
152
+ [/\bnpm_[A-Za-z0-9]{36}\b/g, "npm token"],
153
+ [/\bglpat-[A-Za-z0-9_-]{20,}/g, "GitLab token"],
154
+ [/\bgh[pousr]_[A-Za-z0-9]{30,}/g, "GitHub token"],
155
+ [/\bgithub_pat_[A-Za-z0-9_]{40,}/g, "GitHub token"],
156
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, "Slack token"],
157
+ [/https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/]+/g, "Slack webhook"],
158
+ [/\bAKIA[0-9A-Z]{16}\b/g, "AWS key"],
159
+ [/(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "JWT"],
160
+ [/\b(?:Bearer|BEARER)\s+(?=[A-Za-z0-9._~+/=-]{0,512}\d)[A-Za-z0-9._~+/=-]{16,}/g, "auth header value"]
161
+ ];
162
+ var AUTH_HEADER = /(\bAuthorization["']?\s*[:=]\s*(?:["']\s*)?(?:Bearer|Basic|Token|Digest)\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
163
+ var HEADER_BEARER = /(:[ \t]*(?:["'][ \t]*)?bearer[ \t]+)[A-Za-z0-9._~+/=-]{16,}/gi;
164
+ var ENV_ASSIGN = /\b((?:[A-Z][A-Z0-9_]*_)?(?:API|SECRET|MASTER|ENCRYPTION|PRIVATE|ACCESS|SIGNING|AUTH)?KEY|[A-Z][A-Z0-9_]*_(?:PASS|PWD)|(?:[A-Z][A-Z0-9_]*?)?(?:TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?))(\s*=\s*)(?:"(?!\$)[^"\n]+"|'(?!\$)[^'\n]+'|(?![$"'])[^\s"']+)/g;
165
+ var FIELD_NAME = /(?:(?:api|account|access|private|secret)[-_]?key|secret|token|passw(?:or)?d)["']?\s*(?::=|=>|[:=])\s*/gi;
166
+ var MAX_QUOTED = 4096;
167
+ var BARE_HEAD = /[^\s"',;)]{1,256}/y;
168
+ var BARE_REST = /[^\s"',;)]*/y;
169
+ var NEXT_PARAM = /&[A-Za-z_][\w.-]*=/;
170
+ var bareAt = (re, text, at) => {
171
+ re.lastIndex = at;
172
+ const v = re.exec(text)?.[0] ?? "";
173
+ const cut = v.search(NEXT_PARAM);
174
+ return cut < 0 ? v : v.slice(0, cut);
175
+ };
176
+ function secretValue(quoted, v) {
177
+ if (v.length < 8 || /^\$(?:\{|[A-Za-z_])/.test(v))
178
+ return false;
179
+ if (!quoted)
180
+ return !/^[#$]/.test(v) && /\d/.test(v) && /[A-Za-z]/.test(v) && !/[()]/.test(v);
181
+ if (/^#[0-9a-f]{3,8}$/i.test(v) || !/[A-Za-z0-9]/.test(v))
182
+ return false;
183
+ return !/\s/.test(v) || v.split(/\s+/).some((w) => /\d/.test(w) && /[A-Za-z]/.test(w));
184
+ }
185
+ function maskFields(text) {
186
+ let out = "";
187
+ let last = 0;
188
+ FIELD_NAME.lastIndex = 0;
189
+ for (let m = FIELD_NAME.exec(text);m; m = FIELD_NAME.exec(text)) {
190
+ const at = m.index + m[0].length;
191
+ const q = text[at];
192
+ let quote = "";
193
+ let value;
194
+ if (q === '"' || q === "'") {
195
+ const close = text.indexOf(q, at + 1);
196
+ if (close < 0 || close - at - 1 > MAX_QUOTED)
197
+ continue;
198
+ value = text.slice(at + 1, close);
199
+ if (value.includes(`
200
+ `))
201
+ continue;
202
+ quote = q;
203
+ } else {
204
+ value = bareAt(BARE_HEAD, text, at);
205
+ }
206
+ if (!secretValue(quote !== "", value))
207
+ continue;
208
+ if (!quote && value.length === 256)
209
+ value += bareAt(BARE_REST, text, at + 256);
210
+ out += `${text.slice(last, at)}${quote}[redacted]`;
211
+ last = at + quote.length + value.length;
212
+ FIELD_NAME.lastIndex = last;
213
+ }
214
+ return out + text.slice(last);
215
+ }
216
+ var MYSQL_COMMAND = /\bmysql(?:dump|admin)?\b(?:'[^'\n]*'|"[^"\n]*"|[^\n;&|\\'"]|\\\r?\n|\\(?!\r?\n))*/g;
217
+ var MYSQL_PASSWORD = /(\s-p)(?:'[^'\n]*'|"[^"\n]*"|(?=[^\s-])\S+)/;
218
+ var URL_CREDENTIALS = /\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?|amqps?|https?):\/\/[^:\s/@]*:)[^\s/]*@([^@\s/?#]+)/g;
219
+ var KEY_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/g;
220
+ var KEY_END = /-----END [A-Z ]*PRIVATE KEY-----/g;
221
+ function keyBlocks(text) {
222
+ const ends = [...text.matchAll(KEY_END)].map((m) => [m.index, m.index + m[0].length]);
223
+ const out = [];
224
+ let last = 0;
225
+ let e = 0;
226
+ if (ends.length > 0)
227
+ for (const m of text.matchAll(KEY_BEGIN)) {
228
+ const after = m.index + m[0].length;
229
+ if (m.index < last)
230
+ continue;
231
+ while (e < ends.length && (ends[e]?.[0] ?? 0) < after)
232
+ e++;
233
+ const end = ends[e];
234
+ if (!end)
235
+ break;
236
+ out.push([m.index, end[1]]);
237
+ last = end[1];
238
+ }
239
+ return out;
240
+ }
241
+ function maskPrivateKeys(text) {
242
+ let out = "";
243
+ let last = 0;
244
+ for (const [a, b] of keyBlocks(text)) {
245
+ out += `${text.slice(last, a)}[redacted: private key]`;
246
+ last = b;
247
+ }
248
+ return out + text.slice(last);
249
+ }
250
+ function mask(text) {
251
+ let out = maskFields(maskPrivateKeys(text).replace(URL_CREDENTIALS, "$1[redacted]@$2").replace(AUTH_HEADER, "$1[redacted]").replace(HEADER_BEARER, "$1[redacted]").replace(ENV_ASSIGN, "$1$2[redacted]")).replace(MYSQL_COMMAND, (command) => command.replace(MYSQL_PASSWORD, "$1[redacted]"));
252
+ for (const [re, what] of SECRETS)
253
+ out = out.replace(re, `[redacted: ${what}]`);
254
+ return out;
255
+ }
256
+ var reason = (e) => explain(e, 0) || "unknown failure";
257
+ var plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
258
+ function explain(e, depth) {
259
+ if (!(e instanceof Error)) {
260
+ try {
261
+ return String(e);
262
+ } catch {
263
+ return "";
264
+ }
265
+ }
266
+ const own = e.message || (e.name === "Error" || e.name === "AggregateError" ? "" : e.name);
267
+ const parts = depth >= 3 ? [] : [...e instanceof AggregateError ? e.errors : [], ...e.cause === undefined ? [] : [e.cause]];
268
+ const inner = parts.map((x) => explain(x, depth + 1)).filter(Boolean).join(" / ");
269
+ return own && inner ? `${own} (${inner})` : own || inner;
270
+ }
271
+
272
+ // server/src/anchors.ts
24
273
  var MAX_BYTES = 2 * 1024 * 1024;
25
274
  var leaves = (rel) => rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
26
275
 
@@ -4154,8 +4403,8 @@ class Deferred {
4154
4403
  this.#resolve?.(value);
4155
4404
  this.#resolve = this.#reject = undefined;
4156
4405
  };
4157
- reject = (reason) => {
4158
- this.#reject?.(reason);
4406
+ reject = (reason2) => {
4407
+ this.#reject?.(reason2);
4159
4408
  this.#reject = this.#resolve = undefined;
4160
4409
  };
4161
4410
  }
@@ -4207,9 +4456,9 @@ function assertNotAborted(signal, timing, beforeThrow) {
4207
4456
  throwReasonWithTiming(signal.reason, timing);
4208
4457
  }
4209
4458
  }
4210
- function throwReasonWithTiming(reason, timing) {
4211
- decorateWithTiming(reason, timing);
4212
- throw reason;
4459
+ function throwReasonWithTiming(reason2, timing) {
4460
+ decorateWithTiming(reason2, timing);
4461
+ throw reason2;
4213
4462
  }
4214
4463
  var ABORTED = {};
4215
4464
  async function waitOrAbort(promise, signal, name, onAbort) {
@@ -4234,11 +4483,11 @@ async function waitOrAbort(promise, signal, name, onAbort) {
4234
4483
  }
4235
4484
  }
4236
4485
  function printBackgroundFail(name) {
4237
- return (reason) => console.error(`\`${name}\` failed in the background after abortion: ${getMessage(reason)}`);
4486
+ return (reason2) => console.error(`\`${name}\` failed in the background after abortion: ${getMessage(reason2)}`);
4238
4487
  }
4239
- function decorateWithTiming(reason, timing) {
4240
- if (reason !== null && typeof reason === "object" && !Object.isFrozen(reason)) {
4241
- Object.defineProperty(reason, "__kysely_timing__", {
4488
+ function decorateWithTiming(reason2, timing) {
4489
+ if (reason2 !== null && typeof reason2 === "object" && !Object.isFrozen(reason2)) {
4490
+ Object.defineProperty(reason2, "__kysely_timing__", {
4242
4491
  configurable: true,
4243
4492
  enumerable: false,
4244
4493
  value: timing,
@@ -8163,9 +8412,9 @@ class RuntimeDriver {
8163
8412
  }
8164
8413
  this.#initPromise ??= this.#driver.init(options).then(() => {
8165
8414
  this.#initDone = true;
8166
- }).catch((reason) => {
8415
+ }).catch((reason2) => {
8167
8416
  this.#initPromise = undefined;
8168
- throw reason;
8417
+ throw reason2;
8169
8418
  });
8170
8419
  await waitOrAbort(this.#initPromise, options?.signal, "init");
8171
8420
  }
@@ -8226,9 +8475,9 @@ class RuntimeDriver {
8226
8475
  return;
8227
8476
  }
8228
8477
  await waitOrAbort(this.#initPromise, options?.signal, "destroy:initPromise");
8229
- this.#destroyPromise ??= this.#driver.destroy(options).catch((reason) => {
8478
+ this.#destroyPromise ??= this.#driver.destroy(options).catch((reason2) => {
8230
8479
  this.#destroyPromise = undefined;
8231
- throw reason;
8480
+ throw reason2;
8232
8481
  });
8233
8482
  await waitOrAbort(this.#destroyPromise, options?.signal, "destroy");
8234
8483
  }
@@ -10959,247 +11208,6 @@ function sqliteCode(e) {
10959
11208
 
10960
11209
  // server/src/db-write.ts
10961
11210
  import { constants as C2, DatabaseSync as DatabaseSync2 } from "node:sqlite";
10962
-
10963
- // server/src/text.ts
10964
- import crypto from "node:crypto";
10965
- var segmenter = new Intl.Segmenter("ja", { granularity: "word" });
10966
- var HIRAGANA_ONLY = /^[\p{Script=Hiragana}ー]+$/u;
10967
- var STOP = new Set(["the", "a", "an", "of", "to", "in", "is", "and", "or", "for", "on", "it", "be"]);
10968
- var IDENT = /#\d+|[a-z0-9][a-z0-9_./#-]*[a-z0-9]/g;
10969
- var MAX_TERM = 100;
10970
- var OKURIGANA = /^(\p{Script=Han}+)[\p{Script=Hiragana}ー]+$/u;
10971
- function singular(w) {
10972
- if (!/^[a-z]{4,}$/.test(w))
10973
- return w;
10974
- if (w.endsWith("ies"))
10975
- return `${w.slice(0, -3)}y`;
10976
- if (w.endsWith("ie"))
10977
- return `${w.slice(0, -2)}y`;
10978
- if (/(?:ss|us|x|z|ch|sh)es$/.test(w))
10979
- return w.slice(0, -2);
10980
- if (/(?:ch|sh|us)e$/.test(w))
10981
- return w.slice(0, -1);
10982
- return w.endsWith("s") && !/(?:ss|us|is)$/.test(w) ? w.slice(0, -1) : w;
10983
- }
10984
- function terms(text) {
10985
- const norm = text.normalize("NFKC").toLowerCase();
10986
- const out = [];
10987
- const keep = (w) => {
10988
- if (w.length > MAX_TERM || STOP.has(w) || HIRAGANA_ONLY.test(w))
10989
- return;
10990
- out.push(OKURIGANA.exec(w)?.[1] ?? singular(w));
10991
- };
10992
- for (const s of segmenter.segment(norm))
10993
- if (s.isWordLike)
10994
- keep(s.segment.trim());
10995
- for (const m of norm.matchAll(IDENT))
10996
- if (m[0].length >= 3)
10997
- keep(m[0]);
10998
- return out.filter(Boolean);
10999
- }
11000
- var QUESTION = new Set([
11001
- ...[
11002
- "why",
11003
- "what",
11004
- "which",
11005
- "when",
11006
- "where",
11007
- "who",
11008
- "how",
11009
- "do",
11010
- "does",
11011
- "did",
11012
- "we",
11013
- "our",
11014
- "us",
11015
- "you",
11016
- "i",
11017
- "are",
11018
- "was",
11019
- "were"
11020
- ],
11021
- ...[
11022
- "will",
11023
- "would",
11024
- "should",
11025
- "can",
11026
- "could",
11027
- "there",
11028
- "this",
11029
- "that",
11030
- "with",
11031
- "from",
11032
- "any",
11033
- "ever",
11034
- "long",
11035
- "use",
11036
- "used",
11037
- "using"
11038
- ],
11039
- ...["add", "get", "make", "have", "has", "not", "no", "yes", "reason"],
11040
- ...["理由", "仕組", "何", "方", "場合", "今", "件"]
11041
- ]);
11042
- var sha256 = (s) => crypto.createHash("sha256").update(s).digest();
11043
- function uuidFrom(...parts) {
11044
- const b = crypto.createHash("sha256").update(parts.join("\x00")).digest().subarray(0, 16);
11045
- b[6] = (b[6] ?? 0) & 15 | 128;
11046
- b[8] = (b[8] ?? 0) & 63 | 128;
11047
- const h = b.toString("hex");
11048
- return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
11049
- }
11050
- var bytes = (s) => Buffer.byteLength(s, "utf8");
11051
- function head(s, n) {
11052
- if (bytes(s) <= n)
11053
- return s;
11054
- let out = "";
11055
- let used = 0;
11056
- for (const ch of s) {
11057
- const b = bytes(ch);
11058
- if (used + b > n)
11059
- break;
11060
- out += ch;
11061
- used += b;
11062
- }
11063
- return out;
11064
- }
11065
- function tail(s, n) {
11066
- if (bytes(s) <= n)
11067
- return s;
11068
- const chars = [...s];
11069
- let used = 0;
11070
- let i = chars.length;
11071
- while (i > 0) {
11072
- const b = bytes(chars[i - 1] ?? "");
11073
- if (used + b > n)
11074
- break;
11075
- used += b;
11076
- i--;
11077
- }
11078
- return chars.slice(i).join("");
11079
- }
11080
- var clean = (s) => s.replaceAll("\x00", "");
11081
- var visible = (s) => s.replace(/(?!\p{Join_Control}|\p{Variation_Selector})\p{Default_Ignorable_Code_Point}/gu, "");
11082
- var SECRETS = [
11083
- [/\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{20,}/g, "API key"],
11084
- [/\b[srp]k_(?:live|test)_[A-Za-z0-9]{16,}/g, "API key"],
11085
- [/\bwhsec_[A-Za-z0-9+/=]{16,}/g, "webhook signing secret"],
11086
- [/\bpa-[A-Za-z0-9_-]{20,}/g, "API key"],
11087
- [/\bAIza[0-9A-Za-z_-]{35}/g, "API key"],
11088
- [/\bnpg_[A-Za-z0-9]{12,}/g, "database password"],
11089
- [/\bnapi_[A-Za-z0-9]{30,}/g, "API key"],
11090
- [/\bnpm_[A-Za-z0-9]{36}\b/g, "npm token"],
11091
- [/\bglpat-[A-Za-z0-9_-]{20,}/g, "GitLab token"],
11092
- [/\bgh[pousr]_[A-Za-z0-9]{30,}/g, "GitHub token"],
11093
- [/\bgithub_pat_[A-Za-z0-9_]{40,}/g, "GitHub token"],
11094
- [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, "Slack token"],
11095
- [/https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/]+/g, "Slack webhook"],
11096
- [/\bAKIA[0-9A-Z]{16}\b/g, "AWS key"],
11097
- [/(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "JWT"],
11098
- [/\b(?:Bearer|BEARER)\s+(?=[A-Za-z0-9._~+/=-]{0,512}\d)[A-Za-z0-9._~+/=-]{16,}/g, "auth header value"]
11099
- ];
11100
- var AUTH_HEADER = /(\bAuthorization["']?\s*[:=]\s*(?:["']\s*)?(?:Bearer|Basic|Token|Digest)\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
11101
- var HEADER_BEARER = /(:[ \t]*(?:["'][ \t]*)?bearer[ \t]+)[A-Za-z0-9._~+/=-]{16,}/gi;
11102
- var ENV_ASSIGN = /\b((?:[A-Z][A-Z0-9_]*_)?(?:API|SECRET|MASTER|ENCRYPTION|PRIVATE|ACCESS|SIGNING|AUTH)?KEY|[A-Z][A-Z0-9_]*_(?:PASS|PWD)|(?:[A-Z][A-Z0-9_]*?)?(?:TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?))(\s*=\s*)(?:"(?!\$)[^"\n]+"|'(?!\$)[^'\n]+'|(?![$"'])[^\s"']+)/g;
11103
- var FIELD_NAME = /(?:(?:api|account|access|private|secret)[-_]?key|secret|token|passw(?:or)?d)["']?\s*(?::=|=>|[:=])\s*/gi;
11104
- var MAX_QUOTED = 4096;
11105
- var BARE_HEAD = /[^\s"',;)]{1,256}/y;
11106
- var BARE_REST = /[^\s"',;)]*/y;
11107
- var NEXT_PARAM = /&[A-Za-z_][\w.-]*=/;
11108
- var bareAt = (re, text, at) => {
11109
- re.lastIndex = at;
11110
- const v = re.exec(text)?.[0] ?? "";
11111
- const cut = v.search(NEXT_PARAM);
11112
- return cut < 0 ? v : v.slice(0, cut);
11113
- };
11114
- function secretValue(quoted, v) {
11115
- if (v.length < 8 || /^\$(?:\{|[A-Za-z_])/.test(v))
11116
- return false;
11117
- if (!quoted)
11118
- return !/^[#$]/.test(v) && /\d/.test(v) && /[A-Za-z]/.test(v) && !/[()]/.test(v);
11119
- if (/^#[0-9a-f]{3,8}$/i.test(v) || !/[A-Za-z0-9]/.test(v))
11120
- return false;
11121
- return !/\s/.test(v) || v.split(/\s+/).some((w) => /\d/.test(w) && /[A-Za-z]/.test(w));
11122
- }
11123
- function maskFields(text) {
11124
- let out = "";
11125
- let last = 0;
11126
- FIELD_NAME.lastIndex = 0;
11127
- for (let m = FIELD_NAME.exec(text);m; m = FIELD_NAME.exec(text)) {
11128
- const at = m.index + m[0].length;
11129
- const q = text[at];
11130
- let quote = "";
11131
- let value;
11132
- if (q === '"' || q === "'") {
11133
- const close = text.indexOf(q, at + 1);
11134
- if (close < 0 || close - at - 1 > MAX_QUOTED)
11135
- continue;
11136
- value = text.slice(at + 1, close);
11137
- if (value.includes(`
11138
- `))
11139
- continue;
11140
- quote = q;
11141
- } else {
11142
- value = bareAt(BARE_HEAD, text, at);
11143
- }
11144
- if (!secretValue(quote !== "", value))
11145
- continue;
11146
- if (!quote && value.length === 256)
11147
- value += bareAt(BARE_REST, text, at + 256);
11148
- out += `${text.slice(last, at)}${quote}[redacted]`;
11149
- last = at + quote.length + value.length;
11150
- FIELD_NAME.lastIndex = last;
11151
- }
11152
- return out + text.slice(last);
11153
- }
11154
- var MYSQL_COMMAND = /\bmysql(?:dump|admin)?\b(?:'[^'\n]*'|"[^"\n]*"|[^\n;&|\\'"]|\\\r?\n|\\(?!\r?\n))*/g;
11155
- var MYSQL_PASSWORD = /(\s-p)(?:'[^'\n]*'|"[^"\n]*"|(?=[^\s-])\S+)/;
11156
- var URL_CREDENTIALS = /\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?|amqps?|https?):\/\/[^:\s/@]*:)[^\s/]*@([^@\s/?#]+)/g;
11157
- var KEY_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/g;
11158
- var KEY_END = /-----END [A-Z ]*PRIVATE KEY-----/g;
11159
- function maskPrivateKeys(text) {
11160
- const ends = [...text.matchAll(KEY_END)].map((m) => [m.index, m.index + m[0].length]);
11161
- if (ends.length === 0)
11162
- return text;
11163
- let out = "";
11164
- let last = 0;
11165
- let e = 0;
11166
- for (const m of text.matchAll(KEY_BEGIN)) {
11167
- const after = m.index + m[0].length;
11168
- if (m.index < last)
11169
- continue;
11170
- while (e < ends.length && (ends[e]?.[0] ?? 0) < after)
11171
- e++;
11172
- const end = ends[e];
11173
- if (!end)
11174
- break;
11175
- out += `${text.slice(last, m.index)}[redacted: private key]`;
11176
- last = end[1];
11177
- }
11178
- return out + text.slice(last);
11179
- }
11180
- function mask(text) {
11181
- let out = maskFields(maskPrivateKeys(text).replace(URL_CREDENTIALS, "$1[redacted]@$2").replace(AUTH_HEADER, "$1[redacted]").replace(HEADER_BEARER, "$1[redacted]").replace(ENV_ASSIGN, "$1$2[redacted]")).replace(MYSQL_COMMAND, (command) => command.replace(MYSQL_PASSWORD, "$1[redacted]"));
11182
- for (const [re, what] of SECRETS)
11183
- out = out.replace(re, `[redacted: ${what}]`);
11184
- return out;
11185
- }
11186
- var reason = (e) => explain(e, 0) || "unknown failure";
11187
- var plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
11188
- function explain(e, depth) {
11189
- if (!(e instanceof Error)) {
11190
- try {
11191
- return String(e);
11192
- } catch {
11193
- return "";
11194
- }
11195
- }
11196
- const own = e.message || (e.name === "Error" || e.name === "AggregateError" ? "" : e.name);
11197
- const parts = depth >= 3 ? [] : [...e instanceof AggregateError ? e.errors : [], ...e.cause === undefined ? [] : [e.cause]];
11198
- const inner = parts.map((x) => explain(x, depth + 1)).filter(Boolean).join(" / ");
11199
- return own && inner ? `${own} (${inner})` : own || inner;
11200
- }
11201
-
11202
- // server/src/db-write.ts
11203
11211
  var ddl = null;
11204
11212
  var DDL = () => ddl ??= new Set([
11205
11213
  "SQLITE_ALTER_TABLE",
@@ -45822,27 +45822,80 @@ var MYSQL_PASSWORD = /(\s-p)(?:'[^'\n]*'|"[^"\n]*"|(?=[^\s-])\S+)/;
45822
45822
  var URL_CREDENTIALS = /\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?|amqps?|https?):\/\/[^:\s/@]*:)[^\s/]*@([^@\s/?#]+)/g;
45823
45823
  var KEY_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/g;
45824
45824
  var KEY_END = /-----END [A-Z ]*PRIVATE KEY-----/g;
45825
- function maskPrivateKeys(text) {
45825
+ function keyBlocks(text) {
45826
45826
  const ends = [...text.matchAll(KEY_END)].map((m) => [m.index, m.index + m[0].length]);
45827
- if (ends.length === 0)
45828
- return text;
45829
- let out = "";
45827
+ const out = [];
45830
45828
  let last = 0;
45831
45829
  let e = 0;
45832
- for (const m of text.matchAll(KEY_BEGIN)) {
45833
- const after = m.index + m[0].length;
45834
- if (m.index < last)
45835
- continue;
45836
- while (e < ends.length && (ends[e]?.[0] ?? 0) < after)
45837
- e++;
45838
- const end = ends[e];
45839
- if (!end)
45840
- break;
45841
- out += `${text.slice(last, m.index)}[redacted: private key]`;
45842
- last = end[1];
45830
+ if (ends.length > 0)
45831
+ for (const m of text.matchAll(KEY_BEGIN)) {
45832
+ const after = m.index + m[0].length;
45833
+ if (m.index < last)
45834
+ continue;
45835
+ while (e < ends.length && (ends[e]?.[0] ?? 0) < after)
45836
+ e++;
45837
+ const end = ends[e];
45838
+ if (!end)
45839
+ break;
45840
+ out.push([m.index, end[1]]);
45841
+ last = end[1];
45842
+ }
45843
+ return out;
45844
+ }
45845
+ function maskPrivateKeys(text) {
45846
+ let out = "";
45847
+ let last = 0;
45848
+ for (const [a, b] of keyBlocks(text)) {
45849
+ out += `${text.slice(last, a)}[redacted: private key]`;
45850
+ last = b;
45843
45851
  }
45844
45852
  return out + text.slice(last);
45845
45853
  }
45854
+ function privateKeyRanges(text) {
45855
+ return byteRanges(text, keyBlocks(text));
45856
+ }
45857
+ function byteRanges(text, ranges) {
45858
+ const out = [];
45859
+ let at = 0;
45860
+ let pos = 0;
45861
+ for (const [a, b] of ranges) {
45862
+ pos += bytes(text.slice(at, a));
45863
+ const end = pos + bytes(text.slice(a, b));
45864
+ out.push([pos, end]);
45865
+ at = b;
45866
+ pos = end;
45867
+ }
45868
+ return out;
45869
+ }
45870
+ var PLACEHOLDER = /\[redacted(?:: [^\]\n]{1,40})?\]/g;
45871
+ function placeholderRanges(masked) {
45872
+ return [...masked.matchAll(PLACEHOLDER)].map((x) => [x.index, x.index + x[0].length]);
45873
+ }
45874
+ function starts(hay, needle) {
45875
+ const out = [];
45876
+ for (let at = hay.indexOf(needle);at >= 0; at = hay.indexOf(needle, at + 1))
45877
+ out.push(at);
45878
+ return out;
45879
+ }
45880
+ function quoteSpan(raw, masked, quote) {
45881
+ const q = Buffer.from(quote, "utf8");
45882
+ if (q.length === 0)
45883
+ return null;
45884
+ const m = Buffer.from(masked, "utf8");
45885
+ if (raw === masked) {
45886
+ const at = m.indexOf(q);
45887
+ return at < 0 ? null : [at, at + q.length];
45888
+ }
45889
+ const holes = byteRanges(masked, placeholderRanges(masked));
45890
+ let h = 0;
45891
+ const kept = starts(m, q).filter((at) => {
45892
+ while ((holes[h]?.[1] ?? Number.POSITIVE_INFINITY) <= at)
45893
+ h++;
45894
+ return !((holes[h]?.[0] ?? Number.POSITIVE_INFINITY) < at + q.length);
45895
+ });
45896
+ const first = kept[0];
45897
+ return first !== undefined && kept.length === starts(Buffer.from(raw, "utf8"), q).length ? [first, first + q.length] : null;
45898
+ }
45846
45899
  function mask(text) {
45847
45900
  let out = maskFields(maskPrivateKeys(text).replace(URL_CREDENTIALS, "$1[redacted]@$2").replace(AUTH_HEADER, "$1[redacted]").replace(HEADER_BEARER, "$1[redacted]").replace(ENV_ASSIGN, "$1$2[redacted]")).replace(MYSQL_COMMAND, (command) => command.replace(MYSQL_PASSWORD, "$1[redacted]"));
45848
45901
  for (const [re, what] of SECRETS)
@@ -46053,14 +46106,61 @@ function readText(root, rel) {
46053
46106
  function findSymbol(text, symbol2) {
46054
46107
  const re = new RegExp(`(?<![\\w$])${literal3(symbol2)}(?![\\w$])`);
46055
46108
  const lines = text.split(/\r?\n/);
46056
- const i = lines.findIndex((l) => re.test(l));
46057
- return i < 0 ? null : { line: i + 1, excerpt: (lines[i] ?? "").trim().slice(0, 200) };
46109
+ return { lines, i: lines.findIndex((l) => re.test(l)) };
46110
+ }
46111
+ function swallowed(root, rel, symbol2) {
46112
+ if (mask(symbol2) !== symbol2)
46113
+ return true;
46114
+ const text = root ? readText(root, rel) : null;
46115
+ if (text === undefined)
46116
+ return true;
46117
+ if (text === null)
46118
+ return false;
46119
+ const re = new RegExp(`(?<![\\w$])${literal3(symbol2)}(?![\\w$])`, "g");
46120
+ let raw = 0;
46121
+ for (let m = re.exec(text);m; m = re.exec(text))
46122
+ raw++;
46123
+ if (raw === 0)
46124
+ return false;
46125
+ const masked = mask(text);
46126
+ const holes = placeholderRanges(masked);
46127
+ let h = 0;
46128
+ let kept = 0;
46129
+ re.lastIndex = 0;
46130
+ for (let m = re.exec(masked);m; m = re.exec(masked)) {
46131
+ while ((holes[h]?.[1] ?? Number.POSITIVE_INFINITY) < m.index)
46132
+ h++;
46133
+ if (!((holes[h]?.[0] ?? Number.POSITIVE_INFINITY) <= m.index + symbol2.length))
46134
+ kept++;
46135
+ }
46136
+ return kept !== raw;
46137
+ }
46138
+ function masksSymbol(root, rel, symbol2) {
46139
+ const trimmed = symbol2.trim();
46140
+ return swallowed(root, rel, symbol2) || trimmed !== symbol2 && trimmed !== "" && swallowed(root, rel, trimmed);
46058
46141
  }
46059
46142
  function locate(root, rel, symbol2) {
46060
46143
  if (!root)
46061
46144
  return null;
46062
46145
  const text = readText(root, rel);
46063
- return typeof text === "string" ? findSymbol(text, symbol2) : null;
46146
+ if (typeof text !== "string")
46147
+ return null;
46148
+ const { lines, i } = findSymbol(text, symbol2);
46149
+ if (i < 0)
46150
+ return null;
46151
+ const line = lines[i] ?? "";
46152
+ const from = bytes(lines.slice(0, i).join(`
46153
+ `)) + (i > 0 ? 1 : 0);
46154
+ if (privateKeyRanges(lines.join(`
46155
+ `)).some(([a, b]) => a < from + bytes(line) && b > from))
46156
+ return { line: i + 1, excerpt: "[redacted: private key]" };
46157
+ const before = lines.slice(0, i).map((l) => `${l}
46158
+ `).join("");
46159
+ const after = lines.slice(i + 1).map((l) => `
46160
+ ${l}`).join("");
46161
+ const own2 = mask(line);
46162
+ const cut = mask(before + line) !== mask(before) + own2 || mask(line + after) !== own2 + mask(after);
46163
+ return { line: i + 1, excerpt: cut ? "[redacted]" : own2.trim().slice(0, 200) };
46064
46164
  }
46065
46165
 
46066
46166
  // server/src/project.ts
@@ -46750,9 +46850,23 @@ import { promisify } from "node:util";
46750
46850
  var exec = promisify(execFile);
46751
46851
  var MAX_ISSUES = 5;
46752
46852
  var CLOSES = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+(?:([\w.-]+\/[\w.-]+)#|https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/issues\/|#)(\d{1,9})\b/gi;
46853
+ function visible2(body) {
46854
+ let out = "";
46855
+ let at = 0;
46856
+ for (;; ) {
46857
+ const open2 = body.indexOf("<!--", at);
46858
+ if (open2 < 0)
46859
+ return out + body.slice(at);
46860
+ out += body.slice(at, open2);
46861
+ const close = body.indexOf("-->", open2 + 4);
46862
+ if (close < 0)
46863
+ return out;
46864
+ at = close + 3;
46865
+ }
46866
+ }
46753
46867
  function closingRefs(body, repo) {
46754
46868
  const here = repo?.toLowerCase();
46755
- return [...body.matchAll(CLOSES)].flatMap((m) => {
46869
+ return [...visible2(body).matchAll(CLOSES)].flatMap((m) => {
46756
46870
  const named = (m[1] ?? m[2])?.toLowerCase();
46757
46871
  return !named || named === here ? [Number(m[3])] : [];
46758
46872
  });
@@ -47198,19 +47312,36 @@ async function checkRecord(db, target, raw) {
47198
47312
  adoption.push({ source: s.id, start: span[0], end: span[1], route });
47199
47313
  }
47200
47314
  const anchors = [];
47315
+ const fallbacks = new Set;
47201
47316
  for (const a of u.anchors) {
47202
47317
  const p = repoPath(a.path);
47203
47318
  if (!p) {
47204
47319
  problems.push(`${key}: anchor path ${JSON.stringify(head(a.path, 80))} is not inside the repository; left out`);
47205
47320
  continue;
47206
47321
  }
47322
+ const symbol2 = a.symbol && masksSymbol(target.root, p, a.symbol) ? undefined : a.symbol;
47323
+ if (a.symbol && !symbol2)
47324
+ problems.push(`${key}: anchor symbol in ${p} is text Sphica masks; the anchor keeps only its path`);
47207
47325
  let commit = a.commit;
47208
47326
  if (commit && !(target.root && commitHolds(target.root, commit, p))) {
47209
47327
  problems.push(`${key}: commit ${commit.slice(0, 12)} does not hold ${p} in the repository${target.root ? "" : " (no working tree to check)"}; the anchor keeps no commit`);
47210
47328
  commit = undefined;
47211
47329
  }
47212
47330
  const observation = a.role === "evidence" && !commit && target.sessionId ? (await db.selectFrom("edit_observation").select("id").where("session_id", "=", target.sessionId).where("path", "=", p).orderBy("id", "desc").executeTakeFirst())?.id ?? null : null;
47213
- anchors.push({ ...a, commit, path: p, observation });
47331
+ const planned = { ...a, symbol: symbol2, commit, path: p, observation };
47332
+ if (a.symbol && !symbol2)
47333
+ fallbacks.add(planned);
47334
+ anchors.push(planned);
47335
+ }
47336
+ const place = (x) => `${x.path}\x00${x.role}\x00${x.commit}\x00${x.lines ? [x.lines[0], Math.max(...x.lines)] : ""}`;
47337
+ const covered = new Set(anchors.filter((x) => !x.symbol && !fallbacks.has(x)).map(place));
47338
+ for (const x of [...fallbacks]) {
47339
+ if (!covered.has(place(x))) {
47340
+ covered.add(place(x));
47341
+ continue;
47342
+ }
47343
+ problems.push(`${key}: another path-only anchor on ${x.path} already covers it; left out`);
47344
+ anchors.splice(anchors.indexOf(x), 1);
47214
47345
  }
47215
47346
  const aliases = [...new Set(u.aliases.map((a) => a.trim()))];
47216
47347
  const bad = aliases.filter((a) => !a || a.length > 40);
@@ -47332,12 +47463,13 @@ ${checked.errors.map((e) => ` ${e}`).join(`
47332
47463
  }).onConflict((oc) => oc.doNothing()).execute();
47333
47464
  }
47334
47465
  for (const a of p.anchors) {
47335
- const at = a.lines ? null : a.symbol ? locate(target.root, a.path, a.symbol) : null;
47466
+ const symbol2 = a.symbol && !masksSymbol(target.root, a.path, a.symbol) ? a.symbol : undefined;
47467
+ const at = a.lines ? null : symbol2 ? locate(target.root, a.path, symbol2) : null;
47336
47468
  const lines = a.lines ?? (at ? [at.line, at.line] : null);
47337
47469
  await trx.insertInto("unit_anchor").values({
47338
47470
  unit_id: id,
47339
47471
  path: a.path,
47340
- symbol: a.symbol ?? null,
47472
+ symbol: symbol2 ?? null,
47341
47473
  commit_sha: a.commit ?? null,
47342
47474
  line_start: lines?.[0] ?? null,
47343
47475
  line_end: lines ? Math.max(lines[0], lines[1]) : null,
@@ -47535,15 +47667,24 @@ function readExcerpt(root, file2) {
47535
47667
  } catch {
47536
47668
  throw new Error(`${p} is not UTF-8 text`);
47537
47669
  }
47538
- const starts = [0];
47670
+ const starts2 = [0];
47539
47671
  for (let i = 0;i < buf.length; i++)
47540
47672
  if (buf[i] === 10 && i + 1 < buf.length)
47541
- starts.push(i + 1);
47673
+ starts2.push(i + 1);
47542
47674
  const [a, b] = file2.lines;
47543
- if (a > b || b > starts.length)
47544
- throw new Error(`${p} has ${starts.length} lines; lines ${a}-${b} are not in it`);
47545
- const text2 = buf.subarray(starts[a - 1], b < starts.length ? starts[b] : buf.length).toString("utf8");
47546
- return { path: p, commit, blob, size, lines: [a, b], text: text2 };
47675
+ if (a > b || b > starts2.length)
47676
+ throw new Error(`${p} has ${starts2.length} lines; lines ${a}-${b} are not in it`);
47677
+ const from = starts2[a - 1] ?? 0;
47678
+ const to = b < starts2.length ? starts2[b] ?? buf.length : buf.length;
47679
+ if (privateKeyRanges(buf.toString("utf8")).some(([s, e]) => s < to && e > from && (s < from || e > to)))
47680
+ throw new Error(`${p} lines ${a}-${b} are inside a private key; cite lines outside it`);
47681
+ const raw = buf.subarray(from, to).toString("utf8");
47682
+ const text2 = mask(raw);
47683
+ const before = buf.subarray(0, from).toString("utf8");
47684
+ const after = buf.subarray(to).toString("utf8");
47685
+ if (mask(before + raw) !== mask(before) + text2 || mask(raw + after) !== text2 + mask(after))
47686
+ throw new Error(`${p} lines ${a}-${b} cut through text Sphica masks; cite more lines around it`);
47687
+ return { path: p, commit, blob, size, lines: [a, b], raw, text: text2 };
47547
47688
  }
47548
47689
  var locate3 = (body, q) => {
47549
47690
  const at = Buffer.from(body, "utf8").indexOf(Buffer.from(q, "utf8"));
@@ -47607,8 +47748,10 @@ async function checkGlean(db, target, raw) {
47607
47748
  if (!target.root)
47608
47749
  throw new Error("the repository is not known");
47609
47750
  excerpt = readExcerpt(target.root, op.file);
47610
- if (!locate3(excerpt.text, op.quote))
47751
+ if (!locate3(excerpt.raw, op.quote))
47611
47752
  errors3.push(`${what}: quote not found in ${excerpt.path} lines ${op.file.lines.join("-")}`);
47753
+ else if (!quoteSpan(excerpt.raw, excerpt.text, op.quote))
47754
+ errors3.push(`${what}: the quote also appears in text Sphica masks; quote a longer or different part`);
47612
47755
  } catch (e) {
47613
47756
  errors3.push(`${what}: ${e.message}`);
47614
47757
  }
@@ -47629,6 +47772,10 @@ async function checkGlean(db, target, raw) {
47629
47772
  }
47630
47773
  if (op.op === "anchor" && !repoPath(op.path))
47631
47774
  errors3.push(`${what}: the path is not inside the repository`);
47775
+ const pinned = op.op === "anchor" ? op : op.op === "replace_anchor" ? op.to : null;
47776
+ const pinnedPath = pinned && repoPath(pinned.path);
47777
+ if (pinned?.symbol && pinnedPath && masksSymbol(target.root, pinnedPath, pinned.symbol))
47778
+ errors3.push(`${what}: the symbol is text Sphica masks; anchor a name, not a key`);
47632
47779
  if (op.op === "anchor" && op.commit) {
47633
47780
  const rel2 = repoPath(op.path);
47634
47781
  if (rel2 && !(target.root && commitHolds(target.root, op.commit, rel2)))
@@ -47718,24 +47865,25 @@ async function checkGlean(db, target, raw) {
47718
47865
  }
47719
47866
  async function excerptSource(trx, projectId2, x) {
47720
47867
  const external2 = `file:${x.path}@${x.blob}#L${x.lines[0]}-${x.lines[1]}`;
47721
- const found = await trx.selectFrom("source").select("id").where("project_id", "=", projectId2).where("kind", "=", "file_excerpt").where("external_id", "=", external2).executeTakeFirst();
47722
- if (found)
47868
+ const hash2 = sha256(x.text);
47869
+ const found = await trx.selectFrom("source").select(["id", "revision", "content_hash"]).where("project_id", "=", projectId2).where("kind", "=", "file_excerpt").where("external_id", "=", external2).orderBy("revision", "desc").executeTakeFirst();
47870
+ if (found?.content_hash.equals(hash2))
47723
47871
  return found.id;
47724
47872
  const now = iso(Date.now());
47725
- const partial2 = Buffer.byteLength(x.text, "utf8") !== x.size;
47726
47873
  const row = await trx.insertInto("source").values({
47727
47874
  project_id: projectId2,
47728
47875
  kind: "file_excerpt",
47729
47876
  artifact: `file:${x.path}`,
47730
47877
  external_id: external2,
47731
- revision: 1,
47878
+ revision: (found?.revision ?? 0) + 1,
47732
47879
  author_kind: "person",
47733
47880
  created_at: now,
47734
47881
  captured_at: now,
47735
47882
  text: x.text,
47736
- truncated: partial2 ? 1 : 0,
47883
+ truncated: bytes(x.raw) !== x.size ? 1 : 0,
47884
+ redacted: x.text !== x.raw ? 1 : 0,
47737
47885
  original_bytes: x.size,
47738
- content_hash: sha256(x.text),
47886
+ content_hash: hash2,
47739
47887
  path: x.path,
47740
47888
  line_start: x.lines[0],
47741
47889
  line_end: x.lines[1],
@@ -47785,7 +47933,10 @@ ${c.errors.map((e) => ` ${e}`).join(`
47785
47933
  touched.set(p.unitId, op.unit);
47786
47934
  if (op.op === "add_evidence") {
47787
47935
  const s = p.excerpt ? { id: await excerptSource(trx, target.projectId, p.excerpt), text: p.excerpt.text } : await trx.selectFrom("source").select(["id", "text"]).where("id", "=", Number(op.source?.slice(1))).executeTakeFirstOrThrow();
47788
- const at = locate3(s.text, op.quote) ?? [0, 0];
47936
+ const at = (p.excerpt ? quoteSpan(p.excerpt.raw, s.text, op.quote) : locate3(s.text, op.quote)) ?? [
47937
+ 0,
47938
+ 0
47939
+ ];
47789
47940
  await trx.insertInto("unit_evidence").values({
47790
47941
  unit_id: p.unitId,
47791
47942
  source_id: s.id,
@@ -47812,11 +47963,13 @@ ${c.errors.map((e) => ` ${e}`).join(`
47812
47963
  changed2.push(`${op.unit}: adopted`);
47813
47964
  } else if (op.op === "anchor" || op.op === "replace_anchor") {
47814
47965
  const to = op.op === "anchor" ? op : op.to;
47815
- const at = to.symbol ? locate(target.root, repoPath(to.path) ?? to.path, to.symbol) : null;
47966
+ const rel = repoPath(to.path) ?? to.path;
47967
+ const symbol2 = to.symbol && !masksSymbol(target.root, rel, to.symbol) ? to.symbol : null;
47968
+ const at = symbol2 ? locate(target.root, rel, symbol2) : null;
47816
47969
  const added = await trx.insertInto("unit_anchor").values({
47817
47970
  unit_id: p.unitId,
47818
- path: repoPath(to.path) ?? to.path,
47819
- symbol: to.symbol ?? null,
47971
+ path: rel,
47972
+ symbol: symbol2,
47820
47973
  commit_sha: op.op === "anchor" ? op.commit ?? null : null,
47821
47974
  line_start: at?.line ?? null,
47822
47975
  line_end: at?.line ?? null,
package/dist/mcp.js CHANGED
@@ -45821,8 +45821,7 @@ function readText(root, rel) {
45821
45821
  function findSymbol(text, symbol2) {
45822
45822
  const re = new RegExp(`(?<![\\w$])${literal3(symbol2)}(?![\\w$])`);
45823
45823
  const lines = text.split(/\r?\n/);
45824
- const i = lines.findIndex((l) => re.test(l));
45825
- return i < 0 ? null : { line: i + 1, excerpt: (lines[i] ?? "").trim().slice(0, 200) };
45824
+ return { lines, i: lines.findIndex((l) => re.test(l)) };
45826
45825
  }
45827
45826
  function checkAnchor(root, a) {
45828
45827
  if (!root)
@@ -45834,12 +45833,12 @@ function checkAnchor(root, a) {
45834
45833
  return { state: "unknown", line: null };
45835
45834
  if (!a.symbol)
45836
45835
  return { state: "located", line: a.line_start };
45837
- const found = findSymbol(text, a.symbol);
45838
- if (!found)
45836
+ const { i } = findSymbol(text, a.symbol);
45837
+ if (i < 0)
45839
45838
  return { state: "missing", line: null };
45840
45839
  return {
45841
- state: a.line_start === null || a.line_start === found.line ? "located" : "moved",
45842
- line: found.line
45840
+ state: a.line_start === null || a.line_start === i + 1 ? "located" : "moved",
45841
+ line: i + 1
45843
45842
  };
45844
45843
  }
45845
45844
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sphica",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
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",
@@ -67,9 +67,9 @@ read tools `search` and `read`. Pass the repository root as `cwd` to every tool.
67
67
 
68
68
  | Op | What it does |
69
69
  |---|---|
70
- | `add_evidence` | Cites a `source` ref or a committed `file` (path, commit, lines). `role` as in trace. When the owner reports what someone else said, add `reported_speaker`: it stays the owner's report, never that person's statement or an adoption |
70
+ | `add_evidence` | Cites a `source` ref or a committed `file` (path, commit, lines). `role` as in trace. When the owner reports what someone else said, add `reported_speaker`: it stays the owner's report, never that person's statement or an adoption. A file excerpt is stored with keys masked, so quote words around a key, never the key, and cite whole lines around it: a range that cuts through a private key, or leaves a key's name outside, is refused |
71
71
  | `adopt` | The owner's (or a maintainer's) words that settle a decision or constraint. "Kimura said it was agreed" is not adoption; the owner saying "let's make it final" is |
72
- | `anchor` / `replace_anchor` | Adds a code location, or replaces one whose code moved (`from` and `to`, citing the owner's words); the old one is kept as history. A replacement carries no commit, so an implementation whose proof was the replaced anchor goes back to candidate: add an `anchor` op with `commit` in the same batch to keep it active |
72
+ | `anchor` / `replace_anchor` | Adds a code location, or replaces one whose code moved (`from` and `to`, citing the owner's words); the old one is kept as history. A replacement carries no commit, so an implementation whose proof was the replaced anchor goes back to candidate: add an `anchor` op with `commit` in the same batch to keep it active. A `symbol` that is a key or a value Sphica masks is refused: anchor a name, or the path alone |
73
73
  | `retract_evidence` / `retract_adoption` | Marks a link mistaken, citing the owner's words (`reason_source`, `reason_quote`). When the record cites the same source more than once, add `quote` to say which one. It is kept as history, and the record is judged again |
74
74
  | `resolve_conflict` | Ends an unresolved conflict between `unit` and `with`, citing the owner's words (`reason_source`, `reason_quote`). Until then neither record is shown on its own |
75
75
  | `withdraw` | Withdraws a record the owner says no longer holds, citing the owner's words |
@@ -53,7 +53,7 @@ and only by saying so: "we rejected yarn", "let's keep SQLite". check refuses th
53
53
  ## What to record
54
54
 
55
55
  - Options someone proposed and a maintainer declined, with the reason given: a `decision` whose rejected option carries its `why` and evidence
56
- - What the pull request implemented: an `implementation` citing the commit message or the body, with an `evidence` anchor when a path and symbol are named
56
+ - What the pull request implemented: an `implementation` citing the commit message or the body, with an `evidence` anchor when a path and symbol are named (a `symbol` that is a key or a value Sphica masks is dropped, keeping the path)
57
57
  - The problem the closed issue describes, when it states a rule ("exports must never include private notes"): a `constraint` citing the issue body
58
58
  - Review findings that led to a change (`finding`), paths tried and abandoned (`dead_end`), questions left open (`question`)
59
59
 
@@ -80,7 +80,7 @@ The `"..."` stands for the other language's words: in this example, `"データ
80
80
  | `evidence` | Required. `source` is a ref from context, `quote` is copied **exactly** from that message (a phrase is enough). `role`: `states`, `proposes`, `rejects`, `explains`, `implements`. When the owner reports what someone else said, add `reported_speaker` |
81
81
  | `options` | Options compared, with `outcome` `chosen` / `rejected` / `deferred` / `proposed` and the `why` given. Evidence is optional per option |
82
82
  | `adoption` | Decisions and constraints only: the owner's words that settle it. **Only owner messages adopt.** The AI proposing something and the owner not objecting is not adoption; leave it out and the record stays a candidate |
83
- | `anchors` | Only where the record has a code location: `path` relative to the repository root, `symbol` when there is one, `role` `applies_to` (where it applies) or `evidence` (code that shows it was done; add `commit` when known). When an adopted decision or constraint governs how one existing code location behaves (keeping it as it is included), give it `applies_to` there, even if this work did not change it: delivery shows it when that file is read or edited. Confirm the path in the repository; do not infer one from a broad topic, and leave it unanchored when several places are plausible. `no_code_surface` may say why there is none |
83
+ | `anchors` | Only where the record has a code location: `path` relative to the repository root, `symbol` when there is one, `role` `applies_to` (where it applies) or `evidence` (code that shows it was done; add `commit` when known). When an adopted decision or constraint governs how one existing code location behaves (keeping it as it is included), give it `applies_to` there, even if this work did not change it: delivery shows it when that file is read or edited. Confirm the path in the repository; do not infer one from a broad topic, and leave it unanchored when several places are plausible. `no_code_surface` may say why there is none. A `symbol` must be a name in the code, never a key or a value Sphica masks: such a symbol is dropped and the anchor keeps only its path (save reports it) |
84
84
  | `aliases` | 8 to 12 short search words in **both Japanese and English** a later reader might type: synonyms, the other language's words, abbreviations. Search only; never evidence. Not broad words that match everything (`code`, `fix`, `update`) |
85
85
  | `supersedes` | The key of a live record this one replaces (context lists them). The old one is marked superseded, never deleted |
86
86
  | `conflicts` | Keys of live records this one contradicts without replacing them. Both are held back from automatic injection until resolved |