agentlas 0.9.4 → 0.9.6

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.
@@ -0,0 +1,310 @@
1
+ "use strict";
2
+
3
+ // `agentlas memory import <path> [--apply]` — Phase 1b.
4
+ //
5
+ // Promote legacy markdown memory into the shared agentlas.sqlite the desktop
6
+ // uses (same userData/agentlas.sqlite). Maps each substantive markdown section
7
+ // to a durable memory_entries row owned by the right layer of a team (member
8
+ // cell / orchestrator / shared team_memory) or a single agent. Mirrors the app's
9
+ // electron/memory/import.ts mapping so app and terminal agree. Dry-run by
10
+ // default (prints the preview table); --apply writes. Idempotent via a stable
11
+ // per-section source-hash sentinel embedded in evidence. Secrets are dropped.
12
+
13
+ const fs = require("node:fs");
14
+ const path = require("node:path");
15
+ const { createHash, randomUUID } = require("node:crypto");
16
+
17
+ const SOURCE_TOKEN_PREFIX = "mem-import:v1";
18
+ const MAX_FILES = 400;
19
+ const MAX_CONTENT = 4000;
20
+
21
+ // ── Section extraction (parity with electron/memory/import.ts) ───────────────
22
+ const TEMPLATE_LINE =
23
+ /^\s*[-*]?\s*(Add\b|Fill in\b|Example:|Record\b|Link\b|Prefer\b|Note\b|Which\b|Date:\s*$|Topic:\s*$|Decision:\s*$|Why:\s*$|Risk accepted:\s*$)/i;
24
+ const META_HEADING =
25
+ /^(How To Use|사용 규칙|사용법|구조|형식|사전 참조|누가 업데이트|Entries|Read First|Memory Rules|Recent Activity|Recently Touched|CROSS-REFERENCES|LEARNINGS LOG|ANTIPATTERNS|GOTCHAS|Repeated Failures|성공 패턴|발견 사항|안티패턴|에이전트 구성)/i;
26
+ const SHARED_HINT =
27
+ /(team[-_ ]?memory|team_memory|glossary|handoff|scope[-_ ]?ownership|common[-_ ]?safety|safety|tone|language|dossier|operating[-_ ]?architecture|memory[-_ ]?architecture|용어|공통|안전|인계|톤)/i;
28
+ const RISK_HINT = /(security|attack|vuln|bug|gotcha|incident|보안|취약|버그|사고)/i;
29
+ const ALWAYS_KEEP_HINT = /(team[-_ ]?memory|glossary|dossier|handoff|safety|scope[-_ ]?ownership|tone)/i;
30
+
31
+ // Minimal secret guard (a subset of the shared secret-patterns chokepoint) so a
32
+ // stray key in legacy notes never becomes a durable memory row.
33
+ const { looksSecret } = require("./agentlas-secret-patterns.cjs");
34
+
35
+ function substantiveBody(body) {
36
+ const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
37
+ const real = lines.filter((l) => !TEMPLATE_LINE.test(l) && l.replace(/^#+\s*/, "").length >= 12);
38
+ return real.join("\n");
39
+ }
40
+
41
+ function kindForHeading(heading, fallback) {
42
+ const m = /\[([A-Z_]+)\]/.exec(heading);
43
+ const tag = m ? m[1] : "";
44
+ if (["SUCCESS", "DISCOVERY"].includes(tag)) return "procedure";
45
+ if (["ANTIPATTERN", "GOTCHA", "SECURITY", "CONFIRMED", "REGRESSION", "FALSE_POSITIVE", "BLOCKED_BY_GUARD", "FAILURE"].includes(tag)) {
46
+ return "risk";
47
+ }
48
+ return fallback;
49
+ }
50
+
51
+ function keepSection(heading, body, alwaysKeep) {
52
+ if (META_HEADING.test(heading.replace(/\[[A-Z_]+\]\s*/, "").trim())) return false;
53
+ const hasDate = /\(20\d\d-\d\d-\d\d\)|Date:\s*20\d\d-\d\d-\d\d/.test(heading + "\n" + body);
54
+ const hasTag = /\[[A-Z_]+\]/.test(heading);
55
+ const real = substantiveBody(body);
56
+ if (alwaysKeep) return real.length >= 60;
57
+ if (hasDate || hasTag) return real.length >= 40;
58
+ return real.length >= 160;
59
+ }
60
+
61
+ function splitSections(md) {
62
+ const lines = md.split("\n");
63
+ const sections = [];
64
+ let cur = null;
65
+ for (const line of lines) {
66
+ if (/^#{2,3}\s+\S/.test(line)) {
67
+ if (cur) sections.push(cur);
68
+ cur = { heading: line.replace(/^#{2,3}\s+/, "").trim(), body: "" };
69
+ } else if (cur) {
70
+ cur.body += line + "\n";
71
+ }
72
+ }
73
+ if (cur) sections.push(cur);
74
+ return sections;
75
+ }
76
+
77
+ function splitDatedBullets(md) {
78
+ const idx = md.indexOf("- Date:");
79
+ if (idx === -1) return [];
80
+ return md
81
+ .slice(idx)
82
+ .split(/\n(?=- Date:)/)
83
+ .map((p) => p.trim())
84
+ .filter((p) => /Date:\s*20\d\d-\d\d-\d\d/.test(p))
85
+ .map((p) => {
86
+ const topic = /Topic:\s*(.+)/.exec(p);
87
+ const date = /Date:\s*(20\d\d-\d\d-\d\d)/.exec(p);
88
+ return { heading: `Decision ${date ? date[1] : ""}: ${topic ? topic[1].trim() : ""}`.trim(), body: p };
89
+ });
90
+ }
91
+
92
+ function normalizeToken(value) {
93
+ return String(value || "").toLowerCase().replace(/[^a-z0-9가-힣]+/g, " ").trim();
94
+ }
95
+
96
+ function resolveTarget(db, agentId) {
97
+ let firms = [];
98
+ try {
99
+ firms = db.prepare("SELECT id, ceo_agent_id, org_chart_json FROM firms").all();
100
+ } catch {
101
+ firms = [];
102
+ }
103
+ const firm =
104
+ firms.find((f) => f.id === agentId) ||
105
+ firms.find((f) => f.ceo_agent_id === agentId) ||
106
+ null;
107
+ if (!firm) return { agentId, kind: "agent", members: [] };
108
+ let chart = [];
109
+ try {
110
+ const parsed = JSON.parse(firm.org_chart_json);
111
+ if (Array.isArray(parsed)) chart = parsed;
112
+ } catch {
113
+ chart = [];
114
+ }
115
+ const members = chart
116
+ .filter((node) => node && node.agentId && node.agentId !== firm.ceo_agent_id)
117
+ .map((node) => ({ agentId: node.agentId, role: node.role || node.agentSlug, slug: node.agentSlug }));
118
+ return { agentId: firm.ceo_agent_id, kind: "team", members };
119
+ }
120
+
121
+ function matchMember(fileTokens, target) {
122
+ if (!target.members.length) return null;
123
+ let best = null;
124
+ for (const member of target.members) {
125
+ const roleTokens = normalizeToken(member.role).split(" ").filter((t) => t.length >= 3);
126
+ const slugTokens = normalizeToken(member.slug).split(" ").filter((t) => t.length >= 3);
127
+ const tokens = [...new Set([...roleTokens, ...slugTokens])];
128
+ let score = 0;
129
+ for (const token of tokens) if (fileTokens.includes(token)) score += token.length;
130
+ if (score > 0 && (!best || score > best.score)) best = { agentId: member.agentId, role: member.role, score };
131
+ }
132
+ return best ? { agentId: best.agentId, role: best.role } : null;
133
+ }
134
+
135
+ function decideOwner(relFile, target) {
136
+ const lower = relFile.toLowerCase();
137
+ const fileTokens = normalizeToken(relFile);
138
+ const alwaysKeep = ALWAYS_KEEP_HINT.test(lower);
139
+ if (SHARED_HINT.test(lower)) {
140
+ return {
141
+ scope: "team_memory",
142
+ ownerAgentId: null,
143
+ ownerLabel: "team_memory",
144
+ fallbackKind: /glossary|dossier|용어/i.test(lower) ? "fact" : "procedure",
145
+ alwaysKeep,
146
+ };
147
+ }
148
+ if (target.kind === "team") {
149
+ const member = matchMember(fileTokens, target);
150
+ if (member) {
151
+ return { scope: "agent_repo", ownerAgentId: member.agentId, ownerLabel: member.role, fallbackKind: RISK_HINT.test(lower) ? "risk" : "procedure", alwaysKeep };
152
+ }
153
+ return { scope: "agent_repo", ownerAgentId: target.agentId, ownerLabel: "orchestrator", fallbackKind: RISK_HINT.test(lower) ? "risk" : "decision", alwaysKeep };
154
+ }
155
+ return { scope: "agent_repo", ownerAgentId: target.agentId, ownerLabel: "agent", fallbackKind: RISK_HINT.test(lower) ? "risk" : "procedure", alwaysKeep };
156
+ }
157
+
158
+ function collectMarkdown(root) {
159
+ const stat = fs.statSync(root);
160
+ if (stat.isFile()) {
161
+ return /\.(md|markdown|mdx|txt)$/i.test(root) ? [{ abs: root, rel: path.basename(root) }] : [];
162
+ }
163
+ const out = [];
164
+ const walk = (dir) => {
165
+ if (out.length >= MAX_FILES) return;
166
+ let entries;
167
+ try {
168
+ entries = fs.readdirSync(dir, { withFileTypes: true });
169
+ } catch {
170
+ return;
171
+ }
172
+ for (const entry of entries) {
173
+ if (out.length >= MAX_FILES) return;
174
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
175
+ const abs = path.join(dir, entry.name);
176
+ if (entry.isDirectory()) walk(abs);
177
+ else if (entry.isFile() && /\.(md|markdown|mdx|txt)$/i.test(entry.name)) out.push({ abs, rel: path.relative(root, abs) });
178
+ }
179
+ };
180
+ walk(root);
181
+ return out.sort((a, b) => a.rel.localeCompare(b.rel));
182
+ }
183
+
184
+ function stableToken(relFile, heading) {
185
+ const hash = createHash("sha256").update(`${SOURCE_TOKEN_PREFIX}|${relFile}|${heading}`).digest("hex").slice(0, 16);
186
+ return `${SOURCE_TOKEN_PREFIX}:${hash}`;
187
+ }
188
+
189
+ function buildEntries(sourcePath, target) {
190
+ const built = [];
191
+ for (const { abs, rel } of collectMarkdown(sourcePath)) {
192
+ let md;
193
+ try {
194
+ md = fs.readFileSync(abs, "utf8");
195
+ } catch {
196
+ continue;
197
+ }
198
+ const owner = decideOwner(rel, target);
199
+ const sections = /decisions\.md$/i.test(rel) ? splitDatedBullets(md) : splitSections(md);
200
+ for (const sec of sections) {
201
+ if (!keepSection(sec.heading, sec.body, owner.alwaysKeep)) continue;
202
+ const bodyText = sec.body.replace(/\n{3,}/g, "\n\n").trim();
203
+ const content = `${sec.heading}\n${bodyText}`.trim().slice(0, MAX_CONTENT);
204
+ if (content.length < 40) continue;
205
+ built.push({
206
+ token: stableToken(rel, sec.heading),
207
+ relFile: rel,
208
+ heading: sec.heading,
209
+ content,
210
+ scope: owner.scope,
211
+ kind: kindForHeading(sec.heading, owner.fallbackKind),
212
+ ownerAgentId: owner.ownerAgentId,
213
+ ownerLabel: owner.ownerLabel,
214
+ redacted: looksSecret(content),
215
+ });
216
+ }
217
+ }
218
+ return built;
219
+ }
220
+
221
+ function existsByToken(db, token) {
222
+ try {
223
+ return Boolean(db.prepare("SELECT 1 FROM memory_entries WHERE evidence_json LIKE ? LIMIT 1").get(`%${token}%`));
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * cmdMemory — `agentlas memory <sub> ...`. Currently: import.
231
+ * @param {{db:any,args:string[],out:(s:string)=>void,fail:(s:string)=>void}} ctx
232
+ */
233
+ function cmdMemory(ctx) {
234
+ const { db, out, fail } = ctx;
235
+ const args = Array.isArray(ctx.args) ? ctx.args : [];
236
+ const sub = args[0] || "help";
237
+ if (sub === "help" || sub === "--help" || sub === "-h") {
238
+ out("usage: agentlas memory import <folder-or-file> --agent <agentId> [--apply]");
239
+ out(" dry-run by default (prints the preview table); --apply writes to the shared DB.");
240
+ return;
241
+ }
242
+ if (sub !== "import") return fail(`Unknown memory subcommand: ${sub} (import)`);
243
+
244
+ const apply = args.includes("--apply");
245
+ const agentIdx = args.indexOf("--agent");
246
+ const agentId = agentIdx >= 0 ? String(args[agentIdx + 1] || "").trim() : "";
247
+ const positional = args.slice(1).filter((a, i, arr) => a !== "--apply" && a !== "--agent" && arr[i - 1] !== "--agent");
248
+ const rawPath = positional[0];
249
+ if (!rawPath) return fail('usage: agentlas memory import <folder-or-file> --agent <agentId> [--apply]');
250
+ if (!agentId) return fail("memory import requires --agent <agentId> (the single agent or team to import into).");
251
+ const sourcePath = path.resolve(rawPath);
252
+ if (!fs.existsSync(sourcePath)) return fail(`Import source not found: ${sourcePath}`);
253
+
254
+ const target = resolveTarget(db, agentId);
255
+ const entries = buildEntries(sourcePath, target);
256
+
257
+ out(`== memory import (${apply ? "APPLY" : "DRY-RUN"}) ==`);
258
+ out(`source: ${sourcePath}`);
259
+ out(`target: ${agentId} (${target.kind})`);
260
+ out("");
261
+ out(pad("OWNER", 26) + pad("KIND", 10) + pad("STATUS", 8) + "SECTION");
262
+ const byOwner = {};
263
+ let newCount = 0;
264
+ let dupCount = 0;
265
+ let redacted = 0;
266
+ for (const e of entries) {
267
+ const status = e.redacted ? "skip" : existsByToken(db, e.token) ? "dup" : "new";
268
+ if (status === "new") {
269
+ newCount += 1;
270
+ byOwner[e.ownerLabel] = (byOwner[e.ownerLabel] || 0) + 1;
271
+ } else if (status === "dup") dupCount += 1;
272
+ else redacted += 1;
273
+ out(pad(e.ownerLabel, 26) + pad(e.kind, 10) + pad(status, 8) + e.heading.slice(0, 70));
274
+ }
275
+ out("");
276
+ out(`total ${entries.length} · new ${newCount} · duplicate ${dupCount} · redacted ${redacted}`);
277
+
278
+ if (!apply) {
279
+ out("");
280
+ out("dry-run — nothing written. Re-run with --apply to write to the shared agentlas.sqlite.");
281
+ return;
282
+ }
283
+
284
+ const now = new Date().toISOString();
285
+ let imported = 0;
286
+ const insert = db.prepare(
287
+ "INSERT INTO memory_entries (id,scope,kind,content,project_id,project_path,agent_id,chat_id,confidence,sensitivity,evidence_json,context_json,superseded_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL,?)",
288
+ );
289
+ const write = db.transaction((list) => {
290
+ for (const e of list) {
291
+ if (e.redacted || existsByToken(db, e.token)) continue;
292
+ const confidence = /\(20\d\d-\d\d-\d\d\)|Date:\s*20\d\d/.test(e.content) ? "high" : "medium";
293
+ const context = JSON.stringify({ userIntent: `Imported memory: ${e.heading}`.slice(0, 200), outcome: "imported-from-existing-memory" });
294
+ const evidence = JSON.stringify([e.token, `source:memory-import/${e.relFile}`]);
295
+ insert.run(randomUUID(), e.scope, e.kind, e.content, null, null, e.ownerAgentId, null, confidence, "internal", evidence, context, now);
296
+ imported += 1;
297
+ }
298
+ });
299
+ write(entries);
300
+
301
+ out("");
302
+ out(`imported ${imported} memory entries into the shared DB. (Embedding runs in the desktop app on next open.)`);
303
+ }
304
+
305
+ function pad(value, width) {
306
+ const s = String(value == null ? "" : value);
307
+ return s.length >= width ? s.slice(0, width - 1) + " " : s + " ".repeat(width - s.length);
308
+ }
309
+
310
+ module.exports = { cmdMemory, buildEntries, resolveTarget, decideOwner };
@@ -643,12 +643,26 @@ function startRepl(opts) {
643
643
  state.modelPinned = false;
644
644
  state.native = {};
645
645
  }
646
+ // 이미지 능력 판정 warm-cache — 동기 호출자(applyRuntimeFor/배지)가 읽기 전에 비동기
647
+ // 경로에서 상주 판정 서비스를 먼저 데운다. 러너 없음/실패는 어휘 폴백(라벨은 routingNote가 찍음).
648
+ async function warmImageJudgment(agentRow) {
649
+ if (!agentRow || typeof caps.resolveNeedsImage !== "function") return;
650
+ try {
651
+ await caps.resolveNeedsImage(agentRow);
652
+ } catch {
653
+ /* lexical fallback */
654
+ }
655
+ }
646
656
  // Tell the user when we routed to an image-capable runtime, or when the current one can't make images.
647
657
  function routingNote(subject) {
648
658
  if (!subject || !caps.needsImage(subject.capAgent)) return;
649
659
  const spec = caps.specOf(state.runtime);
650
660
  if (caps.capsFor(spec).image) {
651
- if (spec !== caps.specOf(baseRuntime)) ui.info(ui.t("routedImage", spec));
661
+ if (spec !== caps.specOf(baseRuntime)) {
662
+ // 판정 주체 라벨 — 모델 판정인지 결정적 폴백인지 반드시 밝힌다(조용한 폴백 금지).
663
+ const judged = typeof caps.imageJudgmentSource === "function" && caps.imageJudgmentSource(subject.capAgent) === "llm";
664
+ ui.info(ui.t("routedImage", spec) + " — " + ui.t(judged ? "judge.source.llm" : "judge.source.fallback"));
665
+ }
652
666
  } else {
653
667
  ui.warn(ui.t("guard.imageWarn", caps.capsFor(spec).label || spec));
654
668
  }
@@ -701,10 +715,11 @@ function startRepl(opts) {
701
715
  state.routePreambleOnce = null;
702
716
  applyRuntimeFor(state.subject);
703
717
  }
704
- function switchSubject(kind, query) {
718
+ async function switchSubject(kind, query) {
705
719
  if (kind === "agent") {
706
720
  const agent = H.resolveAgent(db, query);
707
721
  if (!agent) return ui.error(ui.t("noAgent", query));
722
+ await warmImageJudgment(agent); // 런타임 자동 배정 전에 모델 판정을 데운다
708
723
  setSubjectAgent(agent);
709
724
  } else {
710
725
  const firm = H.resolveFirm(db, query);
@@ -933,11 +948,11 @@ function startRepl(opts) {
933
948
  }
934
949
  case "agent":
935
950
  if (!arg) return ui.warn(ui.t("agentUsage")), true;
936
- switchSubject("agent", arg);
951
+ await switchSubject("agent", arg);
937
952
  return true;
938
953
  case "firm":
939
954
  if (!arg) return ui.warn(ui.t("firmUsage")), true;
940
- switchSubject("firm", arg);
955
+ await switchSubject("firm", arg);
941
956
  return true;
942
957
  case "runtime":
943
958
  setRuntime(arg);
@@ -1364,7 +1379,8 @@ function startRepl(opts) {
1364
1379
  }
1365
1380
 
1366
1381
  // ── interactive picker (when no agent was given) ──
1367
- function chooseAndStart(setter, row) {
1382
+ async function chooseAndStart(setter, row) {
1383
+ if (setter === setSubjectAgent) await warmImageJudgment(row); // firm은 팀 베토라 판정 대상 아님
1368
1384
  setter(row);
1369
1385
  ui.ok(ui.t("switched", state.subject.label));
1370
1386
  routingNote(state.subject);
@@ -1402,12 +1418,14 @@ function startRepl(opts) {
1402
1418
  if (a) return chooseAndStart(setSubjectAgent, a);
1403
1419
  const f = H.resolveFirm(db, t);
1404
1420
  if (f) return chooseAndStart(setSubjectFirm, f);
1405
- if (H.autoRouteAgent) {
1406
- const choice = H.autoRouteAgent(db, t, ui.lang);
1421
+ if (H.autoRouteAgent || H.resolveAutoRoute) {
1422
+ // 연결 모델이 라우트를 최종 판정한다(resolveAutoRoute) 없으면 어휘 폴백.
1423
+ const choice = H.resolveAutoRoute ? await H.resolveAutoRoute(db, t, ui.lang) : H.autoRouteAgent(db, t, ui.lang);
1407
1424
  if (choice) {
1408
1425
  if (choice.direct) {
1409
1426
  setSubjectDirect();
1410
1427
  } else {
1428
+ await warmImageJudgment(choice.agent);
1411
1429
  setSubjectAgent(choice.agent);
1412
1430
  }
1413
1431
  state.routePreambleOnce = H.autoRoutePreamble ? H.autoRoutePreamble(choice, ui.lang) : null;
@@ -1442,6 +1460,7 @@ function startRepl(opts) {
1442
1460
  if (/^\d+$/.test(t)) {
1443
1461
  const n = parseInt(t, 10);
1444
1462
  if (n >= 1 && n <= ags.length) {
1463
+ await warmImageJudgment(ags[n - 1]);
1445
1464
  setSubjectAgent(ags[n - 1]);
1446
1465
  ui.ok(ui.t("switched", state.subject.label));
1447
1466
  routingNote(state.subject);
@@ -1450,13 +1469,14 @@ function startRepl(opts) {
1450
1469
  }
1451
1470
  if (single) {
1452
1471
  const a = H.resolveAgent(db, t);
1453
- if (a) { setSubjectAgent(a); ui.ok(ui.t("switched", state.subject.label)); routingNote(state.subject); return; }
1472
+ if (a) { await warmImageJudgment(a); setSubjectAgent(a); ui.ok(ui.t("switched", state.subject.label)); routingNote(state.subject); return; }
1454
1473
  const f = H.resolveFirm(db, t);
1455
1474
  if (f) { setSubjectFirm(f); ui.ok(ui.t("switched", state.subject.label)); routingNote(state.subject); return; }
1456
1475
  }
1457
1476
  }
1458
- if (H.autoRouteAgent) {
1459
- const choice = H.autoRouteAgent(db, t, ui.lang);
1477
+ if (H.autoRouteAgent || H.resolveAutoRoute) {
1478
+ // 연결 모델이 라우트를 최종 판정한다(resolveAutoRoute) 없으면 어휘 폴백.
1479
+ const choice = H.resolveAutoRoute ? await H.resolveAutoRoute(db, t, ui.lang) : H.autoRouteAgent(db, t, ui.lang);
1460
1480
  if (choice) {
1461
1481
  if (choice.direct) {
1462
1482
  setSubjectDirect();
@@ -1466,6 +1486,7 @@ function startRepl(opts) {
1466
1486
  ui.info(H.autoRouteNote ? H.autoRouteNote(choice, ui.lang) : `direct answer (no agent)`);
1467
1487
  }
1468
1488
  } else {
1489
+ await warmImageJudgment(choice.agent);
1469
1490
  setSubjectAgent(choice.agent);
1470
1491
  state.routePreambleOnce = H.autoRoutePreamble ? H.autoRoutePreamble(choice, ui.lang) : null;
1471
1492
  ui.info(H.autoRouteNote ? H.autoRouteNote(choice, ui.lang) : `auto-routed to ${choice.agent.name}`);
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ // Credential/secret detection for every terminal write boundary that must never persist
3
+ // or transmit a live key. Three inline copies had drifted apart (memory import missed
4
+ // JWTs, generic `key=` assignments and bearer headers; governance missed sk_live_/
5
+ // github_pat_/glpat-; experience exchange missed Google AIza and JWTs), so the same
6
+ // secret was caught at one boundary and stored in plain text at another. One list, one
7
+ // behaviour: extend HERE, not at a call site. Mirrors the desktop's
8
+ // shared/secret-patterns.ts.
9
+ //
10
+ // Scope rule: match *credential shapes*, not the words around them. Ordinary prose that
11
+ // mentions "token", or a hyphenated phrase like "risk-management-notes", must not trip
12
+ // this — a false positive silently drops a user's memory, which is its own data loss.
13
+
14
+ /** Live-credential shapes across the providers this product actually touches. */
15
+ const SECRET_SHAPES = [
16
+ // GitHub: classic PAT, OAuth/user/server/refresh tokens, fine-grained PAT.
17
+ /gh[pousr]_[A-Za-z0-9]{20,}/,
18
+ /github_pat_[A-Za-z0-9_]{20,}/,
19
+ // Slack bot/user/app tokens.
20
+ /xox[baprs]-[A-Za-z0-9-]{20,}/,
21
+ // AWS access key ids (long-lived and STS).
22
+ /(?:AKIA|ASIA)[0-9A-Z]{16}/,
23
+ // Google / Firebase API keys.
24
+ /AIza[0-9A-Za-z_-]{30,}/,
25
+ // Stripe and similar: secret/restricted/publishable, live or test.
26
+ /(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/,
27
+ // OpenAI / Anthropic, including provider-segmented forms. The \b prevents an ordinary
28
+ // hyphenated phrase ("ask-forgiveness-not-permission") from matching.
29
+ /\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{12,}/,
30
+ // HuggingFace, GitLab, npm.
31
+ /hf_[A-Za-z0-9]{20,}/,
32
+ /glpat-[A-Za-z0-9_-]{20,}/,
33
+ /npm_[A-Za-z0-9]{20,}/,
34
+ // JWTs (three base64url segments) — bearer tokens frequently land in pasted logs.
35
+ /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/,
36
+ // Telegram bot tokens.
37
+ /\b[0-9]{8,}:[A-Za-z0-9_-]{25,}\b/,
38
+ // Private key blocks.
39
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
40
+ ];
41
+
42
+ /** `password: hunter2` style assignments, where the value shape alone proves nothing. */
43
+ const SECRET_ASSIGNMENT_RE =
44
+ /\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key|cookie|bearer)\b\s*[:=]\s*['"]?[^\s,;'"]{6,}/i;
45
+
46
+ /** `Authorization: Bearer …` / `Basic …` headers. */
47
+ const AUTH_HEADER_RE = /\bauthorization\b\s*[:=]\s*['"]?(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/i;
48
+
49
+ /** Single source of truth. Case-insensitive: providers are inconsistent about casing. */
50
+ const SECRET_PATTERNS = [
51
+ ...SECRET_SHAPES.map((re) => new RegExp(re.source, "i")),
52
+ SECRET_ASSIGNMENT_RE,
53
+ AUTH_HEADER_RE,
54
+ ];
55
+
56
+ /** True when the text contains something that looks like a live credential. */
57
+ function looksSecret(content) {
58
+ const text = String(content || "");
59
+ return SECRET_PATTERNS.some((re) => re.test(text));
60
+ }
61
+
62
+ /** Replace credential-shaped substrings with a marker, preserving surrounding text. */
63
+ function redactSecrets(content, marker = "[redacted-secret]") {
64
+ let out = String(content || "");
65
+ for (const re of SECRET_PATTERNS) {
66
+ out = out.replace(new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`), marker);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ module.exports = { SECRET_PATTERNS, looksSecret, redactSecrets };