@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/lib/index.mjs ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * @druumen/sessions-db — public library entry.
3
+ *
4
+ * Curated re-export hub for the v0.1.0 public surface. Library consumers
5
+ * (cockpit's primary integration target, plus any future tooling that talks
6
+ * to sessions-db without spawning the CLI) should import EXCLUSIVELY from
7
+ * `@druumen/sessions-db` (this file) — never from the deeper
8
+ * `@druumen/sessions-db/lib/<module>.mjs` paths.
9
+ *
10
+ * The depth-paths still resolve (the package.json `exports` would let them),
11
+ * but they're treated as unstable internals — no semver guarantee. This
12
+ * file is the documented surface; anything not re-exported here is subject
13
+ * to refactor without notice.
14
+ *
15
+ * Type-side mirror: `types/index.d.ts` (hand-crafted) re-exports the
16
+ * matching TypeScript types so cockpit can write
17
+ *
18
+ * import { setAlias, watchProjection, type Projection } from '@druumen/sessions-db';
19
+ *
20
+ * and resolve everything through one entry.
21
+ */
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Storage primitives — for consumers that already have a fully-built event
25
+ // and want direct lock-and-apply control. Most consumers should use
26
+ // `operations.*` (validated, structured-result wrappers) instead.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export {
30
+ loadProjection,
31
+ rebuildProjection,
32
+ recordSessionSeen,
33
+ tryUpdateProjection,
34
+ newEvent,
35
+ appendEvent,
36
+ readAllEvents,
37
+ saveProjection,
38
+ PATHS,
39
+ MAX_EVENT_BYTES,
40
+ } from './storage.mjs';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Operations — the primary write surface for library consumers. Each
44
+ // function: validates input, ensures the target session exists, writes
45
+ // the event under the projection lock, returns
46
+ // `{ ok, event_id?, error? }`.
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export {
50
+ setAlias,
51
+ linkTask,
52
+ unlinkTask,
53
+ setParent,
54
+ closeSession,
55
+ runSweep,
56
+ } from './operations.mjs';
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Lifecycle — initialize storage and watch projection for changes.
60
+ // ---------------------------------------------------------------------------
61
+
62
+ export { initProjection } from './init.mjs';
63
+ export { watchProjection } from './watch.mjs';
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Path resolution — exposed so library consumers (cockpit Setup Wizard,
67
+ // debug tooling) can introspect which storage location the resolver picks
68
+ // before they commit to it. The same chain is used internally by every
69
+ // storage primitive; surface it for explicit callers.
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export {
73
+ resolveStoragePaths,
74
+ pathsFromRoot,
75
+ STORAGE_FILENAMES,
76
+ MAX_ASCEND_DEPTH,
77
+ } from './paths.mjs';
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Identity — pure helpers for resolving stable_id from a Claude session
81
+ // signal set. Useful for consumers that want to introspect the resolution
82
+ // chain (e.g. visualize "matched by lineage" in a UI) without minting.
83
+ // ---------------------------------------------------------------------------
84
+
85
+ export {
86
+ resolveIdentity,
87
+ findByClaudeSessionId,
88
+ findByTranscriptLineage,
89
+ scanFingerprintCandidates,
90
+ collectParentCandidates,
91
+ capParentCandidates,
92
+ classifyCorroborators,
93
+ meetsThreshold,
94
+ MAX_PARENT_CANDIDATES,
95
+ STRONG_CORROBORATORS,
96
+ WEAK_CORROBORATORS,
97
+ } from './identity.mjs';
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Sweep — pure planner. `runSweep` (above) wraps these for actual writes,
101
+ // but consumers may want the planner alone (e.g. preview UI in cockpit).
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export {
105
+ computeSweepTransitions,
106
+ computeEffectiveLastProgress,
107
+ } from './sweep.mjs';
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Sanitize — pure prompt-cleanup helpers used by the hook to redact PII /
111
+ // IDE wrappers / system reminders before persistence. Re-exported so any
112
+ // consumer constructing payloads outside the hook can apply the same
113
+ // guarantees.
114
+ // ---------------------------------------------------------------------------
115
+
116
+ export {
117
+ sanitizeFirstPrompt,
118
+ stripIdeWrappers,
119
+ stripSystemReminders,
120
+ } from './sanitize.mjs';
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // UUIDv7 — session_id minter + helpers. Cockpit currently relies on
124
+ // `generateSessionId` to mint synthetic ids in tests; expose for parity
125
+ // with the internal hook.
126
+ // ---------------------------------------------------------------------------
127
+
128
+ export {
129
+ generateSessionId,
130
+ isSessionId,
131
+ extractTimestamp,
132
+ } from './uuid.mjs';
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Projection reducers — pure folders. Surface them so library consumers
136
+ // (and tests) can build projections from event arrays without importing
137
+ // the deep path.
138
+ // ---------------------------------------------------------------------------
139
+
140
+ export {
141
+ applyEvent,
142
+ emptyProjection,
143
+ emptySession,
144
+ rebuildFromEvents,
145
+ } from './projection.mjs';
package/lib/init.mjs ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Idempotent storage initializer for sessions-db.
3
+ *
4
+ * `initProjection({ rootPath })` is the entry point used by cockpit's Setup
5
+ * Wizard (and any other library consumer) to bootstrap the on-disk layout
6
+ * before the first `recordSessionSeen` / CLI write lands. Concretely it:
7
+ *
8
+ * - mkdir -p the parent directory for `tickets/_logs/` (or whatever
9
+ * `paths.eventsJsonl` resolves to)
10
+ * - create an empty (0-byte) `events.jsonl` if missing
11
+ * - create a valid empty `projection.json` (with `_meta.schema_version =
12
+ * 2`, fingerprint_versions, event_count = 0, last_event_id = null,
13
+ * sessions = {}) if missing
14
+ *
15
+ * The function is **idempotent**: calling it twice in a row leaves the
16
+ * second-call return value's `created.*` flags all `false` to indicate
17
+ * that the existing files were respected. Existing content is NEVER
18
+ * overwritten — the wizard MUST be safe to re-run.
19
+ *
20
+ * Failure mode: when permission / disk errors prevent creation we return
21
+ * `{ ok: false, error }` instead of throwing. That mirrors the rest of the
22
+ * library API (operations.mjs / tryUpdateProjection), so the wizard can
23
+ * surface errors uniformly.
24
+ *
25
+ * Why split this from storage.mjs? `loadProjection` already does a "create
26
+ * if missing → empty projection" path implicitly via rebuild-from-events,
27
+ * but it never persists that empty projection. The wizard needs visible
28
+ * on-disk artifacts so subsequent tools (file watchers, telemetry probes)
29
+ * have something to attach to.
30
+ */
31
+
32
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
33
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
34
+
35
+ import { resolveStoragePaths } from './paths.mjs';
36
+ import { PATHS } from './storage.mjs';
37
+
38
+ const SCHEMA_VERSION = 2;
39
+ const FINGERPRINT_VERSIONS = ['first_human_prompt_v1', 'lineage_prefix_v1'];
40
+
41
+ /**
42
+ * Initialize sessions-db storage at the given root.
43
+ *
44
+ * Resolution semantics (Day 4):
45
+ *
46
+ * - `opts.paths` (legacy form) — fully-formed override: each `eventsJsonl`
47
+ * / `projectionJson` is anchored on `opts.rootPath` (or treated as
48
+ * absolute if it starts with `/`). Backward-compatible with Day 3
49
+ * callers that passed `paths: { eventsJsonl: 'custom/events.jsonl', ... }`.
50
+ *
51
+ * - `opts.rootPath` (Day 4 form, no `opts.paths`) — `rootPath` IS the
52
+ * storage directory; files live directly under it as
53
+ * `<rootPath>/sessions-db-events.jsonl` and `<rootPath>/sessions-db.json`.
54
+ * This is what cockpit's Setup Wizard passes (typically resolved to
55
+ * `<workspace>/.dru-code/`).
56
+ *
57
+ * - No opts (default) — delegates to `resolveStoragePaths()` which runs
58
+ * the env > existing-storage > cwd/.dru-code chain. Useful for ad-hoc
59
+ * "init wherever the resolver thinks it should go" scripts.
60
+ *
61
+ * @param {{
62
+ * rootPath?: string,
63
+ * paths?: { eventsJsonl?: string, projectionJson?: string, lockFile?: string },
64
+ * }} [opts]
65
+ * @returns {Promise<{
66
+ * ok: boolean,
67
+ * created?: { dir: boolean, eventsJsonl: boolean, projectionJson: boolean },
68
+ * paths?: { eventsJsonl: string, projectionJson: string },
69
+ * source?: string,
70
+ * error?: string,
71
+ * }>}
72
+ */
73
+ export async function initProjection(opts) {
74
+ if (!opts || typeof opts !== 'object') {
75
+ return { ok: false, error: 'initProjection: opts required' };
76
+ }
77
+ const { rootPath } = opts;
78
+
79
+ let eventsPath;
80
+ let projectionPath;
81
+ let source = 'arg';
82
+
83
+ if (opts.paths) {
84
+ // Legacy `paths` override — anchor each rel-path against rootPath
85
+ // (required when paths is supplied) unless it's absolute. This shape
86
+ // pre-dates Day 4 and is preserved verbatim for backward compat.
87
+ if (typeof rootPath !== 'string' || rootPath.length === 0) {
88
+ return { ok: false, error: 'initProjection: rootPath required when paths override is supplied' };
89
+ }
90
+ const eventsRel = opts.paths.eventsJsonl ?? PATHS.eventsJsonl;
91
+ const projectionRel = opts.paths.projectionJson ?? PATHS.projectionJson;
92
+ const abs = (p) => (isAbsolute(p) ? p : resolve(rootPath, p));
93
+ eventsPath = abs(eventsRel);
94
+ projectionPath = abs(projectionRel);
95
+ } else if (typeof rootPath === 'string' && rootPath.length > 0) {
96
+ // Day 4 form — rootPath IS the storage dir. Delegates to resolver so
97
+ // canonical filenames stay in one place.
98
+ const r = resolveStoragePaths({ rootPath });
99
+ eventsPath = r.eventsJsonl;
100
+ projectionPath = r.projectionJson;
101
+ source = r.source;
102
+ } else {
103
+ // No opts — full default chain (env → ascend → cwd/.dru-code).
104
+ const r = resolveStoragePaths();
105
+ eventsPath = r.eventsJsonl;
106
+ projectionPath = r.projectionJson;
107
+ source = r.source;
108
+ }
109
+
110
+ // Both files share a parent dir under tickets/_logs/. Compute the deeper
111
+ // of the two so we cover both even with custom path overrides.
112
+ const dirsToCreate = new Set([dirname(eventsPath), dirname(projectionPath)]);
113
+
114
+ const created = { dir: false, eventsJsonl: false, projectionJson: false };
115
+ try {
116
+ for (const dir of dirsToCreate) {
117
+ if (!existsSync(dir)) {
118
+ mkdirSync(dir, { recursive: true });
119
+ created.dir = true;
120
+ }
121
+ }
122
+ if (!existsSync(eventsPath)) {
123
+ // Touch — empty file (0 bytes). loadEvents reads this fine; the
124
+ // first `appendEvent` call will populate it.
125
+ writeFileSync(eventsPath, '', { flag: 'wx' });
126
+ created.eventsJsonl = true;
127
+ }
128
+ if (!existsSync(projectionPath)) {
129
+ const empty = emptyProjectionLiteral();
130
+ // `flag: 'wx'` so a concurrent initializer doesn't clobber a live
131
+ // projection. existsSync check + wx flag is belt-and-suspenders;
132
+ // the existsSync race would otherwise surface as EEXIST, which we
133
+ // re-translate as "not created" rather than an error.
134
+ try {
135
+ writeFileSync(
136
+ projectionPath,
137
+ JSON.stringify(empty, null, 2),
138
+ { flag: 'wx' },
139
+ );
140
+ created.projectionJson = true;
141
+ } catch (err) {
142
+ if (err && err.code === 'EEXIST') {
143
+ // Lost the race; another initializer beat us. That's fine —
144
+ // the file exists, the contract holds.
145
+ created.projectionJson = false;
146
+ } else {
147
+ throw err;
148
+ }
149
+ }
150
+ }
151
+ } catch (err) {
152
+ return {
153
+ ok: false,
154
+ error: `initProjection: ${err && err.message ? err.message : String(err)}`,
155
+ };
156
+ }
157
+
158
+ return {
159
+ ok: true,
160
+ created,
161
+ paths: { eventsJsonl: eventsPath, projectionJson: projectionPath },
162
+ source,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Build the empty projection literal we serialize for fresh initialization.
168
+ *
169
+ * Kept inline (rather than importing `emptyProjection()` from
170
+ * `projection.mjs`) so the on-disk shape is decoupled from the in-memory
171
+ * reducer — `_meta.updated` here is set to a real timestamp so consumers
172
+ * have a non-null marker, while `applyEvent`'s `updated` is event-driven.
173
+ */
174
+ function emptyProjectionLiteral() {
175
+ return {
176
+ _meta: {
177
+ schema_version: SCHEMA_VERSION,
178
+ fingerprint_versions: [...FINGERPRINT_VERSIONS],
179
+ updated: new Date().toISOString(),
180
+ event_count: 0,
181
+ last_event_id: null,
182
+ },
183
+ sessions: {},
184
+ };
185
+ }
package/lib/lock.mjs ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * File-based exclusive lock helper for sessions-db projection writes.
3
+ *
4
+ * Uses POSIX `O_CREAT | O_EXCL` semantics via `fs.openSync(path, 'wx')`:
5
+ * - Atomic create-or-fail across processes on a single filesystem
6
+ * - On EEXIST we retry until either the lock is released by the holder or
7
+ * the timeout window elapses
8
+ *
9
+ * The lock file content is `<pid>\t<iso-ts>\n` (one line). Future phases will
10
+ * use the embedded PID for stale-lock detection (kill -0 PID); this phase
11
+ * intentionally does not implement stale recovery — Phase 1 ticket §"Stale
12
+ * lock detection (PID-based)" is explicitly out of scope.
13
+ *
14
+ * Zero new npm deps: only `node:fs`, `node:timers/promises`.
15
+ */
16
+
17
+ import { closeSync, openSync, unlinkSync, writeSync } from 'node:fs';
18
+ import { setTimeout as sleep } from 'node:timers/promises';
19
+
20
+ const DEFAULT_TIMEOUT_MS = 5000;
21
+ const DEFAULT_RETRY_MS = 50;
22
+
23
+ /**
24
+ * Acquire an exclusive lock on `lockPath`.
25
+ *
26
+ * @param {string} lockPath - Absolute path to the lock file. Parent dir must
27
+ * exist; we do not mkdir-p (callers control layout).
28
+ * @param {{ timeoutMs?: number, retryMs?: number }} [opts]
29
+ * @returns {Promise<{ release: () => void }>} - Resolves with a release
30
+ * handle. `release()` is idempotent: calling it twice is a no-op.
31
+ *
32
+ * Throws on timeout: `Error("acquireLock: timeout after <ms>ms (path=...)").`
33
+ * Re-throws unexpected fs errors verbatim (anything other than EEXIST).
34
+ */
35
+ export async function acquireLock(lockPath, opts = {}) {
36
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
37
+ const retryMs = opts.retryMs ?? DEFAULT_RETRY_MS;
38
+ const deadline = Date.now() + timeoutMs;
39
+
40
+ while (true) {
41
+ let fd;
42
+ try {
43
+ // 'wx' === O_WRONLY | O_CREAT | O_EXCL — atomic create-or-fail.
44
+ fd = openSync(lockPath, 'wx');
45
+ } catch (err) {
46
+ if (err && err.code === 'EEXIST') {
47
+ if (Date.now() >= deadline) {
48
+ throw new Error(
49
+ `acquireLock: timeout after ${timeoutMs}ms (path=${lockPath})`,
50
+ );
51
+ }
52
+ await sleep(retryMs);
53
+ continue;
54
+ }
55
+ throw err;
56
+ }
57
+
58
+ // Stamp PID + iso ts so future stale-lock detection has the metadata
59
+ // it needs. Failure to write metadata still gives us the lock — release
60
+ // proceeds normally.
61
+ try {
62
+ const stamp = `${process.pid}\t${new Date().toISOString()}\n`;
63
+ writeSync(fd, stamp);
64
+ } catch {
65
+ // Non-fatal: keep the lock, swallow metadata write error.
66
+ }
67
+
68
+ let released = false;
69
+ const release = () => {
70
+ if (released) return;
71
+ released = true;
72
+ try {
73
+ closeSync(fd);
74
+ } catch {
75
+ // fd may already be closed in edge cases — ignore.
76
+ }
77
+ try {
78
+ unlinkSync(lockPath);
79
+ } catch {
80
+ // lock may already be gone — ignore.
81
+ }
82
+ };
83
+
84
+ return { release };
85
+ }
86
+ }