@vib795/agent-memory 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,6 +124,89 @@ Two things are deliberately dropped on the way through:
124
124
  Importing the same file twice updates rather than duplicates, so re-running after a
125
125
  change is safe.
126
126
 
127
+ ### Sharing an export with another person
128
+
129
+ Scope decides *whose* knowledge travels. It does not decide whether the file can be
130
+ handed to a person, and those are separate questions. Every export is scanned for
131
+ personal identifiers on the way out, and the file carries a receipt naming the rule
132
+ set that ran and what it took.
133
+
134
+ ```
135
+ $ agent-memory export --out carry.json
136
+ Exported 41 global-scope notes to carry.json.
137
+ rule set: pii/v1
138
+ redacted: 6 phone, 2 payment-card, 1 ssn
139
+ withheld: 1 note
140
+ oncall-roster — 8 identifiers (phone)
141
+
142
+ Structured identifiers only. Have an agent review this file for names and
143
+ identifying prose before sharing it outside your team.
144
+ ```
145
+
146
+ Notes are **redacted in place**, so the knowledge survives and the identifier does
147
+ not. A note that is *mostly* identifiers — a roster, a contact sheet — is withheld
148
+ whole and named in the receipt, because a redacted skeleton is useless to the reader
149
+ and still re-identifiable from the structure that remains.
150
+
151
+ **Two layers, and the markers tell you which one acted.**
152
+
153
+ | | Runs at | Asks | Marker |
154
+ |---|---|---|---|
155
+ | `src/redact.js` | capture | could this authenticate as someone? | `<redacted:kind>` |
156
+ | `src/pii.js` | export | could this identify a person? | `[redacted:kind]` |
157
+
158
+ They disagree about your own email address on purpose. Capture keeps it, because it
159
+ is already public in every commit you have ever pushed. Export removes it, because it
160
+ is not yours to hand to someone else alongside a hundred notes about how a client
161
+ works.
162
+
163
+ **What the deterministic layer does not catch.** Names, job titles, postal addresses
164
+ and identifying prose have no shape to match on. They are language, and the honest
165
+ place to judge them is the model already in the conversation — so the receipt says so
166
+ instead of letting a pattern imply a completeness it does not have. Ask your agent to
167
+ review the export before it leaves your team.
168
+
169
+ ## Evals and governance
170
+
171
+ Disclosure control is a claim until it is measured, so it is measured on every run of
172
+ `npm test`:
173
+
174
+ ```
175
+ [pii/v1] recall by kind
176
+ email 2/2
177
+ ip-address 2/2
178
+ payment-card 3/3
179
+ phone 4/4
180
+ ssn 2/2
181
+ [pii/v1] precision 15/15 on the negative corpus
182
+ [pii/v1] 3 known gaps pass through by design: person name, postal address, bare internal identifier
183
+ ```
184
+
185
+ The practices behind that number, which are the transferable part:
186
+
187
+ - **Both error directions are costed, not just the obvious one.** A miss discloses one
188
+ identifier. A false hit shreds a sentence, and a tool that mangles ordinary prose
189
+ gets switched off, which discloses everything. Recall is held at 1.0 because no
190
+ number of leaked identifiers is acceptable; precision is held at 1.0 against a
191
+ corpus built to tempt each detector with what it most resembles — a git SHA for a
192
+ card, a four-part version for an address, a date for a phone number.
193
+ - **Gaps are asserted as gaps.** `test/pii.eval.test.js` proves that names and
194
+ addresses pass through, so closing one has to be a deliberate act rather than a
195
+ silent change in what an old receipt meant.
196
+ - **The eval is checked for teeth.** Removing the Luhn validation drops precision to
197
+ 14/15 and fails the build. A green suite that cannot go red is decoration.
198
+ - **Corpora are synthetic.** Reserved ranges only — RFC 5737 addresses, the card
199
+ networks' published test numbers — so the file that tests for leaks is not one.
200
+ - **Rule sets are versioned.** A receipt means something only against the rules that
201
+ produced it, so `pii/v1` is stamped into every export and changing a detector
202
+ changes the name.
203
+
204
+ For sharing outside your team: keep the receipt with the file, re-export rather than
205
+ hand-editing a file you already sent, and treat `--scope all` as a decision with your
206
+ name on it. What this is not: it is not encryption, access control, or a DLP product.
207
+ It is one command that refuses to hand over identifiers, and it says exactly what it
208
+ checked.
209
+
127
210
  ## Engagements
128
211
 
129
212
  If you work for more than one client on one machine, knowledge from one of them is
@@ -389,7 +472,7 @@ is generated state, and the committed value is only a placeholder.
389
472
 
390
473
  Needs Node 22.5 or newer; `doctor` says so plainly if the version is too old.
391
474
 
392
- Run `npm test` for the suite (78 tests, no dependencies). CI runs it on Linux,
475
+ Run `npm test` for the suite (87 tests, no dependencies). CI runs it on Linux,
393
476
  macOS and Windows across Node 22 and 24, and separately installs the packed tarball
394
477
  and exercises it end to end on all three.
395
478
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vib795/agent-memory",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Durable cross-repo knowledge graph for GitHub Copilot and Claude Code. Markdown source of truth, disposable SQLite index, zero runtime dependencies.",
5
5
  "keywords": [
6
6
  "github-copilot",
package/src/cli.js CHANGED
@@ -17,6 +17,7 @@ import { setup as runSetup, unlinkSkills, danglingSkillLinks, SKILLS } from './s
17
17
  import { detectTargets, installableTargets } from './targets.js';
18
18
  import { join, dirname } from 'node:path';
19
19
  import { atomicWrite } from './atomic.js';
20
+ import { redactNodeForExport, buildReceipt, renderReceipt } from './pii.js';
20
21
 
21
22
  /**
22
23
  * One process, one answer.
@@ -581,6 +582,10 @@ const EXPORT_SCOPES = ['global', 'repo', 'all'];
581
582
  * `captured_sha` is deliberately dropped. It names a commit that does not exist
582
583
  * anywhere else, and a staleness signal that cannot be checked is worse than none:
583
584
  * it would either read as current forever or claim the history was rewritten.
585
+ *
586
+ * Personal identifiers are removed on the way out, and the export carries a receipt
587
+ * saying which rule set ran and what it took. Scope decides whose knowledge travels;
588
+ * the receipt decides whether it can be handed to a person.
584
589
  */
585
590
  function cmdExport(opts) {
586
591
  const scope = opts.scope === true ? 'global' : (opts.scope ?? 'global');
@@ -612,21 +617,42 @@ function cmdExport(opts) {
612
617
  source: n.source ?? 'manual',
613
618
  }));
614
619
 
615
- const payload = `${JSON.stringify({ nodes }, null, 2)}\n`;
620
+ // Export is the only path by which the store leaves this machine, which makes it
621
+ // the only place disclosure control belongs. It is a different question from the
622
+ // one capture-time redaction answers, and src/pii.js says why at length.
623
+ const results = nodes.map((n) => ({ id: n.id, ...redactNodeForExport(n) }));
624
+ const clean = results.filter((r) => !r.withheld).map((r) => r.node);
625
+ const receipt = buildReceipt(results, { scanned: nodes.length });
626
+ const payload = `${JSON.stringify({ nodes: clean, redaction: receipt }, null, 2)}\n`;
627
+
616
628
  if (typeof opts.out === 'string') {
617
629
  atomicWrite(opts.out, payload);
618
630
  return {
619
631
  ok: true,
620
- exported: nodes.length,
632
+ exported: clean.length,
633
+ withheld: receipt.withheld.length,
621
634
  scope,
622
635
  out: opts.out,
623
- text: `Exported ${nodes.length} ${scope}-scope note${nodes.length === 1 ? '' : 's'} to ${opts.out}.`,
636
+ redaction: receipt,
637
+ text: [
638
+ `Exported ${clean.length} ${scope}-scope note${clean.length === 1 ? '' : 's'} to ${opts.out}.`,
639
+ ...renderReceipt(receipt),
640
+ ].join('\n'),
624
641
  };
625
642
  }
626
- // Straight to stdout so it pipes, with the count on stderr where it will not
627
- // corrupt the document.
628
- process.stderr.write(`${nodes.length} ${scope}-scope notes\n`);
629
- return { ok: true, exported: nodes.length, scope, nodes, text: payload.trimEnd() };
643
+ // Straight to stdout so it pipes, with the count and the receipt on stderr where
644
+ // they will not corrupt the document.
645
+ process.stderr.write(`${clean.length} ${scope}-scope notes\n`);
646
+ for (const line of renderReceipt(receipt)) process.stderr.write(`${line}\n`);
647
+ return {
648
+ ok: true,
649
+ exported: clean.length,
650
+ withheld: receipt.withheld.length,
651
+ scope,
652
+ nodes: clean,
653
+ redaction: receipt,
654
+ text: payload.trimEnd(),
655
+ };
630
656
  }
631
657
 
632
658
  /**
package/src/pii.js ADDED
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Disclosure control for export.
3
+ *
4
+ * This is not the same job as src/redact.js and must not be folded into it. That one
5
+ * runs at capture and asks "could this authenticate as someone?" — its threat is a
6
+ * credential reaching disk, and it deliberately preserves the operator's own email
7
+ * because that address is already public in every commit they have ever pushed.
8
+ *
9
+ * This one runs at export and asks "could this identify a person?" — its threat is a
10
+ * human reading the file. The operator's own email is the clearest case of the
11
+ * difference: safe to store, not yours to hand to someone else along with a hundred
12
+ * notes about how their client works.
13
+ *
14
+ * Two layers, and only one of them lives here. Structured identifiers have shape, so
15
+ * they are matched and validated deterministically, which makes them testable and the
16
+ * result reproducible from a named rule set. Names, addresses and identifying prose
17
+ * have no shape; they are language, and the honest place to judge them is the model
18
+ * already sitting in the conversation. The receipt says so out loud rather than
19
+ * letting a regex imply a completeness it does not have.
20
+ */
21
+
22
+ /** Bump when a detector changes. A receipt is only reproducible against its rule set. */
23
+ export const RULE_SET = 'pii/v1';
24
+
25
+ /** Detections in one note before the note is withheld whole rather than redacted. */
26
+ export const DEFAULT_WITHHOLD_COUNT = 8;
27
+
28
+ /** Share of the text that may be replaced before redaction stops being meaningful. */
29
+ export const DEFAULT_WITHHOLD_RATIO = 0.3;
30
+
31
+ /** Luhn, the checksum every payment card carries. Turns a digit run into a card. */
32
+ function luhnValid(digits) {
33
+ let sum = 0;
34
+ let double = false;
35
+ for (let i = digits.length - 1; i >= 0; i -= 1) {
36
+ let d = digits.charCodeAt(i) - 48;
37
+ if (d < 0 || d > 9) return false;
38
+ if (double) {
39
+ d *= 2;
40
+ if (d > 9) d -= 9;
41
+ }
42
+ sum += d;
43
+ double = !double;
44
+ }
45
+ return sum % 10 === 0;
46
+ }
47
+
48
+ /**
49
+ * A US SSN has structure the Social Security Administration never issues.
50
+ * Checking it is what separates an SSN from any other nine digits with dashes.
51
+ */
52
+ function ssnValid(area, group, serial) {
53
+ if (area === '000' || area === '666' || area[0] === '9') return false;
54
+ if (group === '00' || serial === '0000') return false;
55
+ return true;
56
+ }
57
+
58
+ function octetsValid(ip) {
59
+ const parts = ip.split('.');
60
+ return parts.length === 4 && parts.every((p) => p.length <= 3 && Number(p) <= 255);
61
+ }
62
+
63
+ /**
64
+ * Addresses that describe infrastructure rather than a person.
65
+ *
66
+ * The privacy interest in an IP is that a routable one, with a timestamp, can be put
67
+ * to a subscriber by whoever holds the logs. A pod CIDR cannot. Capture-time
68
+ * redaction already removes these ranges as infrastructure, so scrubbing them again
69
+ * here would only cost meaning in ordinary engineering notes.
70
+ */
71
+ function reservedV4(ip) {
72
+ const [a, b] = ip.split('.').map(Number);
73
+ if (a === 0 || a === 10 || a === 127 || a >= 224) return true;
74
+ if (a === 169 && b === 254) return true;
75
+ if (a === 172 && b >= 16 && b <= 31) return true;
76
+ if (a === 192 && b === 168) return true;
77
+ return false;
78
+ }
79
+
80
+ /**
81
+ * Every detector is precision-first.
82
+ *
83
+ * A false negative leaks one identifier. A false positive shreds a sentence, and a
84
+ * tool that mangles ordinary prose gets switched off, which leaks everything. So each
85
+ * pattern here carries a checksum, a structural rule, or a separator requirement that
86
+ * ordinary technical writing does not satisfy. Bare digit runs are left to the
87
+ * semantic pass rather than guessed at, because a git SHA, an order number and a
88
+ * national ID are the same characters.
89
+ *
90
+ * Order is load-bearing. A grouped card number is also a valid phone shape, so cards
91
+ * are consumed before phones get a chance; reordering this array silently changes
92
+ * what the labels say happened.
93
+ */
94
+ const DETECTORS = [
95
+ {
96
+ kind: 'email',
97
+ // No self-address exemption. That exemption is correct at capture and wrong here.
98
+ re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
99
+ },
100
+ {
101
+ kind: 'ssn',
102
+ // Only the delimited form. Nine adjacent digits are too many other things.
103
+ re: /(?<![\w-])(\d{3})-(\d{2})-(\d{4})(?![\w-])/g,
104
+ validate: (m) => ssnValid(m[1], m[2], m[3]),
105
+ },
106
+ {
107
+ kind: 'payment-card',
108
+ re: /(?<![\w-])(?:\d[ -]?){12,18}\d(?![\w-])/g,
109
+ validate: (m) => {
110
+ const digits = m[0].replace(/\D/g, '');
111
+ return digits.length >= 13 && digits.length <= 19 && luhnValid(digits);
112
+ },
113
+ },
114
+ {
115
+ kind: 'phone',
116
+ // Requires separators or a country code, so `10.2.4` and `v1.2.3` cannot match,
117
+ // and a bare run of ten digits is left alone on purpose.
118
+ re: /(?<![\w-])(?:\+\d{1,3}[\s.-]?)?(?:\(\d{2,4}\)|\d{2,4})[\s.-]\d{2,4}[\s.-]\d{2,5}(?:[\s.-]\d{1,5})?(?![\w-])/g,
119
+ validate: (m) => {
120
+ const digits = m[0].replace(/\D/g, '');
121
+ if (digits.length < 10 || digits.length > 15) return false;
122
+ // One repeated digit is a placeholder or a masked field, never a number.
123
+ if (/^(\d)\1+$/.test(digits)) return false;
124
+ // A date reads as three separated numbers too. Reject anything shaped like one
125
+ // rather than deciding by punctuation, which varies by locale.
126
+ return !/^\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}$/.test(m[0].trim());
127
+ },
128
+ },
129
+ {
130
+ kind: 'ip-address',
131
+ // Capture-time redaction removes RFC1918 ranges as infrastructure. A routable
132
+ // address is the one that can identify a subscriber, and it survives to here.
133
+ re: /(?<![\w.])\d{1,3}(?:\.\d{1,3}){3}(?![\w.])/g,
134
+ validate: (m) => {
135
+ if (!octetsValid(m[0])) return false;
136
+ // Four single-digit groups is a four-part version string (6.0.1.2) far more
137
+ // often than an address. A deliberate recall gap, measured in the eval and
138
+ // covered by the semantic pass rather than paid for in shredded prose.
139
+ if (/^\d(\.\d){3}$/.test(m[0])) return false;
140
+ return !reservedV4(m[0]);
141
+ },
142
+ },
143
+ ];
144
+
145
+ /**
146
+ * Scan text, returning the redacted form and what fired.
147
+ *
148
+ * Fail-closed in the same sense as capture: a non-string is a programming error, and
149
+ * returning it unscanned would be the one outcome worse than throwing.
150
+ */
151
+ export function scanText(text) {
152
+ if (typeof text !== 'string') {
153
+ throw new TypeError('scanText() requires a string; refusing to export unscanned content');
154
+ }
155
+ const counts = new Map();
156
+ let redactedChars = 0;
157
+ let out = text;
158
+
159
+ for (const { kind, re, validate } of DETECTORS) {
160
+ out = out.replace(re, (...args) => {
161
+ const match = args.slice(0, -2);
162
+ const whole = match[0];
163
+ if (validate && !validate(match)) return whole;
164
+ counts.set(kind, (counts.get(kind) || 0) + 1);
165
+ redactedChars += whole.length;
166
+ return `[redacted:${kind}]`;
167
+ });
168
+ }
169
+
170
+ return {
171
+ text: out,
172
+ findings: [...counts.entries()].map(([kind, count]) => ({ kind, count })),
173
+ redactedChars,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Clean one node for export, or decide it cannot be cleaned.
179
+ *
180
+ * Withholding is not a fallback for a broken redactor; it answers a different
181
+ * question. A note that is mostly identifiers is a contact record rather than
182
+ * knowledge, and its redacted skeleton is both useless to the reader and still
183
+ * re-identifiable from the structure that remains. Better to name it in the receipt
184
+ * and leave it behind.
185
+ */
186
+ export function redactNodeForExport(node, opts = {}) {
187
+ const withholdCount = opts.withholdCount ?? DEFAULT_WITHHOLD_COUNT;
188
+ const withholdRatio = opts.withholdRatio ?? DEFAULT_WITHHOLD_RATIO;
189
+
190
+ const clean = { ...node };
191
+ const counts = new Map();
192
+ let redactedChars = 0;
193
+ let textChars = 0;
194
+
195
+ for (const field of ['title', 'body']) {
196
+ if (typeof clean[field] !== 'string') continue;
197
+ textChars += clean[field].length;
198
+ const r = scanText(clean[field]);
199
+ clean[field] = r.text;
200
+ redactedChars += r.redactedChars;
201
+ for (const f of r.findings) counts.set(f.kind, (counts.get(f.kind) || 0) + f.count);
202
+ }
203
+
204
+ const findings = [...counts.entries()].map(([kind, count]) => ({ kind, count }));
205
+ const total = findings.reduce((n, f) => n + f.count, 0);
206
+ const ratio = textChars ? redactedChars / textChars : 0;
207
+
208
+ if (total >= withholdCount || ratio > withholdRatio) {
209
+ return {
210
+ node: null,
211
+ findings,
212
+ withheld: true,
213
+ reason:
214
+ total >= withholdCount ? `${total} identifiers` : `${Math.round(ratio * 100)}% of the text`,
215
+ };
216
+ }
217
+ return { node: clean, findings, withheld: false, reason: null };
218
+ }
219
+
220
+ /**
221
+ * Fold per-note results into the receipt that ships with the export.
222
+ *
223
+ * The receipt is the governance artifact. An export without one is a file of unknown
224
+ * provenance, and the reason to write it down is that "we redact PII" is a claim
225
+ * while "pii/v1 removed four emails and withheld two notes" is evidence.
226
+ */
227
+ export function buildReceipt(results, { scanned }) {
228
+ const counts = new Map();
229
+ const withheld = [];
230
+ for (const r of results) {
231
+ for (const f of r.findings) counts.set(f.kind, (counts.get(f.kind) || 0) + f.count);
232
+ if (r.withheld) {
233
+ withheld.push({ id: r.id, reason: r.reason, kinds: r.findings.map((f) => f.kind) });
234
+ }
235
+ }
236
+ const redactions = [...counts.entries()]
237
+ .map(([kind, count]) => ({ kind, count }))
238
+ .sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
239
+
240
+ return {
241
+ ruleSet: RULE_SET,
242
+ scanned,
243
+ exported: scanned - withheld.length,
244
+ redactions,
245
+ withheld,
246
+ // Stated in the artifact rather than only in the docs, because the person who
247
+ // reads the receipt is the person deciding whether to send the file.
248
+ semanticReviewRequired:
249
+ 'Structured identifiers only. Names, job titles, addresses and identifying prose are not detected here; have an agent review the export before sharing it outside your team.',
250
+ };
251
+ }
252
+
253
+ /** The receipt as lines, for the humans who will read it in a terminal. */
254
+ export function renderReceipt(receipt) {
255
+ const out = [`rule set: ${receipt.ruleSet}`];
256
+ if (receipt.redactions.length) {
257
+ out.push(`redacted: ${receipt.redactions.map((r) => `${r.count} ${r.kind}`).join(', ')}`);
258
+ } else {
259
+ out.push('redacted: nothing matched');
260
+ }
261
+ if (receipt.withheld.length) {
262
+ out.push(`withheld: ${receipt.withheld.length} note${receipt.withheld.length === 1 ? '' : 's'}`);
263
+ for (const w of receipt.withheld) out.push(` ${w.id} — ${w.reason} (${w.kinds.join(', ')})`);
264
+ }
265
+ out.push('');
266
+ out.push('Structured identifiers only. Have an agent review this file for names and');
267
+ out.push('identifying prose before sharing it outside your team.');
268
+ return out;
269
+ }