@druumen/sessions-db 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 (50) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +10 -0
  4. package/README.md +250 -0
  5. package/cli/_write-helpers.mjs +99 -0
  6. package/cli/alias.mjs +115 -0
  7. package/cli/argparse.mjs +296 -0
  8. package/cli/close.mjs +116 -0
  9. package/cli/find.mjs +185 -0
  10. package/cli/format.mjs +277 -0
  11. package/cli/link-parent.mjs +133 -0
  12. package/cli/link.mjs +132 -0
  13. package/cli/rebuild.mjs +98 -0
  14. package/cli/sessions-db-session-start-main.mjs +454 -0
  15. package/cli/sessions-db-session-start.mjs +56 -0
  16. package/cli/sessions-db.mjs +119 -0
  17. package/cli/sweep.mjs +171 -0
  18. package/cli/tree.mjs +127 -0
  19. package/lib/git-context.mjs +479 -0
  20. package/lib/identity.mjs +616 -0
  21. package/lib/index.mjs +145 -0
  22. package/lib/init.mjs +185 -0
  23. package/lib/lock.mjs +86 -0
  24. package/lib/operations.mjs +490 -0
  25. package/lib/paths.mjs +199 -0
  26. package/lib/projection.mjs +496 -0
  27. package/lib/sanitize.mjs +131 -0
  28. package/lib/storage.mjs +759 -0
  29. package/lib/sweep.mjs +209 -0
  30. package/lib/transcript.mjs +230 -0
  31. package/lib/types.mjs +276 -0
  32. package/lib/uuid.mjs +116 -0
  33. package/lib/watch.mjs +217 -0
  34. package/package.json +53 -0
  35. package/types/git-context.d.mts +98 -0
  36. package/types/identity.d.mts +658 -0
  37. package/types/index.d.mts +10 -0
  38. package/types/index.d.ts +127 -0
  39. package/types/init.d.mts +53 -0
  40. package/types/lock.d.mts +18 -0
  41. package/types/operations.d.mts +204 -0
  42. package/types/paths.d.mts +54 -0
  43. package/types/projection.d.mts +79 -0
  44. package/types/sanitize.d.mts +39 -0
  45. package/types/storage.d.mts +276 -0
  46. package/types/sweep.d.mts +58 -0
  47. package/types/transcript.d.mts +59 -0
  48. package/types/types.d.mts +255 -0
  49. package/types/uuid.d.mts +17 -0
  50. package/types/watch.d.mts +33 -0
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # @druumen/sessions-db
2
+
3
+ Cross-session traceability for [Claude Code](https://claude.com/claude-code).
4
+
5
+ ## What it does
6
+
7
+ Records every Claude Code session start (cwd, branch, transcript file,
8
+ sanitized first prompt) into a local JSONL event log + projection cache.
9
+ Provides 3-priority identity reconciliation across forks, resumes, and
10
+ hub-spoke (sub-agent) relationships, so you can find related sessions
11
+ across days and worktrees without losing the thread.
12
+
13
+ **Local-only**: no network egress. All data stays on your machine.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @druumen/sessions-db
19
+ ```
20
+
21
+ Requires Node.js 18 or newer. Zero runtime dependencies.
22
+
23
+ ## Quick start
24
+
25
+ ### Library API
26
+
27
+ ```js
28
+ import {
29
+ initProjection,
30
+ loadProjection,
31
+ watchProjection,
32
+ setAlias,
33
+ setParent,
34
+ closeSession,
35
+ runSweep,
36
+ } from '@druumen/sessions-db';
37
+
38
+ // 1. Bootstrap storage at .dru-code/ in current cwd. Idempotent — safe
39
+ // to call on every app start.
40
+ const init = await initProjection({ rootPath: process.cwd() + '/.dru-code' });
41
+ if (!init.ok) throw new Error(init.error);
42
+
43
+ // 2. Load the current projection (sessions + meta).
44
+ const projection = await loadProjection({ rootPath: init.paths.eventsJsonl.replace(/\/[^/]+$/, '') });
45
+ console.log(Object.keys(projection.sessions).length, 'sessions');
46
+
47
+ // 3. Watch for changes (debounced 80ms).
48
+ const watcher = watchProjection(rootPath, (event) => {
49
+ console.log('changed:', event.type);
50
+ });
51
+ // Later: watcher.dispose();
52
+
53
+ // 4. Mutate via the operations API. Each call returns
54
+ // { ok: true, event_id } or { ok: false, error }.
55
+ await setAlias({ stableId: 'sess_xxx', alias: 'my session', rootPath });
56
+ await setParent({ childId: 'sess_xxx', parentId: 'sess_yyy', rootPath });
57
+ await closeSession({
58
+ stableId: 'sess_xxx',
59
+ outcome: 'done',
60
+ reason: 'shipped',
61
+ rootPath,
62
+ });
63
+
64
+ // 5. Sweep activity_state transitions (active → idle → archived).
65
+ const sweep = await runSweep({ rootPath, dryRun: true });
66
+ console.log(sweep.transitions.length, 'pending transitions');
67
+ ```
68
+
69
+ All operations are lock-safe (single-writer through an exclusive-create
70
+ lockfile) and idempotent at the projection level. Errors return as
71
+ `{ ok: false, error }` rather than throwing — system-class failures (disk
72
+ full, permission denied) and business-class failures (cycle, missing
73
+ session) share the same shape.
74
+
75
+ ### CLI
76
+
77
+ ```bash
78
+ npm install -g @druumen/sessions-db
79
+ sessions-db --help
80
+
81
+ sessions-db find --limit 10 # list recent sessions
82
+ sessions-db tree sess_019e0f2d-c6e3... # ancestry / descendants
83
+ sessions-db alias sess_019e0f2d-c6e3 "label" # human-readable alias
84
+ sessions-db link sess_xxx --task feat-foo.md # link to ticket / project
85
+ sessions-db link-parent sess_child sess_parent
86
+ sessions-db close sess_xxx --outcome done --reason "shipped"
87
+ sessions-db rebuild # rebuild projection from events
88
+ sessions-db sweep --dry-run # preview activity transitions
89
+ ```
90
+
91
+ The CLI is the same surface as the library API; both write through the
92
+ same primitives, so a workflow that mixes hook-driven CLI commands with
93
+ programmatic library calls observes a consistent projection.
94
+
95
+ ### Hook setup (Claude Code SessionStart)
96
+
97
+ Add to your `~/.claude/settings.json`:
98
+
99
+ ```json
100
+ {
101
+ "hooks": {
102
+ "SessionStart": [{
103
+ "hooks": [{
104
+ "type": "command",
105
+ "command": "node /absolute/path/to/node_modules/@druumen/sessions-db/cli/sessions-db-session-start.mjs",
106
+ "timeout": 5
107
+ }]
108
+ }]
109
+ }
110
+ }
111
+ ```
112
+
113
+ The hook is bootstrap-safe by design:
114
+
115
+ - Kill switch: set `DRUUMEN_SESSIONS_DB_DISABLED=1` to no-op the hook
116
+ without removing it from settings.
117
+ - 2-second hard timeout on every operation; the hook always exits 0 so
118
+ it never blocks Claude Code start, even on disk full / permission
119
+ denied / lockfile contention.
120
+ - Errors are logged to stderr (visible in Claude Code's session log)
121
+ but never surfaced as user-facing failures.
122
+
123
+ ## Path resolution
124
+
125
+ When you don't pass an explicit `rootPath`, sessions-db walks a 5-priority
126
+ chain. First hit wins:
127
+
128
+ 1. `opts.rootPath` — explicit caller arg (highest priority).
129
+ 2. `DRUUMEN_SESSIONS_DB_ROOT` — env var override (cockpit Setup Wizard,
130
+ CI matrix runs, ops incident pinning).
131
+ 3. cwd-ascend (≤12 levels) for an existing
132
+ `tickets/_logs/sessions-db.json` — preserves the druumen-monorepo
133
+ experience: any sessions-db command from anywhere inside the worktree
134
+ finds the canonical root.
135
+ 4. cwd-ascend (≤12 levels) for an existing `.dru-code/sessions-db.json`
136
+ — the new convention for fresh installs that have already been
137
+ initialized once.
138
+ 5. Default: `<cwd>/.dru-code/` — what fresh `initProjection({})` lands
139
+ when no existing storage is found. Cockpit marketplace's first
140
+ install creates this dir.
141
+
142
+ The ascend bound caps the worst-case stat budget at 24 (two candidate
143
+ file checks × 12 levels) before falling through to the default — the
144
+ resolver never accidentally walks to `/` on a slow networked mount.
145
+
146
+ The same three filenames are used at every layout:
147
+
148
+ ```
149
+ <root>/sessions-db-events.jsonl # append-only SSoT
150
+ <root>/sessions-db.json # projection cache
151
+ <root>/sessions-db.json.lock # exclusive-create lockfile
152
+ ```
153
+
154
+ ## Privacy
155
+
156
+ `first_prompt_preview` stores a sanitized 200-char excerpt of the first
157
+ user message in each session, so operators can recognize sessions in
158
+ the projection without re-opening transcripts. Sanitization strips:
159
+
160
+ - IDE-injected wrappers: `<ide_opened_file>`, `<ide_selection>`
161
+ - Slash command wrappers: `<command-name>`, `<command-message>`,
162
+ `<command-args>`
163
+ - System reminders: `<system-reminder>`, `<system>`, `<thinking>`
164
+ - Tool-use blocks: `<tool_use>`, `<tool_result>`, `<parameter>`,
165
+ `<function_calls>`
166
+
167
+ NFKC normalization is applied **before** stripping so fullwidth-bracket
168
+ splice attacks (e.g. `<system-reminder>`) cannot bypass the redactor.
169
+ The strip is double-pass — when removing one wrapper exposes a fresh
170
+ inner wrapper, the second pass catches it. Truncation is UTF-16
171
+ codepoint-safe (200 codepoints, not 200 bytes) so multi-byte characters
172
+ are not split mid-glyph.
173
+
174
+ ### Privacy opt-out (available in 0.1.0)
175
+
176
+ To disable preview storage entirely — useful for marketplace audits,
177
+ shared-machine deployments, or any user who'd rather not persist the
178
+ human-readable first prompt:
179
+
180
+ **Library API:**
181
+
182
+ ```js
183
+ import { recordSessionSeen } from '@druumen/sessions-db';
184
+
185
+ await recordSessionSeen({
186
+ claudeSessionId,
187
+ // ...other opts...
188
+ storeFirstPrompt: false, // payload.first_prompt_preview = null
189
+ });
190
+ ```
191
+
192
+ **Hook env var (Claude Code SessionStart):**
193
+
194
+ ```bash
195
+ DRUUMEN_SESSIONS_DB_STORE_PREVIEW=0 \
196
+ claude code # or whatever spawns the hook
197
+ ```
198
+
199
+ `'0'` and `'false'` (case-insensitive) opt out; anything else (or unset)
200
+ keeps the default. Default is `true` — backward compatible with the
201
+ 0.1.0-dev preview behavior.
202
+
203
+ Fingerprints (`first_human_prompt_v1`, `lineage_prefix_v1`) and
204
+ `transcript_file` metadata are intentionally **not** affected by this
205
+ opt-out, so identity reconciliation (resume / fork detection) keeps
206
+ working for opt-out users.
207
+
208
+ ## Schema
209
+
210
+ The events log (`sessions-db-events.jsonl`) is the single source of
211
+ truth; the projection (`sessions-db.json`) is a derivable cache. Run
212
+ `sessions-db rebuild` at any time to regenerate the projection from
213
+ events — useful after manual events-log inspection / surgery.
214
+
215
+ `schema_version: 2` is the stable contract for the entire 0.1.x line.
216
+ Reducers stay backward-compatible: new optional fields may appear in
217
+ 0.1.x minor releases, but no existing field is removed or repurposed.
218
+ Schema-breaking changes (rename, type change, removal) ship at 0.2.0+.
219
+
220
+ ## Versioning
221
+
222
+ 0.x semver:
223
+
224
+ - **Patch** (0.1.x): bug fixes, doc, internal refactors. No API change.
225
+ - **Minor** (0.x.0): additive only. New library exports, new CLI
226
+ subcommands, new optional projection fields. Existing surface is
227
+ unchanged.
228
+ - **Major** (1.0.0): commits the API as stable. Until then, treat 0.x as
229
+ "settling" — pin `>=0.1.0 <0.2.0` in your `package.json` if you want
230
+ field-additive but no breaking changes inside the 0.1 line.
231
+
232
+ Schema-breaking changes always coincide with at least a 0.x minor bump
233
+ (0.2.0+) and ship with a documented migration path.
234
+
235
+ ## License
236
+
237
+ Apache 2.0 — see [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
238
+
239
+ ## Roadmap
240
+
241
+ - **0.1.0** (current): Library + CLI + hook + 3-priority identity +
242
+ cross-platform (macOS / Linux verified in CI; Windows pending runner) +
243
+ privacy opt-out (`storeFirstPrompt: false` /
244
+ `DRUUMEN_SESSIONS_DB_STORE_PREVIEW=0`).
245
+ - **0.2.0** (TBD): parent_candidate auto-promote heuristic, outcome
246
+ auto-derive on `/task-done` linkage.
247
+ - **0.3.0** (TBD): Multi-machine sync (schema_version=3 break,
248
+ documented migration).
249
+ - **0.4.0+** (TBD): Web UI / VS Code Sessions panel via
250
+ [Druumen Cockpit](https://druumen.com).
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Shared helpers for write subcommands (alias / link / link-parent / close).
3
+ *
4
+ * Day 3: the actual mutation logic lives in `lib/operations.mjs`. These
5
+ * helpers handle the CLI surface only:
6
+ * - rendering planned events for `--dry-run`
7
+ * - mapping the library result `{ ok, event_id?, error? }` into stdout /
8
+ * stderr / exit code in three formats: human (default), `--json`,
9
+ * `--quiet`.
10
+ *
11
+ * Why keep the helpers? The five write handlers all share the same
12
+ * presentation logic — centralizing it keeps each handler focused on flag
13
+ * plumbing + the operations-call signature mapping.
14
+ *
15
+ * Note on output messages: the test suite regex-matches phrases like
16
+ * `ok: <op> written for <stable_id>` and `error: stable_id not found`. Any
17
+ * change to wording here MUST be paired with a sweep of __tests__/cli/*.
18
+ */
19
+
20
+ import { newEvent } from '../lib/storage.mjs';
21
+ import { formatJSON } from './format.mjs';
22
+
23
+ /**
24
+ * Render a planned event for --dry-run. Always returns the event so callers
25
+ * can post-process if they need to. Output goes to stdout for easy piping.
26
+ *
27
+ * The intent is that the rendered output be machine-grep-able (op + stable_id
28
+ * + payload as JSON) so pipelines can audit what would change without
29
+ * actually writing.
30
+ */
31
+ export function renderDryRun({ op, stableId, payload, json = false }) {
32
+ const event = newEvent({ op, stable_id: stableId, payload });
33
+ if (json) {
34
+ process.stdout.write(formatJSON({ dry_run: true, event }));
35
+ } else {
36
+ process.stdout.write(`[dry-run] would write event:\n`);
37
+ process.stdout.write(` op: ${op}\n`);
38
+ process.stdout.write(` stable_id: ${stableId}\n`);
39
+ process.stdout.write(` payload: ${JSON.stringify(payload)}\n`);
40
+ }
41
+ return event;
42
+ }
43
+
44
+ /**
45
+ * Standard success / failure feedback for write commands.
46
+ *
47
+ * Result shape comes straight from `lib/operations.mjs` —
48
+ * `{ ok, event_id?, error? }`. We render and return the exit code the caller
49
+ * should hand to process.exit().
50
+ *
51
+ * Exit code policy:
52
+ * - 0 = success
53
+ * - 1 = business error (stable_id not found, validation failure, lock
54
+ * timeout, cycle detection — anything `operations.*` returned with
55
+ * `{ ok: false }`)
56
+ *
57
+ * `--quiet` swallows stdout but preserves the exit code so cron / scripted
58
+ * usage stays observable via `$?`.
59
+ */
60
+ export function reportResult({ result, op, stableId, json, quiet, extra = {} }) {
61
+ if (quiet) return result.ok ? 0 : 1;
62
+ if (json) {
63
+ process.stdout.write(formatJSON({
64
+ ok: result.ok,
65
+ op,
66
+ stable_id: stableId,
67
+ event_id: result.event_id,
68
+ error: result.error,
69
+ ...extra,
70
+ }));
71
+ } else if (result.ok) {
72
+ process.stdout.write(`ok: ${op} written for ${stableId} (event_id=${result.event_id})\n`);
73
+ for (const [k, v] of Object.entries(extra)) {
74
+ process.stdout.write(` ${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}\n`);
75
+ }
76
+ } else {
77
+ process.stderr.write(`error: ${op} failed for ${stableId}: ${result.error}\n`);
78
+ }
79
+ return result.ok ? 0 : 1;
80
+ }
81
+
82
+ /**
83
+ * Special-case the "stable_id not found" error so the CLI prints the
84
+ * historical exact phrase the tests pin against:
85
+ *
86
+ * error: stable_id not found: <id>
87
+ *
88
+ * The operations layer uses the same wording for that error, but it embeds
89
+ * it inside the call's `result.error`. When the wrapper detects this prefix
90
+ * it re-emits the bare phrase to stderr so the existing test regex
91
+ * `/stable_id not found/` and operator muscle memory keep working.
92
+ *
93
+ * Returns the exit code the handler should hand to process.exit() —
94
+ * typically 1 for a not-found, but the caller may pass `code` to override.
95
+ */
96
+ export function reportStableIdNotFound(error, code = 1) {
97
+ process.stderr.write(`error: ${error}\n`);
98
+ return code;
99
+ }
package/cli/alias.mjs ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `sessions-db alias <stable_id> <alias>` — set or change the human-readable
3
+ * alias.
4
+ * `sessions-db alias <stable_id> --clear` — remove the alias (sets to null).
5
+ *
6
+ * Day 3 refactor: this handler is a thin wrapper around
7
+ * `lib/operations.setAlias` — argparse + dry-run rendering + result-to-exit
8
+ * mapping only. Existence-check is performed by the operation BEFORE the
9
+ * write so a typo'd stable_id surfaces as a clean exit-1 message instead
10
+ * of a synthesized empty session record.
11
+ */
12
+
13
+ import { setAlias } from '../lib/operations.mjs';
14
+ import { ArgparseError, formatHelp, parseArgs } from './argparse.mjs';
15
+ import { renderDryRun, reportResult, reportStableIdNotFound } from './_write-helpers.mjs';
16
+
17
+ const SPEC = {
18
+ positional: [
19
+ { name: 'stable_id', required: true },
20
+ { name: 'alias', required: false },
21
+ ],
22
+ flags: {
23
+ '--clear': { type: 'boolean' },
24
+ '--dry-run': { type: 'boolean' },
25
+ '--json': { type: 'boolean' },
26
+ '--root': { type: 'string' },
27
+ '--quiet': { type: 'boolean' },
28
+ },
29
+ };
30
+
31
+ export const HELP = formatHelp({
32
+ usage: 'sessions-db alias <stable_id> <alias> | sessions-db alias <stable_id> --clear',
33
+ summary: 'Set, change, or clear the human-readable alias for a session.',
34
+ flags: [
35
+ { name: '--clear', desc: 'remove the alias (sets to null)' },
36
+ { name: '--dry-run', desc: 'print the planned event but do not write' },
37
+ { name: '--json', desc: 'JSON output (machine-readable)' },
38
+ { name: '--root <p>', desc: 'override storage root (default cwd)' },
39
+ ],
40
+ examples: [
41
+ 'sessions-db alias sess_01970000-... pricing-overhaul-main',
42
+ 'sessions-db alias sess_01970000-... --clear',
43
+ ],
44
+ });
45
+
46
+ export async function run(argv) {
47
+ let parsed;
48
+ try {
49
+ parsed = parseArgs(argv, SPEC);
50
+ } catch (err) {
51
+ if (err instanceof ArgparseError) {
52
+ process.stderr.write(`error: ${err.message}\n\n${HELP}`);
53
+ process.exit(err.exitCode);
54
+ }
55
+ throw err;
56
+ }
57
+
58
+ if (parsed.helpRequested) {
59
+ process.stdout.write(HELP);
60
+ return;
61
+ }
62
+
63
+ const stableId = parsed.positional.stable_id;
64
+ const aliasArg = parsed.positional.alias;
65
+ const clear = parsed.flags['--clear'] === true;
66
+ const root = parsed.flags['--root'];
67
+ const dryRun = parsed.flags['--dry-run'] === true;
68
+ const json = parsed.flags['--json'] === true;
69
+ const quiet = parsed.flags['--quiet'] === true;
70
+
71
+ // Mutually-exclusive intent check: must be EITHER alias positional OR
72
+ // --clear. Both or neither is an argparse-class error (exit 2). The
73
+ // operations layer also rejects this combination, but doing the check
74
+ // here keeps the error stream identical to the historical CLI behavior
75
+ // (no library prefix in the message).
76
+ if (clear && aliasArg !== undefined) {
77
+ process.stderr.write(`error: alias and --clear are mutually exclusive\n`);
78
+ process.exit(2);
79
+ }
80
+ if (!clear && aliasArg === undefined) {
81
+ process.stderr.write(`error: provide an alias positional or --clear\n`);
82
+ process.exit(2);
83
+ }
84
+
85
+ if (dryRun) {
86
+ const payload = clear ? { alias: null } : { alias: aliasArg };
87
+ renderDryRun({ op: 'alias_set', stableId, payload, json });
88
+ return;
89
+ }
90
+
91
+ const opts = root ? { root } : {};
92
+ const result = clear
93
+ ? await setAlias({ stableId, clear: true, ...opts })
94
+ : await setAlias({ stableId, alias: aliasArg, ...opts });
95
+
96
+ // Stable-id-not-found gets the historical "error: stable_id not found:
97
+ // <id>" phrasing (no `op failed for ...` prefix). Operations returns the
98
+ // bare phrase as `result.error`; we recognize it and bypass reportResult
99
+ // for that one case so muscle-memory regex `/stable_id not found/` keeps
100
+ // matching.
101
+ if (!result.ok && typeof result.error === 'string'
102
+ && result.error.startsWith('stable_id not found:')) {
103
+ if (!quiet) {
104
+ const code = reportStableIdNotFound(result.error);
105
+ process.exit(code);
106
+ }
107
+ process.exit(1);
108
+ }
109
+
110
+ const code = reportResult({
111
+ result, op: 'alias_set', stableId, json, quiet,
112
+ extra: clear ? { cleared: true } : { alias: aliasArg },
113
+ });
114
+ if (code !== 0) process.exit(code);
115
+ }