@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.
- package/LICENSE +201 -0
- package/README.md +15 -0
- package/bin/fit-outpost.js +3 -0
- package/config/scheduler.json +34 -0
- package/package.json +63 -0
- package/src/agent-runner.js +189 -0
- package/src/index.js +5 -0
- package/src/kb-manager.js +228 -0
- package/src/outpost.js +417 -0
- package/src/scheduler.js +195 -0
- package/src/socket-server.js +336 -0
- package/src/state-manager.js +126 -0
- package/templates/.claude/agents/chief-of-staff.md +58 -0
- package/templates/.claude/agents/concierge.md +46 -0
- package/templates/.claude/agents/head-hunter.md +57 -0
- package/templates/.claude/agents/librarian.md +60 -0
- package/templates/.claude/agents/postman.md +47 -0
- package/templates/.claude/agents/recruiter.md +63 -0
- package/templates/.claude/settings.json +88 -0
- package/templates/.claude/skills/candidate-report/SKILL.md +139 -0
- package/templates/.claude/skills/candidate-report/references/report-template.html +203 -0
- package/templates/.claude/skills/candidate-report/references/report.css +438 -0
- package/templates/.claude/skills/candidate-report/references/rubric.md +76 -0
- package/templates/.claude/skills/candidate-report/scripts/render-pdf.mjs +48 -0
- package/templates/.claude/skills/deck-create/SKILL.md +75 -0
- package/templates/.claude/skills/deck-create/references/slide.css +35 -0
- package/templates/.claude/skills/deck-create/scripts/convert-to-pdf.mjs +47 -0
- package/templates/.claude/skills/deck-summarize/SKILL.md +139 -0
- package/templates/.claude/skills/deck-summarize/references/brief-template.md +85 -0
- package/templates/.claude/skills/deck-summarize/scripts/extract-pptx.mjs +210 -0
- package/templates/.claude/skills/doc-collab/SKILL.md +112 -0
- package/templates/.claude/skills/doc-create/SKILL.md +105 -0
- package/templates/.claude/skills/doc-create/scripts/convert-to-pdf.mjs +50 -0
- package/templates/.claude/skills/draft-emails/SKILL.md +141 -0
- package/templates/.claude/skills/draft-emails/references/template.md +26 -0
- package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
- package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +139 -0
- package/templates/.claude/skills/extract-entities/SKILL.md +187 -0
- package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +14 -0
- package/templates/.claude/skills/extract-entities/references/conditions.md +55 -0
- package/templates/.claude/skills/extract-entities/references/content.md +104 -0
- package/templates/.claude/skills/extract-entities/references/links.md +48 -0
- package/templates/.claude/skills/extract-entities/references/recruitment.md +57 -0
- package/templates/.claude/skills/extract-entities/references/resolution.md +71 -0
- package/templates/.claude/skills/extract-entities/references/sources.md +70 -0
- package/templates/.claude/skills/extract-entities/references/templates-conditions.md +36 -0
- package/templates/.claude/skills/extract-entities/references/templates-goals-priorities.md +63 -0
- package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +67 -0
- package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +68 -0
- package/templates/.claude/skills/extract-entities/scripts/state.mjs +131 -0
- package/templates/.claude/skills/hyprnote-follow/SKILL.md +174 -0
- package/templates/.claude/skills/hyprnote-follow/references/coaching.md +118 -0
- package/templates/.claude/skills/hyprnote-follow/references/debrief.md +59 -0
- package/templates/.claude/skills/hyprnote-follow/references/meeting-types.md +14 -0
- package/templates/.claude/skills/hyprnote-follow/scripts/follow.mjs +243 -0
- package/templates/.claude/skills/hyprnote-process/SKILL.md +147 -0
- package/templates/.claude/skills/hyprnote-process/references/extraction.md +84 -0
- package/templates/.claude/skills/hyprnote-process/references/sessions.md +81 -0
- package/templates/.claude/skills/hyprnote-process/scripts/scan.mjs +246 -0
- package/templates/.claude/skills/hyprnote-trim/SKILL.md +177 -0
- package/templates/.claude/skills/manage-tasks/SKILL.md +152 -0
- package/templates/.claude/skills/manage-tasks/references/format.md +73 -0
- package/templates/.claude/skills/meeting-prep/SKILL.md +180 -0
- package/templates/.claude/skills/organize-files/SKILL.md +146 -0
- package/templates/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
- package/templates/.claude/skills/organize-files/scripts/summarize.mjs +91 -0
- package/templates/.claude/skills/req-assess/SKILL.md +137 -0
- package/templates/.claude/skills/req-assess/references/interview-template.md +62 -0
- package/templates/.claude/skills/req-assess/references/panel-template.md +117 -0
- package/templates/.claude/skills/req-assess/references/rubric.md +65 -0
- package/templates/.claude/skills/req-decide/SKILL.md +163 -0
- package/templates/.claude/skills/req-decide/references/rubric.md +63 -0
- package/templates/.claude/skills/req-decide/references/template.md +118 -0
- package/templates/.claude/skills/req-forget/SKILL.md +154 -0
- package/templates/.claude/skills/req-forget/references/classify.md +23 -0
- package/templates/.claude/skills/req-forget/references/locations.md +59 -0
- package/templates/.claude/skills/req-forget/references/report-template.md +67 -0
- package/templates/.claude/skills/req-scan/SKILL.md +145 -0
- package/templates/.claude/skills/req-scan/references/fallbacks.md +76 -0
- package/templates/.claude/skills/req-scan/references/filters.md +83 -0
- package/templates/.claude/skills/req-scan/references/sources.md +76 -0
- package/templates/.claude/skills/req-scan/references/state.md +54 -0
- package/templates/.claude/skills/req-scan/references/template.md +36 -0
- package/templates/.claude/skills/req-scan/scripts/state.mjs +396 -0
- package/templates/.claude/skills/req-screen/SKILL.md +151 -0
- package/templates/.claude/skills/req-screen/references/rubric.md +94 -0
- package/templates/.claude/skills/req-screen/references/template.md +91 -0
- package/templates/.claude/skills/req-track/SKILL.md +189 -0
- package/templates/.claude/skills/req-track/references/fields.md +114 -0
- package/templates/.claude/skills/req-track/references/signals.md +42 -0
- package/templates/.claude/skills/req-track/references/statuses.md +32 -0
- package/templates/.claude/skills/req-track/references/templates.md +127 -0
- package/templates/.claude/skills/req-workday/SKILL.md +162 -0
- package/templates/.claude/skills/req-workday/references/field-mapping.md +40 -0
- package/templates/.claude/skills/req-workday/references/status-mapping.md +38 -0
- package/templates/.claude/skills/req-workday/references/templates.md +126 -0
- package/templates/.claude/skills/req-workday/references/xlsx-format.md +56 -0
- package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +313 -0
- package/templates/.claude/skills/send-chat/SKILL.md +170 -0
- package/templates/.claude/skills/sync-apple-calendar/SKILL.md +145 -0
- package/templates/.claude/skills/sync-apple-calendar/references/SCHEMA.md +80 -0
- package/templates/.claude/skills/sync-apple-calendar/scripts/query.mjs +307 -0
- package/templates/.claude/skills/sync-apple-calendar/scripts/sync.mjs +332 -0
- package/templates/.claude/skills/sync-apple-mail/SKILL.md +151 -0
- package/templates/.claude/skills/sync-apple-mail/references/SCHEMA.md +115 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +389 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/sync-helpers.mjs +372 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/sync.mjs +239 -0
- package/templates/.claude/skills/sync-teams/SKILL.md +177 -0
- package/templates/.claude/skills/sync-teams/scripts/idb-reader.mjs +147 -0
- package/templates/.claude/skills/sync-teams/scripts/leveldb-reader.mjs +288 -0
- package/templates/.claude/skills/sync-teams/scripts/sync.mjs +381 -0
- package/templates/.claude/skills/upstream-skill/SKILL.md +130 -0
- package/templates/.claude/skills/upstream-skill/references/examples.md +80 -0
- package/templates/.claude/skills/weekly-update/SKILL.md +141 -0
- package/templates/.claude/skills/weekly-update/references/template.md +55 -0
- package/templates/CLAUDE.md +134 -0
- package/templates/USER.md +3 -0
- package/templates/apm.yml +5 -0
- 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
|
+
}
|