@forwardimpact/outpost 3.0.3

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 (120) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +15 -0
  3. package/bin/fit-outpost.js +3 -0
  4. package/config/scheduler.json +34 -0
  5. package/package.json +63 -0
  6. package/src/agent-runner.js +189 -0
  7. package/src/index.js +5 -0
  8. package/src/kb-manager.js +228 -0
  9. package/src/outpost.js +417 -0
  10. package/src/scheduler.js +195 -0
  11. package/src/socket-server.js +336 -0
  12. package/src/state-manager.js +126 -0
  13. package/templates/.claude/agents/chief-of-staff.md +58 -0
  14. package/templates/.claude/agents/concierge.md +46 -0
  15. package/templates/.claude/agents/head-hunter.md +57 -0
  16. package/templates/.claude/agents/librarian.md +60 -0
  17. package/templates/.claude/agents/postman.md +47 -0
  18. package/templates/.claude/agents/recruiter.md +63 -0
  19. package/templates/.claude/settings.json +88 -0
  20. package/templates/.claude/skills/candidate-report/SKILL.md +139 -0
  21. package/templates/.claude/skills/candidate-report/references/report-template.html +203 -0
  22. package/templates/.claude/skills/candidate-report/references/report.css +438 -0
  23. package/templates/.claude/skills/candidate-report/references/rubric.md +76 -0
  24. package/templates/.claude/skills/candidate-report/scripts/render-pdf.mjs +48 -0
  25. package/templates/.claude/skills/deck-create/SKILL.md +75 -0
  26. package/templates/.claude/skills/deck-create/references/slide.css +35 -0
  27. package/templates/.claude/skills/deck-create/scripts/convert-to-pdf.mjs +47 -0
  28. package/templates/.claude/skills/deck-summarize/SKILL.md +139 -0
  29. package/templates/.claude/skills/deck-summarize/references/brief-template.md +85 -0
  30. package/templates/.claude/skills/deck-summarize/scripts/extract-pptx.mjs +210 -0
  31. package/templates/.claude/skills/doc-collab/SKILL.md +112 -0
  32. package/templates/.claude/skills/doc-create/SKILL.md +105 -0
  33. package/templates/.claude/skills/doc-create/scripts/convert-to-pdf.mjs +50 -0
  34. package/templates/.claude/skills/draft-emails/SKILL.md +141 -0
  35. package/templates/.claude/skills/draft-emails/references/template.md +26 -0
  36. package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
  37. package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +139 -0
  38. package/templates/.claude/skills/extract-entities/SKILL.md +187 -0
  39. package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +14 -0
  40. package/templates/.claude/skills/extract-entities/references/conditions.md +55 -0
  41. package/templates/.claude/skills/extract-entities/references/content.md +104 -0
  42. package/templates/.claude/skills/extract-entities/references/links.md +48 -0
  43. package/templates/.claude/skills/extract-entities/references/recruitment.md +57 -0
  44. package/templates/.claude/skills/extract-entities/references/resolution.md +71 -0
  45. package/templates/.claude/skills/extract-entities/references/sources.md +70 -0
  46. package/templates/.claude/skills/extract-entities/references/templates-conditions.md +36 -0
  47. package/templates/.claude/skills/extract-entities/references/templates-goals-priorities.md +63 -0
  48. package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +67 -0
  49. package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +68 -0
  50. package/templates/.claude/skills/extract-entities/scripts/state.mjs +131 -0
  51. package/templates/.claude/skills/hyprnote-follow/SKILL.md +174 -0
  52. package/templates/.claude/skills/hyprnote-follow/references/coaching.md +118 -0
  53. package/templates/.claude/skills/hyprnote-follow/references/debrief.md +59 -0
  54. package/templates/.claude/skills/hyprnote-follow/references/meeting-types.md +14 -0
  55. package/templates/.claude/skills/hyprnote-follow/scripts/follow.mjs +243 -0
  56. package/templates/.claude/skills/hyprnote-process/SKILL.md +147 -0
  57. package/templates/.claude/skills/hyprnote-process/references/extraction.md +84 -0
  58. package/templates/.claude/skills/hyprnote-process/references/sessions.md +81 -0
  59. package/templates/.claude/skills/hyprnote-process/scripts/scan.mjs +246 -0
  60. package/templates/.claude/skills/hyprnote-trim/SKILL.md +177 -0
  61. package/templates/.claude/skills/manage-tasks/SKILL.md +152 -0
  62. package/templates/.claude/skills/manage-tasks/references/format.md +73 -0
  63. package/templates/.claude/skills/meeting-prep/SKILL.md +180 -0
  64. package/templates/.claude/skills/organize-files/SKILL.md +146 -0
  65. package/templates/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
  66. package/templates/.claude/skills/organize-files/scripts/summarize.mjs +91 -0
  67. package/templates/.claude/skills/req-assess/SKILL.md +137 -0
  68. package/templates/.claude/skills/req-assess/references/interview-template.md +62 -0
  69. package/templates/.claude/skills/req-assess/references/panel-template.md +117 -0
  70. package/templates/.claude/skills/req-assess/references/rubric.md +65 -0
  71. package/templates/.claude/skills/req-decide/SKILL.md +163 -0
  72. package/templates/.claude/skills/req-decide/references/rubric.md +63 -0
  73. package/templates/.claude/skills/req-decide/references/template.md +118 -0
  74. package/templates/.claude/skills/req-forget/SKILL.md +154 -0
  75. package/templates/.claude/skills/req-forget/references/classify.md +23 -0
  76. package/templates/.claude/skills/req-forget/references/locations.md +59 -0
  77. package/templates/.claude/skills/req-forget/references/report-template.md +67 -0
  78. package/templates/.claude/skills/req-scan/SKILL.md +145 -0
  79. package/templates/.claude/skills/req-scan/references/fallbacks.md +76 -0
  80. package/templates/.claude/skills/req-scan/references/filters.md +83 -0
  81. package/templates/.claude/skills/req-scan/references/sources.md +76 -0
  82. package/templates/.claude/skills/req-scan/references/state.md +54 -0
  83. package/templates/.claude/skills/req-scan/references/template.md +36 -0
  84. package/templates/.claude/skills/req-scan/scripts/state.mjs +396 -0
  85. package/templates/.claude/skills/req-screen/SKILL.md +151 -0
  86. package/templates/.claude/skills/req-screen/references/rubric.md +94 -0
  87. package/templates/.claude/skills/req-screen/references/template.md +91 -0
  88. package/templates/.claude/skills/req-track/SKILL.md +189 -0
  89. package/templates/.claude/skills/req-track/references/fields.md +114 -0
  90. package/templates/.claude/skills/req-track/references/signals.md +42 -0
  91. package/templates/.claude/skills/req-track/references/statuses.md +32 -0
  92. package/templates/.claude/skills/req-track/references/templates.md +127 -0
  93. package/templates/.claude/skills/req-workday/SKILL.md +162 -0
  94. package/templates/.claude/skills/req-workday/references/field-mapping.md +40 -0
  95. package/templates/.claude/skills/req-workday/references/status-mapping.md +38 -0
  96. package/templates/.claude/skills/req-workday/references/templates.md +126 -0
  97. package/templates/.claude/skills/req-workday/references/xlsx-format.md +56 -0
  98. package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +313 -0
  99. package/templates/.claude/skills/send-chat/SKILL.md +170 -0
  100. package/templates/.claude/skills/sync-apple-calendar/SKILL.md +145 -0
  101. package/templates/.claude/skills/sync-apple-calendar/references/SCHEMA.md +80 -0
  102. package/templates/.claude/skills/sync-apple-calendar/scripts/query.mjs +307 -0
  103. package/templates/.claude/skills/sync-apple-calendar/scripts/sync.mjs +332 -0
  104. package/templates/.claude/skills/sync-apple-mail/SKILL.md +151 -0
  105. package/templates/.claude/skills/sync-apple-mail/references/SCHEMA.md +115 -0
  106. package/templates/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +389 -0
  107. package/templates/.claude/skills/sync-apple-mail/scripts/sync-helpers.mjs +372 -0
  108. package/templates/.claude/skills/sync-apple-mail/scripts/sync.mjs +239 -0
  109. package/templates/.claude/skills/sync-teams/SKILL.md +177 -0
  110. package/templates/.claude/skills/sync-teams/scripts/idb-reader.mjs +147 -0
  111. package/templates/.claude/skills/sync-teams/scripts/leveldb-reader.mjs +288 -0
  112. package/templates/.claude/skills/sync-teams/scripts/sync.mjs +381 -0
  113. package/templates/.claude/skills/upstream-skill/SKILL.md +130 -0
  114. package/templates/.claude/skills/upstream-skill/references/examples.md +80 -0
  115. package/templates/.claude/skills/weekly-update/SKILL.md +141 -0
  116. package/templates/.claude/skills/weekly-update/references/template.md +55 -0
  117. package/templates/CLAUDE.md +134 -0
  118. package/templates/USER.md +3 -0
  119. package/templates/apm.yml +5 -0
  120. package/templates/knowledge/Briefings/.gitkeep +0 -0
@@ -0,0 +1,177 @@
1
+ ---
2
+ name: sync-teams
3
+ description: Sync recent Microsoft Teams chat messages into ~/.cache/fit/outpost/teams_chat/ as markdown files by reading the Teams IndexedDB cache from disk. Use on a schedule or when the user asks to sync their Teams chats. Requires macOS with the Teams desktop app installed.
4
+ compatibility: Requires macOS with Microsoft Teams desktop app (com.microsoft.teams2) installed
5
+ ---
6
+
7
+ # Sync Teams
8
+
9
+ Sync recent Microsoft Teams chat messages into
10
+ `~/.cache/fit/outpost/teams_chat/` as markdown files. This is an automated data
11
+ pipeline skill — it ingests chat data that other skills (like
12
+ `extract-entities`) consume downstream.
13
+
14
+ This skill reads the Teams IndexedDB cache directly from disk — no browser
15
+ automation, no API tokens, no network access needed. The Teams desktop app
16
+ (which uses Edge WebView2) stores conversations and messages in a LevelDB-backed
17
+ IndexedDB at a known location. This skill parses those files, deserializes the
18
+ V8-encoded records, and writes markdown.
19
+
20
+ ## Trigger
21
+
22
+ Run this skill on a schedule (every 15 minutes) or when the user asks to sync
23
+ their Teams chats.
24
+
25
+ ## Prerequisites
26
+
27
+ - macOS with the Microsoft Teams desktop app installed (`com.microsoft.teams2`)
28
+ - `snappyjs` npm package installed (`npm install snappyjs`)
29
+ - Node.js 20+ (uses `node:v8` built-in for deserialization)
30
+
31
+ ## Inputs
32
+
33
+ - `~/Library/Containers/com.microsoft.teams2/Data/Library/Application Support/Microsoft/MSTeams/EBWebView/WV2Profile_tfw/IndexedDB/https_teams.microsoft.com_0.indexeddb.leveldb/`
34
+ — Teams IndexedDB (LevelDB on disk)
35
+ - `~/.cache/fit/outpost/state/teams_last_sync` — ISO timestamp of last sync
36
+ - `~/.cache/fit/outpost/state/teams_chat_index.tsv` — index of known chats
37
+
38
+ ## Outputs
39
+
40
+ - `~/.cache/fit/outpost/teams_chat/{slug}.md` — one markdown file per chat
41
+ (overwritten each sync with current state)
42
+ - `~/.cache/fit/outpost/state/teams_last_sync` — updated with sync timestamp
43
+ - `~/.cache/fit/outpost/state/teams_chat_index.tsv` — updated chat index
44
+
45
+ ---
46
+
47
+ ## Implementation
48
+
49
+ Run the sync as a single Node.js script:
50
+
51
+ node scripts/sync.mjs [--days N]
52
+
53
+ - `--days N` — only include messages from the last N days (default: 30)
54
+
55
+ The script:
56
+
57
+ 1. Reads all LevelDB `.ldb` (SSTable) and `.log` (write-ahead log) files from
58
+ the Teams IndexedDB directory
59
+ 2. Decompresses Snappy-compressed blocks and deserializes V8-encoded values
60
+ using Node's built-in `v8.deserialize()`
61
+ 3. Extracts conversation records (with member lists, topics, chat type) and
62
+ message records (with sender names, HTML content, timestamps)
63
+ 4. Groups messages by conversation, filters by date window, and converts HTML
64
+ content to plain text
65
+ 5. Writes one markdown file per chat to `~/.cache/fit/outpost/teams_chat/`
66
+ 6. Updates sync state (timestamp and chat index)
67
+
68
+ ### Architecture
69
+
70
+ Three modules, following the same pattern as `sync-apple-mail`:
71
+
72
+ | Module | Purpose |
73
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
74
+ | `scripts/leveldb-reader.mjs` | Parse LevelDB SSTable and WAL files. Handles Snappy decompression. No external dependencies except `snappyjs`. |
75
+ | `scripts/idb-reader.mjs` | Chromium IndexedDB layer. Strips Blink envelope, calls `v8.deserialize()`, classifies records as conversations or messages. |
76
+ | `scripts/sync.mjs` | Main sync script. Reads data, groups by conversation, normalizes names, writes markdown and state. |
77
+
78
+ ### How It Works
79
+
80
+ The Teams desktop app uses Edge WebView2 internally. WebView2 stores IndexedDB
81
+ data in LevelDB (the same way Chrome does). The key databases are:
82
+
83
+ - **conversation-manager** — stores conversation metadata: ID, type (Chat vs
84
+ Thread), members, topic, last message time
85
+ - **replychain-manager** — stores actual messages: sender display name, HTML
86
+ content, timestamps, reactions, edit status
87
+
88
+ Since LevelDB is an append-only format, files can be read while Teams is
89
+ running. Newer `.ldb` files supersede older ones for the same records.
90
+
91
+ ### Name Resolution
92
+
93
+ Teams conversation records don't store human-readable member names — only orgid
94
+ identifiers. Display names are resolved from:
95
+
96
+ 1. **Conversation topic** (for named group chats)
97
+ 2. **Message sender names** (`imDisplayName` field) — for 1:1 chats, the chat is
98
+ named after the other participant(s)
99
+
100
+ ## Output Format
101
+
102
+ Each `{slug}.md` file follows the same format as the previous browser-based
103
+ implementation:
104
+
105
+ **1:1 chat:**
106
+
107
+ ```markdown
108
+ # Chat with {First Last}
109
+
110
+ **Platform:** Microsoft Teams
111
+ **Last Synced:** {YYYY-MM-DD}
112
+
113
+ ---
114
+
115
+ ### {Sender First Last}
116
+ **Date:** {YYYY-MM-DD HH:MM:SS}
117
+
118
+ {message content}
119
+
120
+ ---
121
+ ```
122
+
123
+ **Group chat:**
124
+
125
+ ```markdown
126
+ # Chat: {Topic} (group)
127
+
128
+ **Platform:** Microsoft Teams
129
+ **Type:** Group chat
130
+ **Participants:** {Name1}, {Name2}, ...
131
+ **Last Synced:** {YYYY-MM-DD}
132
+
133
+ ---
134
+
135
+ ### {Sender First Last}
136
+ **Date:** {YYYY-MM-DD HH:MM:SS}
137
+
138
+ {message content}
139
+ ```
140
+
141
+ Key conventions:
142
+
143
+ - Messages in **chronological order** (oldest first)
144
+ - **Normalize names** from Teams format ("Last, First") to "First Last"
145
+ - **Platform** line distinguishes Teams from email in downstream processing
146
+ - **Plain text only** — HTML is stripped, mentions are preserved as plain text
147
+ - Skip system messages (calls, member adds/removes, topic changes)
148
+
149
+ ## Error Handling
150
+
151
+ - Teams app not installed → report and stop
152
+ - IndexedDB directory missing → report and stop
153
+ - LevelDB file parse error → skip that file, continue with others
154
+ - V8 deserialization failure → skip that record, continue
155
+ - Snappy decompression failure → skip that block, continue
156
+ - Empty chat (no messages in window) → skip, don't write a file
157
+ - Always update sync state, even on partial success
158
+
159
+ ## Constraints
160
+
161
+ - **Read-only.** Never writes to the Teams IndexedDB or sends messages.
162
+ - **Cache-dependent.** Only conversations cached locally by Teams are available.
163
+ This covers recently viewed chats, not full history.
164
+ - **Both 1:1 and group chats** are synced (channels are excluded).
165
+ - **No message limit per chat** — all cached messages within the `--days` window
166
+ are included.
167
+
168
+ ## Limitations
169
+
170
+ - The IndexedDB is a **cache, not an archive**. Only conversations the user has
171
+ recently opened in Teams will have cached message data. Older conversations
172
+ that haven't been opened may have conversation metadata but no messages.
173
+ - Clearing the Teams cache (a common troubleshooting step) will temporarily
174
+ remove all local data until Teams rebuilds it from the server.
175
+ - Some V8-serialized records (~17% in testing) use formats that
176
+ `v8.deserialize()` cannot decode. These are silently skipped — they are
177
+ typically IndexedDB metadata, not conversation or message records.
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Read Chromium IndexedDB records from a LevelDB-backed store.
4
+ *
5
+ * Chromium stores IndexedDB data in LevelDB with a specific key encoding
6
+ * (database ID, object store ID) and V8-serialized values wrapped in a Blink
7
+ * envelope. This module handles the key parsing and value deserialization.
8
+ *
9
+ * Exports: readIndexedDb(dir) → { conversations: Map, messages: Map }
10
+ */
11
+
12
+ import v8 from "node:v8";
13
+ import { readAllEntries } from "./leveldb-reader.mjs";
14
+
15
+ // Chromium IndexedDB key prefix types (from indexed_db_leveldb_coding.h)
16
+ // Key format: [database_id varint] [object_store_id varint] [key_type byte] [...]
17
+ // We care about object store data records (key_type = 1) and database metadata
18
+
19
+ function readIdbVarint(buf, offset) {
20
+ let result = 0;
21
+ let shift = 0;
22
+ let pos = offset;
23
+ while (pos < buf.length) {
24
+ const byte = buf[pos];
25
+ result |= (byte & 0x7f) << shift;
26
+ pos++;
27
+ if ((byte & 0x80) === 0) break;
28
+ shift += 7;
29
+ if (shift > 35) break;
30
+ }
31
+ return { value: result, bytesRead: pos - offset };
32
+ }
33
+
34
+ /**
35
+ * Try to deserialize from the second 0xFF marker within the first `limit` bytes.
36
+ * The Blink envelope has: [varint wire_size] [0xFF blink_ver] [envelope...] [0xFF v8_ver] [V8 data]
37
+ * We want the second 0xFF that starts valid V8 data.
38
+ */
39
+ function deserializeFromSecondMarker(rawValue, limit) {
40
+ let ffCount = 0;
41
+ for (let i = 0; i < limit; i++) {
42
+ if (rawValue[i] !== 0xff) continue;
43
+ ffCount++;
44
+ if (ffCount >= 2) {
45
+ try {
46
+ return v8.deserialize(rawValue.subarray(i));
47
+ } catch {
48
+ // keep scanning
49
+ }
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Fallback: try deserializing from every 0xFF position within `limit` bytes.
57
+ */
58
+ function deserializeFromAnyMarker(rawValue, limit) {
59
+ for (let i = 0; i < limit; i++) {
60
+ if (rawValue[i] !== 0xff) continue;
61
+ try {
62
+ return v8.deserialize(rawValue.subarray(i));
63
+ } catch {
64
+ continue;
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Try to deserialize a Chromium IndexedDB value.
72
+ * Values have a Blink envelope before the V8 payload.
73
+ * Scans for the V8 version tag (0xFF) and attempts deserialization.
74
+ */
75
+ function tryDeserialize(rawValue) {
76
+ if (!rawValue || rawValue.length < 4) return null;
77
+
78
+ const headerLimit = Math.min(rawValue.length, 60);
79
+ const result = deserializeFromSecondMarker(rawValue, headerLimit);
80
+ if (result !== null) return result;
81
+
82
+ const fallbackLimit = Math.min(rawValue.length, 100);
83
+ return deserializeFromAnyMarker(rawValue, fallbackLimit);
84
+ }
85
+
86
+ /**
87
+ * Parse a Chromium IndexedDB key prefix to extract database and object store IDs.
88
+ * Returns null if the key doesn't look like an IndexedDB data record.
89
+ */
90
+ function _parseKeyPrefix(key) {
91
+ if (key.length < 3) return null;
92
+ const db = readIdbVarint(key, 0);
93
+ if (db.bytesRead + 1 > key.length) return null;
94
+ const os = readIdbVarint(key, db.bytesRead);
95
+ return {
96
+ databaseId: db.value,
97
+ objectStoreId: os.value,
98
+ remaining: key.subarray(db.bytesRead + os.bytesRead),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Read all IndexedDB records from a Chromium LevelDB directory and return
104
+ * conversations and messages.
105
+ *
106
+ * @param {string} dir - path to the .indexeddb.leveldb directory
107
+ * @returns {{ conversations: object[], messages: object[] }}
108
+ */
109
+ export function readIndexedDb(dir) {
110
+ // Use Maps so later entries (from newer .ldb files) overwrite older ones.
111
+ // LevelDB reads files in ascending order — newer compactions have higher
112
+ // numbers, so the last write for a given key is the most current.
113
+ const convMap = new Map();
114
+ const msgMap = new Map();
115
+
116
+ for (const entry of readAllEntries(dir)) {
117
+ const obj = tryDeserialize(entry.value);
118
+ if (!obj || typeof obj !== "object") continue;
119
+
120
+ if (isConversation(obj)) {
121
+ const id = obj.id;
122
+ if (id) convMap.set(id, obj);
123
+ continue;
124
+ }
125
+
126
+ if (obj.messageMap && obj.conversationId) {
127
+ const rcId = `${obj.conversationId}:${obj.replyChainId ?? ""}`;
128
+ msgMap.set(rcId, obj);
129
+ }
130
+ }
131
+
132
+ return {
133
+ conversations: [...convMap.values()],
134
+ messages: [...msgMap.values()],
135
+ };
136
+ }
137
+
138
+ function isConversation(obj) {
139
+ return (
140
+ obj.id &&
141
+ typeof obj.id === "string" &&
142
+ (obj.type === "Chat" || obj.type === "Thread") &&
143
+ (obj.members !== undefined ||
144
+ obj.threadProperties !== undefined ||
145
+ obj.lastMessageTimeUtc !== undefined)
146
+ );
147
+ }
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Read LevelDB SSTable (.ldb) and write-ahead log (.log) files.
4
+ *
5
+ * Parses the binary SSTable format directly — no LevelDB library needed.
6
+ * Handles Snappy-compressed blocks via snappyjs (pure JS, zero native deps).
7
+ *
8
+ * Exports a single generator function: readAllEntries(directory)
9
+ */
10
+
11
+ import { readFileSync, readdirSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import SnappyJS from "snappyjs";
14
+
15
+ const uncompress =
16
+ SnappyJS.uncompress ?? SnappyJS.default?.uncompress ?? SnappyJS;
17
+
18
+ const LEVELDB_TABLE_MAGIC = 0xdb4775248b80fb57n;
19
+
20
+ function readFixed32(buf, offset) {
21
+ return buf.readUInt32LE(offset);
22
+ }
23
+
24
+ function readFixed64(buf, offset) {
25
+ const lo = buf.readUInt32LE(offset);
26
+ const hi = buf.readUInt32LE(offset + 4);
27
+ return BigInt(hi) * 0x100000000n + BigInt(lo);
28
+ }
29
+
30
+ function readVarint(buf, offset) {
31
+ let result = 0;
32
+ let shift = 0;
33
+ let pos = offset;
34
+ while (pos < buf.length) {
35
+ const byte = buf[pos];
36
+ result |= (byte & 0x7f) << shift;
37
+ pos++;
38
+ if ((byte & 0x80) === 0) break;
39
+ shift += 7;
40
+ if (shift > 35) break;
41
+ }
42
+ return { value: result, bytesRead: pos - offset };
43
+ }
44
+
45
+ function decompressBlock(raw, compressionType) {
46
+ if (compressionType === 1) {
47
+ try {
48
+ return Buffer.from(uncompress(raw));
49
+ } catch {
50
+ return raw;
51
+ }
52
+ }
53
+ return raw;
54
+ }
55
+
56
+ function parseBlockEntries(blockData) {
57
+ const entries = [];
58
+ if (blockData.length < 4) return entries;
59
+
60
+ const numRestarts = readFixed32(blockData, blockData.length - 4);
61
+ const restartsOffset = blockData.length - 4 - numRestarts * 4;
62
+ if (restartsOffset < 0) return entries;
63
+
64
+ let pos = 0;
65
+ let prevKey = Buffer.alloc(0);
66
+
67
+ while (pos < restartsOffset) {
68
+ if (pos + 3 > blockData.length) break;
69
+
70
+ const shared = readVarint(blockData, pos);
71
+ pos += shared.bytesRead;
72
+ const nonShared = readVarint(blockData, pos);
73
+ pos += nonShared.bytesRead;
74
+ const valueLen = readVarint(blockData, pos);
75
+ pos += valueLen.bytesRead;
76
+
77
+ if (pos + nonShared.value + valueLen.value > blockData.length) break;
78
+
79
+ const keyDelta = blockData.subarray(pos, pos + nonShared.value);
80
+ pos += nonShared.value;
81
+
82
+ const key = Buffer.concat([prevKey.subarray(0, shared.value), keyDelta]);
83
+ prevKey = key;
84
+
85
+ const value = Buffer.from(blockData.subarray(pos, pos + valueLen.value));
86
+ pos += valueLen.value;
87
+
88
+ entries.push({ key, value });
89
+ }
90
+ return entries;
91
+ }
92
+
93
+ function readBlock(fileData, offset, size) {
94
+ if (offset + size + 5 > fileData.length) return null;
95
+ const raw = fileData.subarray(offset, offset + size);
96
+ const compressionType = fileData[offset + size];
97
+ return decompressBlock(raw, compressionType);
98
+ }
99
+
100
+ function parseFooter(fileData) {
101
+ const footer = fileData.subarray(fileData.length - 48);
102
+ const mi = readVarint(footer, 0);
103
+ readVarint(footer, mi.bytesRead);
104
+ let pos = mi.bytesRead + readVarint(footer, mi.bytesRead).bytesRead;
105
+ const idx = readVarint(footer, pos);
106
+ pos += idx.bytesRead;
107
+ const idx2 = readVarint(footer, pos);
108
+ return { indexOffset: idx.value, indexSize: idx2.value };
109
+ }
110
+
111
+ function* readSstEntries(filePath) {
112
+ let fileData;
113
+ try {
114
+ fileData = readFileSync(filePath);
115
+ } catch {
116
+ return;
117
+ }
118
+ if (fileData.length < 48) return;
119
+
120
+ const magic = readFixed64(fileData, fileData.length - 8);
121
+ if (magic !== LEVELDB_TABLE_MAGIC) return;
122
+
123
+ let footer;
124
+ try {
125
+ footer = parseFooter(fileData);
126
+ } catch {
127
+ return;
128
+ }
129
+
130
+ const indexBlock = readBlock(fileData, footer.indexOffset, footer.indexSize);
131
+ if (!indexBlock) return;
132
+
133
+ for (const indexEntry of parseBlockEntries(indexBlock)) {
134
+ const handle = readVarint(indexEntry.value, 0);
135
+ const handleSize = readVarint(indexEntry.value, handle.bytesRead);
136
+
137
+ const dataBlock = readBlock(fileData, handle.value, handleSize.value);
138
+ if (!dataBlock) continue;
139
+
140
+ yield* parseBlockEntries(dataBlock);
141
+ }
142
+ }
143
+
144
+ // LevelDB write-ahead log format: 32 KB blocks with record headers
145
+ const LOG_BLOCK_SIZE = 32768;
146
+ const LOG_HEADER_SIZE = 7; // checksum(4) + length(2) + type(1)
147
+
148
+ /**
149
+ * Parse records from a single WAL block, yielding { payload, type } for each.
150
+ */
151
+ function* parseLogBlock(fileData, blockStart, blockEnd) {
152
+ let offset = blockStart;
153
+ while (offset + LOG_HEADER_SIZE <= blockEnd) {
154
+ const length = fileData.readUInt16LE(offset + 4);
155
+ const type = fileData[offset + 6];
156
+ if (type === 0 || length === 0) break;
157
+
158
+ const payload = fileData.subarray(
159
+ offset + LOG_HEADER_SIZE,
160
+ offset + LOG_HEADER_SIZE + length,
161
+ );
162
+ yield { payload, type };
163
+ offset += LOG_HEADER_SIZE + length;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Accumulate record fragments across blocks.
169
+ * Type 1 = full, 2 = first, 3 = middle, 4 = last.
170
+ * Returns the updated pending buffer (or null).
171
+ */
172
+ function handleFragment(type, payload, pendingRecord, emit) {
173
+ if (type === 1) {
174
+ emit(payload);
175
+ return null;
176
+ }
177
+ if (type === 2) return [payload];
178
+ if (type === 3) {
179
+ if (pendingRecord) pendingRecord.push(payload);
180
+ return pendingRecord;
181
+ }
182
+ if (type === 4 && pendingRecord) {
183
+ pendingRecord.push(payload);
184
+ emit(Buffer.concat(pendingRecord));
185
+ return null;
186
+ }
187
+ return pendingRecord;
188
+ }
189
+
190
+ function* readLogEntries(filePath) {
191
+ let fileData;
192
+ try {
193
+ fileData = readFileSync(filePath);
194
+ } catch {
195
+ return;
196
+ }
197
+
198
+ let pos = 0;
199
+ let pendingRecord = null;
200
+ const completed = [];
201
+
202
+ while (pos < fileData.length) {
203
+ const blockEnd = Math.min(pos + LOG_BLOCK_SIZE, fileData.length);
204
+
205
+ for (const { payload, type } of parseLogBlock(fileData, pos, blockEnd)) {
206
+ pendingRecord = handleFragment(type, payload, pendingRecord, (buf) =>
207
+ completed.push(buf),
208
+ );
209
+ }
210
+
211
+ for (const buf of completed) {
212
+ yield* parseWriteBatchEntries(buf);
213
+ }
214
+ completed.length = 0;
215
+
216
+ pos = blockEnd;
217
+ }
218
+ }
219
+
220
+ function* parseWriteBatchEntries(batchData) {
221
+ if (batchData.length < 12) return;
222
+
223
+ // WriteBatch header: sequence(8) + count(4)
224
+ const count = readFixed32(batchData, 8);
225
+ let pos = 12;
226
+
227
+ for (let i = 0; i < count && pos < batchData.length; i++) {
228
+ const tag = batchData[pos];
229
+ pos++;
230
+
231
+ if (tag === 1) {
232
+ // Put
233
+ const keyLen = readVarint(batchData, pos);
234
+ pos += keyLen.bytesRead;
235
+ if (pos + keyLen.value > batchData.length) break;
236
+ const key = Buffer.from(batchData.subarray(pos, pos + keyLen.value));
237
+ pos += keyLen.value;
238
+
239
+ const valLen = readVarint(batchData, pos);
240
+ pos += valLen.bytesRead;
241
+ if (pos + valLen.value > batchData.length) break;
242
+ const value = Buffer.from(batchData.subarray(pos, pos + valLen.value));
243
+ pos += valLen.value;
244
+
245
+ yield { key, value };
246
+ } else if (tag === 0) {
247
+ // Delete — skip the key
248
+ const keyLen = readVarint(batchData, pos);
249
+ pos += keyLen.bytesRead;
250
+ pos += keyLen.value;
251
+ } else {
252
+ break;
253
+ }
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Read all key-value entries from a LevelDB directory.
259
+ * Yields { key: Buffer, value: Buffer } for each entry.
260
+ * Reads .ldb files (SSTables) and .log files (write-ahead log).
261
+ *
262
+ * @param {string} dir - path to the LevelDB directory
263
+ * @yields {{ key: Buffer, value: Buffer }}
264
+ */
265
+ export function* readAllEntries(dir) {
266
+ let files;
267
+ try {
268
+ files = readdirSync(dir);
269
+ } catch {
270
+ return;
271
+ }
272
+
273
+ const ldbFiles = files
274
+ .filter((f) => f.endsWith(".ldb"))
275
+ .sort((a, b) => parseInt(a) - parseInt(b));
276
+
277
+ for (const file of ldbFiles) {
278
+ yield* readSstEntries(join(dir, file));
279
+ }
280
+
281
+ const logFiles = files
282
+ .filter((f) => f.endsWith(".log"))
283
+ .sort((a, b) => parseInt(a) - parseInt(b));
284
+
285
+ for (const file of logFiles) {
286
+ yield* readLogEntries(join(dir, file));
287
+ }
288
+ }