syndes 0.1.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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/adapters/claude-code.mjs +59 -0
  4. package/adapters/codex.mjs +256 -0
  5. package/adapters/index.mjs +92 -0
  6. package/analytics/index.mjs +189 -0
  7. package/analytics/metrics/context.mjs +95 -0
  8. package/analytics/metrics/cost.mjs +83 -0
  9. package/analytics/metrics/friction.mjs +86 -0
  10. package/analytics/metrics/prompts.mjs +93 -0
  11. package/analytics/metrics/rework.mjs +113 -0
  12. package/analytics/metrics/time.mjs +104 -0
  13. package/analytics/metrics/tokens.mjs +88 -0
  14. package/analytics/metrics/tools.mjs +118 -0
  15. package/analytics/metrics/volume.mjs +98 -0
  16. package/analytics/ranges.mjs +98 -0
  17. package/analytics/rollup.mjs +151 -0
  18. package/analytics/score.mjs +194 -0
  19. package/bin/cli.mjs +596 -0
  20. package/bin/postinstall.mjs +44 -0
  21. package/collect/classify.mjs +226 -0
  22. package/collect/git.mjs +78 -0
  23. package/collect/projects.mjs +82 -0
  24. package/collect/redact.mjs +85 -0
  25. package/collect/sessions.mjs +119 -0
  26. package/collect/tail.mjs +126 -0
  27. package/collect/tools.mjs +121 -0
  28. package/collect/transcript.mjs +128 -0
  29. package/dashboard/api/index.mjs +296 -0
  30. package/dashboard/auth.mjs +235 -0
  31. package/dashboard/router.mjs +55 -0
  32. package/dashboard/security.mjs +95 -0
  33. package/dashboard/server.mjs +156 -0
  34. package/dashboard/static.mjs +47 -0
  35. package/dashboard/web/SynDes.icns +0 -0
  36. package/dashboard/web/api.js +80 -0
  37. package/dashboard/web/app.css +532 -0
  38. package/dashboard/web/app.js +261 -0
  39. package/dashboard/web/charts.js +273 -0
  40. package/dashboard/web/index.html +23 -0
  41. package/dashboard/web/logo.png +0 -0
  42. package/dashboard/web/ui.js +434 -0
  43. package/dashboard/web/views/habits.js +166 -0
  44. package/dashboard/web/views/ledger.js +164 -0
  45. package/dashboard/web/views/overview.js +214 -0
  46. package/dashboard/web/views/sessions.js +133 -0
  47. package/dashboard/web/views/settings.js +180 -0
  48. package/ledger/append.mjs +126 -0
  49. package/ledger/chain.mjs +53 -0
  50. package/ledger/keys.mjs +72 -0
  51. package/ledger/read.mjs +77 -0
  52. package/ledger/retention.mjs +104 -0
  53. package/ledger/schema.mjs +96 -0
  54. package/ledger/segments.mjs +109 -0
  55. package/ledger/verify.mjs +174 -0
  56. package/notify/index.mjs +67 -0
  57. package/notify/linux.mjs +41 -0
  58. package/notify/mac.mjs +44 -0
  59. package/notify/terminal.mjs +15 -0
  60. package/notify/windows.mjs +61 -0
  61. package/package.json +66 -0
  62. package/practices/budget.mjs +97 -0
  63. package/practices/catalog.mjs +64 -0
  64. package/practices/deliver.mjs +101 -0
  65. package/practices/engine.mjs +107 -0
  66. package/practices/rules/batch-tool-calls.mjs +15 -0
  67. package/practices/rules/context-hygiene.mjs +17 -0
  68. package/practices/rules/delegate-wide-search.mjs +15 -0
  69. package/practices/rules/index.mjs +28 -0
  70. package/practices/rules/permission-friction.mjs +16 -0
  71. package/practices/rules/project-memory.mjs +27 -0
  72. package/practices/rules/prompt-specificity.mjs +15 -0
  73. package/practices/rules/read-before-edit.mjs +16 -0
  74. package/practices/rules/retry-storm.mjs +22 -0
  75. package/practices/rules/session-sprawl.mjs +15 -0
  76. package/practices/rules/verify-after-change.mjs +16 -0
  77. package/runtime/config.mjs +116 -0
  78. package/runtime/hook.mjs +154 -0
  79. package/runtime/jsonl.mjs +104 -0
  80. package/runtime/lock.mjs +98 -0
  81. package/runtime/log.mjs +37 -0
  82. package/runtime/paths.mjs +116 -0
  83. package/runtime/platform.mjs +74 -0
  84. package/runtime/spool.mjs +92 -0
  85. package/runtime/worker.mjs +275 -0
  86. package/src/briefing.mjs +94 -0
  87. package/src/doctor.mjs +153 -0
  88. package/src/export.mjs +68 -0
  89. package/src/install.mjs +95 -0
  90. package/src/open.mjs +23 -0
  91. package/src/report.mjs +120 -0
  92. package/src/settings.mjs +173 -0
  93. package/src/status.mjs +61 -0
  94. package/src/systemauth.mjs +179 -0
  95. package/src/term.mjs +272 -0
  96. package/src/uninstall.mjs +43 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Raw hook payload → ledger record(s). Runs in the worker, never in the hook.
3
+ *
4
+ * Pure given its context: the same payload and the same session state always
5
+ * produce the same drafts, which is what makes a replay of the spool reproduce
6
+ * an identical chain. The session state it reads and updates is passed in, not
7
+ * reached for.
8
+ *
9
+ * Hook payloads carry session_id, transcript_path, cwd, hook_event_name, plus:
10
+ * PreToolUse tool_name, tool_input
11
+ * PostToolUse tool_name, tool_input, tool_response
12
+ * PermissionRequest tool_name, tool_input, permission_mode
13
+ * UserPromptSubmit prompt
14
+ * Notification message
15
+ * Stop / SubagentStop stop_hook_active
16
+ * PreCompact trigger, custom_instructions
17
+ * SessionStart source: startup | resume | clear | compact
18
+ * SessionEnd reason: clear | logout | prompt_input_exit | other
19
+ */
20
+
21
+ import { draft, KIND } from '../ledger/schema.mjs';
22
+ import { redact, clamp } from './redact.mjs';
23
+ import { familyOf, summarise, isWrite, isRead, isSearchThroughBash, isVerification } from './tools.mjs';
24
+ import { markToolStart, takeToolDuration, markRead, wasRead } from './sessions.mjs';
25
+
26
+ /**
27
+ * @param {{t: number, e: string, p: string}} spooled
28
+ * @param {{config: object, session: object, project: object|null}} ctx
29
+ * @returns {object[]} drafts, in order
30
+ */
31
+ export function classify(spooled, ctx) {
32
+ // The worker has usually parsed this already; parsing a megabyte of
33
+ // tool_response twice is pure waste on the one path that handles every call.
34
+ let payload = spooled.parsed;
35
+ if (!payload) {
36
+ try {
37
+ payload = JSON.parse(spooled.p);
38
+ } catch {
39
+ return []; // an unparseable payload is not evidence of anything
40
+ }
41
+ }
42
+
43
+ const ts = spooled.t;
44
+ const session = payload.session_id ?? null;
45
+ const project = ctx.project?.id ?? null;
46
+ const base = { ts, session, project };
47
+ const mode = ctx.config.privacy;
48
+ const extra = ctx.config.redactPatterns;
49
+
50
+ switch (payload.hook_event_name) {
51
+ case 'SessionStart':
52
+ return [draft(KIND.SESSION_START, {
53
+ ...base,
54
+ data: {
55
+ source: payload.source ?? 'startup',
56
+ cwd: mode === 'metadata' ? null : payload.cwd ?? null,
57
+ projectName: ctx.project?.name ?? null,
58
+ resumed: payload.source === 'resume' || payload.source === 'compact',
59
+ },
60
+ })];
61
+
62
+ case 'SessionEnd':
63
+ return [draft(KIND.SESSION_END, {
64
+ ...base,
65
+ data: {
66
+ reason: payload.reason ?? 'other',
67
+ durationMs: ctx.session.startedAt ? ts - ctx.session.startedAt : null,
68
+ counts: { ...ctx.session.counts },
69
+ },
70
+ })];
71
+
72
+ case 'PreCompact':
73
+ return [draft(KIND.COMPACT, {
74
+ ...base,
75
+ // 'auto' means the context ran out; 'manual' means the user chose it.
76
+ // Only the first one is a problem, so the trigger is the whole point.
77
+ data: { trigger: payload.trigger ?? 'auto', custom: Boolean(payload.custom_instructions) },
78
+ })];
79
+
80
+ case 'UserPromptSubmit': {
81
+ const prompt = typeof payload.prompt === 'string' ? payload.prompt : '';
82
+ const scrubbed = redact(prompt, { mode, extra });
83
+ const stored = clamp(scrubbed.text);
84
+ return [draft(KIND.PROMPT, {
85
+ ...base,
86
+ data: {
87
+ chars: prompt.length,
88
+ words: prompt.trim() ? prompt.trim().split(/\s+/).length : 0,
89
+ lines: prompt.split('\n').length,
90
+ text: stored.text,
91
+ truncated: stored.truncated,
92
+ redactions: scrubbed.redactions,
93
+ // A correction is the evidence that the previous prompt under-specified.
94
+ correction: looksLikeCorrection(prompt),
95
+ },
96
+ })];
97
+ }
98
+
99
+ case 'PreToolUse': {
100
+ const tool = payload.tool_name ?? 'Unknown';
101
+ const { target, detail } = summarise(tool, payload.tool_input);
102
+ markToolStart(ctx.session, tool, target, ts);
103
+ if (isRead(tool)) markRead(ctx.session, target);
104
+
105
+ const command = payload.tool_input?.command;
106
+ return [draft(KIND.TOOL_PRE, {
107
+ ...base,
108
+ data: {
109
+ tool,
110
+ family: familyOf(tool),
111
+ target: mode === 'metadata' ? null : target,
112
+ detail: mode === 'metadata' ? null : redact(detail ?? '', { mode, extra }).text || null,
113
+ searchViaBash: tool === 'Bash' ? isSearchThroughBash(command) : false,
114
+ verification: tool === 'Bash' ? isVerification(command) : false,
115
+ },
116
+ })];
117
+ }
118
+
119
+ case 'PostToolUse': {
120
+ const tool = payload.tool_name ?? 'Unknown';
121
+ const { target } = summarise(tool, payload.tool_input);
122
+ const durationMs = takeToolDuration(ctx.session, tool, target, ts);
123
+ const drafts = [draft(KIND.TOOL_POST, {
124
+ ...base,
125
+ data: {
126
+ tool,
127
+ family: familyOf(tool),
128
+ target: mode === 'metadata' ? null : target,
129
+ durationMs,
130
+ // Heuristic only. The authoritative failure signal is the transcript's
131
+ // tool_result.is_error, which PostToolUse does not carry.
132
+ failed: looksFailed(payload.tool_response),
133
+ bytes: sizeOf(payload.tool_response),
134
+ },
135
+ })];
136
+
137
+ if (isWrite(tool) && target) {
138
+ drafts.push(draft(KIND.FILE_TOUCH, {
139
+ ...base,
140
+ data: {
141
+ path: mode === 'metadata' ? null : target,
142
+ ext: extensionOf(target),
143
+ op: tool === 'Write' ? 'write' : 'edit',
144
+ // Editing a file this session never read is the strongest single
145
+ // predictor of an edit that has to be redone.
146
+ readFirst: wasRead(ctx.session, target),
147
+ },
148
+ }));
149
+ markRead(ctx.session, target); // after a write, the content is known
150
+ }
151
+ return drafts;
152
+ }
153
+
154
+ case 'PermissionRequest': {
155
+ const tool = payload.tool_name ?? 'Unknown';
156
+ const { target } = summarise(tool, payload.tool_input);
157
+ return [draft(KIND.TOOL_BLOCKED, {
158
+ ...base,
159
+ data: {
160
+ tool,
161
+ family: familyOf(tool),
162
+ target: mode === 'metadata' ? null : target,
163
+ mode: payload.permission_mode ?? null,
164
+ },
165
+ })];
166
+ }
167
+
168
+ case 'Notification': {
169
+ const message = typeof payload.message === 'string' ? payload.message : '';
170
+ return [draft(KIND.NOTIFY, {
171
+ ...base,
172
+ data: {
173
+ kind: /permission/i.test(message) ? 'permission' : 'waiting',
174
+ message: mode === 'metadata' ? null : message.slice(0, 200),
175
+ },
176
+ })];
177
+ }
178
+
179
+ case 'Stop':
180
+ return [draft(KIND.STOP, { ...base, data: { chained: payload.stop_hook_active === true } })];
181
+
182
+ case 'SubagentStop':
183
+ return [draft(KIND.SUBAGENT_STOP, { ...base, data: { chained: payload.stop_hook_active === true } })];
184
+
185
+ default:
186
+ return [];
187
+ }
188
+ }
189
+
190
+ /** Phrases that mean the last turn missed. Deliberately narrow — a false positive here inflates the correction rate and slanders the user's prompting. */
191
+ const CORRECTIONS = [
192
+ /^\s*(no|nope|nah)\b/i,
193
+ /\b(actually|instead)\b.{0,40}\b(i|we)\b/i,
194
+ /\bi (meant|said)\b/i,
195
+ /\bthat'?s (wrong|not right|not what)\b/i,
196
+ /\b(undo|revert) (that|it|this)\b/i,
197
+ /\bnot what i (asked|wanted|meant)\b/i,
198
+ ];
199
+
200
+ function looksLikeCorrection(prompt) {
201
+ return CORRECTIONS.some((pattern) => pattern.test(prompt));
202
+ }
203
+
204
+ function looksFailed(response) {
205
+ if (!response) return false;
206
+ if (response.is_error === true || response.interrupted === true) return true;
207
+ if (typeof response === 'string') return /^(error|command failed)\b/i.test(response.trim());
208
+ if (typeof response.stderr === 'string' && response.stderr.trim() && !response.stdout) return true;
209
+ return false;
210
+ }
211
+
212
+ function sizeOf(response) {
213
+ if (response == null) return 0;
214
+ try {
215
+ return typeof response === 'string' ? response.length : JSON.stringify(response).length;
216
+ } catch {
217
+ return 0;
218
+ }
219
+ }
220
+
221
+ function extensionOf(path) {
222
+ if (typeof path !== 'string') return null;
223
+ const name = path.split(/[\\/]/).pop() ?? '';
224
+ const dot = name.lastIndexOf('.');
225
+ return dot > 0 ? name.slice(dot + 1).toLowerCase().slice(0, 12) : null;
226
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Optional git context: branch, and commits made during a session.
3
+ *
4
+ * Strictly best-effort and strictly in the worker. Every call is execFile with
5
+ * an argv array and a hard timeout, so a repo that is huge, locked or mid-rebase
6
+ * gets skipped rather than blocking the drain. Read-only commands only — nothing
7
+ * here may mutate a user's repository, ever.
8
+ */
9
+
10
+ import { execFileSync } from 'node:child_process';
11
+ import { which } from '../runtime/platform.mjs';
12
+ import { debug } from '../runtime/log.mjs';
13
+
14
+ const TIMEOUT_MS = 1500;
15
+
16
+ function git(cwd, args) {
17
+ const binary = which('git');
18
+ if (!binary) return null;
19
+ try {
20
+ return execFileSync(binary, args, {
21
+ cwd,
22
+ encoding: 'utf8',
23
+ timeout: TIMEOUT_MS,
24
+ stdio: ['ignore', 'pipe', 'ignore'],
25
+ windowsHide: true,
26
+ }).trim();
27
+ } catch (error) {
28
+ debug('git failed', args.join(' '), error.message);
29
+ return null;
30
+ }
31
+ }
32
+
33
+ export function branchOf(cwd) {
34
+ return git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
35
+ }
36
+
37
+ export function headOf(cwd) {
38
+ return git(cwd, ['rev-parse', 'HEAD']);
39
+ }
40
+
41
+ /**
42
+ * Commits reachable from HEAD that were not there last time we looked.
43
+ *
44
+ * `since` is a sha, not a timestamp: a rebase or an amend changes history in
45
+ * ways a clock cannot describe, and counting commits by time would double-count
46
+ * every rewritten one.
47
+ *
48
+ * @returns {{sha, subject, files, insertions, deletions}[]}
49
+ */
50
+ export function commitsSince(cwd, since) {
51
+ if (!since) {
52
+ const head = headOf(cwd);
53
+ return head ? [] : []; // first sighting establishes the baseline only
54
+ }
55
+
56
+ const range = git(cwd, ['rev-list', '--max-count=20', `${since}..HEAD`]);
57
+ if (!range) return [];
58
+
59
+ const out = [];
60
+ for (const sha of range.split('\n').filter(Boolean).reverse()) {
61
+ const stat = git(cwd, ['show', '--stat=200', '--format=%s', '--no-color', sha]);
62
+ if (!stat) continue;
63
+ const lines = stat.split('\n');
64
+ const summary = lines[lines.length - 1] ?? '';
65
+ out.push({
66
+ sha: sha.slice(0, 12),
67
+ subject: (lines[0] ?? '').slice(0, 120),
68
+ files: number(/(\d+) files? changed/.exec(summary)),
69
+ insertions: number(/(\d+) insertions?/.exec(summary)),
70
+ deletions: number(/(\d+) deletions?/.exec(summary)),
71
+ });
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function number(match) {
77
+ return match ? Number(match[1]) : 0;
78
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * cwd → stable project identity.
3
+ *
4
+ * A path is not an identity. The same repo cloned twice, or moved, must not
5
+ * become two projects in the metrics; two unrelated ~/work/app directories on
6
+ * two machines must not merge into one.
7
+ *
8
+ * Keyed on the git toplevel where there is one, else the directory itself, and
9
+ * cached — resolving this costs a subprocess and the answer never changes for a
10
+ * given cwd within a run.
11
+ */
12
+
13
+ import { execFileSync } from 'node:child_process';
14
+ import { basename, resolve } from 'node:path';
15
+ import { createHash } from 'node:crypto';
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
17
+ import { dirname } from 'node:path';
18
+ import { projectsFile } from '../runtime/paths.mjs';
19
+ import { which } from '../runtime/platform.mjs';
20
+
21
+ const cache = new Map();
22
+
23
+ /** @returns {{id: string, name: string, root: string}|null} */
24
+ export function projectFor(cwd) {
25
+ if (!cwd) return null;
26
+ const key = resolve(cwd);
27
+ if (cache.has(key)) return cache.get(key);
28
+
29
+ const root = gitRoot(key) ?? key;
30
+ const project = {
31
+ id: createHash('sha256').update(root).digest('hex').slice(0, 12),
32
+ name: basename(root) || root,
33
+ root,
34
+ };
35
+ cache.set(key, project);
36
+ return project;
37
+ }
38
+
39
+ function gitRoot(cwd) {
40
+ const git = which('git');
41
+ if (!git) return null;
42
+ try {
43
+ return execFileSync(git, ['rev-parse', '--show-toplevel'], {
44
+ cwd,
45
+ encoding: 'utf8',
46
+ timeout: 1500,
47
+ stdio: ['ignore', 'pipe', 'ignore'],
48
+ windowsHide: true,
49
+ }).trim() || null;
50
+ } catch {
51
+ return null; // not a repo, git is broken, or the repo is mid-rebase
52
+ }
53
+ }
54
+
55
+ /**
56
+ * The id → name map, so 'metadata' privacy can still show per-project
57
+ * breakdowns without the ledger recording anyone's directory layout.
58
+ */
59
+ export function rememberProject(project) {
60
+ if (!project) return;
61
+ try {
62
+ const map = loadProjects();
63
+ if (map[project.id]?.name === project.name) return;
64
+ map[project.id] = { name: project.name, root: project.root, seen: Date.now() };
65
+ mkdirSync(dirname(projectsFile), { recursive: true });
66
+ writeFileSync(projectsFile, `${JSON.stringify(map, null, 2)}\n`);
67
+ } catch {
68
+ // A missing name map degrades the UI to ids. It never blocks a write.
69
+ }
70
+ }
71
+
72
+ export function loadProjects() {
73
+ try {
74
+ return existsSync(projectsFile) ? JSON.parse(readFileSync(projectsFile, 'utf8')) : {};
75
+ } catch {
76
+ return {};
77
+ }
78
+ }
79
+
80
+ export function nameFor(projectId) {
81
+ return loadProjects()[projectId]?.name ?? projectId;
82
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Secret scrubbing, run BEFORE anything is chained.
3
+ *
4
+ * Order matters and is not negotiable: an append-only ledger has no take-backs,
5
+ * so a secret must never be written and then regretted. This is the first stage
6
+ * of the worker pipeline, not a view-time filter.
7
+ *
8
+ * A redaction records that it happened. Silently altered evidence is worse than
9
+ * absent evidence.
10
+ *
11
+ * ── Pattern contract ────────────────────────────────────────────────────────
12
+ * Each entry is [regexp, label, secretGroup]. The capture groups must TILE the
13
+ * whole match — concatenating them must reproduce it exactly — because the
14
+ * replacement is built by swapping one group for the label and re-joining the
15
+ * rest. That is what keeps the surrounding shape (`KEY=`, `Bearer `, the `@` in
16
+ * a connection string) intact, and the shape is usually the useful part.
17
+ */
18
+
19
+ const PATTERNS = [
20
+ [/(sk-ant-[A-Za-z0-9_-]{8,})/g, 'ANTHROPIC_KEY', 1],
21
+ [/(sk-[A-Za-z0-9]{20,})/g, 'API_KEY', 1],
22
+ [/(gh[pousr]_[A-Za-z0-9]{16,})/g, 'GITHUB_TOKEN', 1],
23
+ [/(github_pat_[A-Za-z0-9_]{20,})/g, 'GITHUB_TOKEN', 1],
24
+ [/(xox[baprs]-[A-Za-z0-9-]{10,})/g, 'SLACK_TOKEN', 1],
25
+ [/(AKIA[0-9A-Z]{16})/g, 'AWS_KEY_ID', 1],
26
+ [/(AIza[0-9A-Za-z_-]{35})/g, 'GOOGLE_KEY', 1],
27
+ [/(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})/g, 'JWT', 1],
28
+ [/(-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?-----END[ A-Z]*PRIVATE KEY-----)/g, 'PRIVATE_KEY', 1],
29
+ // scheme://user:SECRET@host — keep the scheme, the user and the @.
30
+ [/([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s:@/]+:)([^\s@/]+)(@)/g, 'URL_PASSWORD', 2],
31
+ [/(Bearer\s+)([A-Za-z0-9._~+/-]{20,}=*)/gi, 'BEARER', 2],
32
+ // NAME=value in shell or .env form. The name survives; only the value goes.
33
+ // The closing quote is its own group so the groups still tile the match.
34
+ [/([A-Z][A-Z0-9_]{2,}(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|API)[A-Z0-9_]*\s*=\s*)(["']?)([^\s"'#]{6,})(\2)/g, 'ENV_SECRET', 3],
35
+ ];
36
+
37
+ /**
38
+ * @param {string} text
39
+ * @param {{mode?: 'redacted'|'full'|'metadata', extra?: string[]}} options
40
+ * @returns {{text: string|null, redactions: number, kinds: string[]}}
41
+ */
42
+ export function redact(text, { mode = 'redacted', extra = [] } = {}) {
43
+ if (typeof text !== 'string') return { text: null, redactions: 0, kinds: [] };
44
+ if (mode === 'metadata') return { text: null, redactions: 0, kinds: [] };
45
+ if (mode === 'full') return { text, redactions: 0, kinds: [] };
46
+
47
+ let out = text;
48
+ let redactions = 0;
49
+ const kinds = new Set();
50
+
51
+ for (const [pattern, label, secretGroup] of [...PATTERNS, ...compileExtra(extra)]) {
52
+ out = out.replace(pattern, (...args) => {
53
+ // args is [match, ...groups, offset, string]; no named groups are used.
54
+ const groups = args.slice(1, -2);
55
+ redactions += 1;
56
+ kinds.add(label);
57
+ if (!groups.length) return `[${label}]`;
58
+ return groups
59
+ .map((group, index) => (index === secretGroup - 1 ? `[${label}]` : group ?? ''))
60
+ .join('');
61
+ });
62
+ }
63
+
64
+ return { text: out, redactions, kinds: [...kinds] };
65
+ }
66
+
67
+ /** User patterns from config. A bad regex is skipped, never thrown into a hook. */
68
+ function compileExtra(sources) {
69
+ const out = [];
70
+ for (const source of sources ?? []) {
71
+ try {
72
+ out.push([new RegExp(source, 'g'), 'CUSTOM', 1]);
73
+ } catch {
74
+ // An unusable pattern must not take the pipeline down with it.
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /** Truncate for storage, flagging that it happened. Bounds the ledger's growth. */
81
+ export function clamp(text, limit = 2000) {
82
+ if (typeof text !== 'string') return { text: null, truncated: false };
83
+ if (text.length <= limit) return { text, truncated: false };
84
+ return { text: text.slice(0, limit), truncated: true };
85
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Session lifecycle, held in state/sessions.json.
3
+ *
4
+ * Disposable: the ledger is truth. This file exists so the worker can answer
5
+ * questions a single hook payload cannot — how long a tool took, whether a file
6
+ * was read before it was edited, where the transcript was last read to.
7
+ *
8
+ * A four-hour session with eleven minutes of work is the single most useful
9
+ * thing this tool can show someone, and separating active from wall-clock time
10
+ * is what makes that possible.
11
+ */
12
+
13
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
14
+ import { dirname } from 'node:path';
15
+ import { sessionsFile } from '../runtime/paths.mjs';
16
+ import { debug } from '../runtime/log.mjs';
17
+
18
+ /** Bounded so a long session cannot grow the state file without limit. */
19
+ const MAX_READ_PATHS = 400;
20
+ const MAX_PENDING = 200;
21
+ /** A session with no activity for this long ended without telling us. */
22
+ const REAP_MS = 12 * 60 * 60 * 1000;
23
+
24
+ let state = null;
25
+
26
+ export function load() {
27
+ if (state) return state;
28
+ try {
29
+ state = JSON.parse(readFileSync(sessionsFile, 'utf8'));
30
+ } catch {
31
+ state = {};
32
+ }
33
+ return state;
34
+ }
35
+
36
+ export function save() {
37
+ if (!state) return;
38
+ try {
39
+ mkdirSync(dirname(sessionsFile), { recursive: true });
40
+ const staging = `${sessionsFile}.tmp`;
41
+ writeFileSync(staging, `${JSON.stringify(state)}\n`);
42
+ renameSync(staging, sessionsFile);
43
+ } catch (error) {
44
+ debug('session state save failed', error.message);
45
+ }
46
+ }
47
+
48
+ export function get(sessionId) {
49
+ const all = load();
50
+ if (!all[sessionId]) {
51
+ all[sessionId] = {
52
+ startedAt: null, lastAt: null, project: null, cwd: null,
53
+ transcript: null, cursor: 0,
54
+ pending: {}, reads: [],
55
+ counts: { prompts: 0, tools: 0, blocks: 0, errors: 0, compacts: 0, writes: 0 },
56
+ };
57
+ }
58
+ return all[sessionId];
59
+ }
60
+
61
+ export function forget(sessionId) {
62
+ const all = load();
63
+ delete all[sessionId];
64
+ }
65
+
66
+ /** Record that a tool started, so the matching post can be given a duration. */
67
+ export function markToolStart(session, toolName, target, ts) {
68
+ const key = `${toolName}|${target ?? ''}`;
69
+ session.pending[key] = ts;
70
+
71
+ const keys = Object.keys(session.pending);
72
+ if (keys.length > MAX_PENDING) delete session.pending[keys[0]];
73
+ }
74
+
75
+ /**
76
+ * How long a tool took, or null.
77
+ *
78
+ * Null when the pre-hook was never wired or the pair did not match — an
79
+ * unmeasured call must read as unmeasured, never as zero, or every average is
80
+ * dragged toward a number nobody observed.
81
+ */
82
+ export function takeToolDuration(session, toolName, target, ts) {
83
+ const key = `${toolName}|${target ?? ''}`;
84
+ const started = session.pending[key];
85
+ if (started === undefined) return null;
86
+ delete session.pending[key];
87
+ return Math.max(0, ts - started);
88
+ }
89
+
90
+ export function markRead(session, path) {
91
+ if (!path) return;
92
+ if (!session.reads.includes(path)) session.reads.push(path);
93
+ if (session.reads.length > MAX_READ_PATHS) session.reads.shift();
94
+ }
95
+
96
+ export function wasRead(session, path) {
97
+ return Boolean(path) && session.reads.includes(path);
98
+ }
99
+
100
+ /** Sessions that stopped reporting. Marked inferred, never as a clean close. */
101
+ export function reapStale(now = Date.now()) {
102
+ const all = load();
103
+ const reaped = [];
104
+ for (const [id, session] of Object.entries(all)) {
105
+ if (session.lastAt && now - session.lastAt > REAP_MS) {
106
+ reaped.push({ id, session });
107
+ delete all[id];
108
+ }
109
+ }
110
+ return reaped;
111
+ }
112
+
113
+ export function all() {
114
+ return load();
115
+ }
116
+
117
+ export function resetCache() {
118
+ state = null;
119
+ }