memhouse 0.18.12 → 0.20.1
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/AGENTS.md +22 -151
- package/CHANGELOG.md +263 -29
- package/LICENSE +202 -22
- package/LICENSES/agentlytics-MIT.txt +21 -0
- package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
- package/LICENSES/models.dev-MIT.txt +22 -0
- package/NOTICE +28 -0
- package/README.md +42 -20
- package/SECURITY.md +21 -8
- package/bin/memhouse.js +2223 -311
- package/docs/README.md +11 -9
- package/docs/guides/admin-password.md +37 -10
- package/docs/guides/configuration.md +13 -6
- package/docs/guides/daemons.md +64 -3
- package/docs/guides/import-claude-ai.md +204 -0
- package/docs/guides/install.md +11 -6
- package/docs/guides/invite.md +21 -7
- package/docs/guides/operate.md +114 -7
- package/docs/guides/search-and-resume.md +72 -3
- package/docs/guides/share.md +91 -52
- package/docs/guides/storage.md +44 -4
- package/docs/guides/troubleshooting.md +121 -14
- package/docs/guides/uninstall.md +4 -3
- package/docs/guides/update.md +16 -0
- package/docs/guides/upgrade-to-0.20.md +188 -0
- package/docs/releases/README.md +2 -1
- package/docs/releases/v0.18.10.md +5 -5
- package/docs/releases/v0.18.11.md +3 -3
- package/docs/releases/v0.20.0.md +627 -0
- package/docs/releases/v0.20.1.md +114 -0
- package/docs/tutorials/01-first-house.md +10 -7
- package/docs/tutorials/02-join-a-house.md +24 -15
- package/docs/tutorials/03-share-your-memory.md +40 -31
- package/editors/antigravity.js +1 -80
- package/editors/base.js +463 -9
- package/editors/bridge.js +119 -0
- package/editors/claude.js +273 -105
- package/editors/codex.js +278 -52
- package/editors/copilot.js +1 -70
- package/editors/cursor.js +1 -75
- package/editors/derive-fingerprints.json +18 -0
- package/editors/harness-paths.js +69 -0
- package/editors/index.js +1 -20
- package/editors/normalise/agy.js +910 -0
- package/editors/normalise/copilot-cli.js +321 -0
- package/editors/normalise/cursor-agent.js +852 -0
- package/editors/normalise/cursor.js +474 -0
- package/editors/normalise/devin.js +575 -0
- package/editors/normalise/gemini-cli.js +602 -0
- package/editors/normalise/goose.js +673 -0
- package/editors/normalise/opencode.js +633 -0
- package/editors/normalise/vscode.js +549 -0
- package/editors/normalise/zed.js +473 -0
- package/editors/opencode-v4.js +119 -0
- package/editors/scope.js +14 -3
- package/editors/sessions-log.js +349 -0
- package/editors/sqlite.js +28 -1
- package/editors/vscode.js +1 -72
- package/editors/windsurf.js +1 -143
- package/examples/01-local-one-machine/README.md +24 -19
- package/examples/01-local-one-machine/run.sh +1 -1
- package/examples/02-existing-clickhouse/README.md +30 -22
- package/examples/02-existing-clickhouse/run.sh +5 -4
- package/examples/03-team-house/README.md +44 -37
- package/examples/03-team-house/run.sh +35 -18
- package/examples/04-self-hosted-team/README.md +29 -14
- package/examples/04-self-hosted-team/compose.yml +5 -2
- package/examples/04-self-hosted-team/smoke.sh +26 -12
- package/examples/README.md +8 -4
- package/examples/fixtures/README.md +4 -1
- package/examples/fixtures/make-sessions.js +8 -5
- package/memhouse/admin-secret.js +89 -24
- package/memhouse/argv-secrets.js +64 -0
- package/memhouse/channel.js +49 -9
- package/memhouse/codex-plugin.js +194 -0
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
- package/memhouse/delivery/AGENT-INSTALL.md +22 -11
- package/memhouse/delivery/INVITATION.md.tpl +44 -45
- package/memhouse/delivery/PROMPT.md +53 -5
- package/memhouse/delivery/UPGRADE.md.tpl +1 -1
- package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
- package/memhouse/delivery/codex/SKILL.md +50 -0
- package/memhouse/delivery/kernel-install.md +17 -9
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
- package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
- package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
- package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
- package/memhouse/deploy.js +1 -1
- package/memhouse/flags.js +35 -18
- package/memhouse/help.js +37 -0
- package/memhouse/host.js +82 -2
- package/memhouse/house/HOUSE.md +2 -2
- package/memhouse/house/convert-profile.js +638 -0
- package/memhouse/house/convert-v3.js +742 -0
- package/memhouse/house/doctor-v3.js +324 -0
- package/memhouse/house/host-alias.js +85 -0
- package/memhouse/house/house.js +3 -3
- package/memhouse/house/migrate.js +2 -2
- package/memhouse/house/profile-dedupe.js +183 -0
- package/memhouse/house/profile-edges.js +554 -0
- package/memhouse/house/profile-house.js +430 -0
- package/memhouse/house/profile-rollup.js +190 -0
- package/memhouse/house/q-cli.js +863 -0
- package/memhouse/house/queries-v3.js +1013 -0
- package/memhouse/house/reconcile-v3.js +192 -0
- package/memhouse/house/records-to-profile.js +174 -0
- package/memhouse/house/records-to-rows.js +169 -0
- package/memhouse/house/relocate.js +2 -1
- package/memhouse/house/schema-choice.js +121 -0
- package/memhouse/house/schema-profile.js +290 -0
- package/memhouse/house/schema-profile.sql.tpl +398 -0
- package/memhouse/house/schema-v3.js +267 -0
- package/memhouse/house/schema-v3.sql.tpl +213 -0
- package/memhouse/import/claude-ai.js +420 -0
- package/memhouse/import/import.js +372 -0
- package/memhouse/import/pyjson.js +206 -0
- package/memhouse/import/records.js +64 -0
- package/memhouse/import/writer-profile.js +192 -0
- package/memhouse/import/writer-v3.js +240 -0
- package/memhouse/import/zip.js +112 -0
- package/memhouse/invitation.js +102 -0
- package/memhouse/invitefile.js +5 -2
- package/memhouse/plugin-copies.js +176 -0
- package/memhouse/provision.js +7 -2
- package/memhouse/release-hint.js +24 -0
- package/memhouse/resume.js +4 -4
- package/memhouse/server/api-v3.js +632 -0
- package/memhouse/server/dashboard-auth.js +181 -0
- package/memhouse/server/dashboard-port.js +91 -0
- package/memhouse/server/queries.js +30 -0
- package/memhouse/server/server.js +118 -20
- package/memhouse/service.js +1 -1
- package/memhouse/share.js +687 -1
- package/memhouse/shipper/adapter-capture.js +756 -0
- package/memhouse/shipper/capture.js +2571 -0
- package/memhouse/shipper/dedupe-profile.js +237 -0
- package/memhouse/shipper/dedupe-v3.js +1027 -0
- package/memhouse/shipper/forks.js +233 -0
- package/memhouse/shipper/log-adapter.js +219 -0
- package/memhouse/shipper/maintenance.js +49 -0
- package/memhouse/shipper/normalise-run.js +148 -0
- package/memhouse/shipper/passlog.js +68 -5
- package/memhouse/shipper/profile-capture.js +903 -0
- package/memhouse/shipper/ship.js +91 -8
- package/memhouse/shipper/snapshots.js +193 -0
- package/memhouse/shipper/source-roots.js +111 -0
- package/memhouse/version-notice.js +66 -0
- package/package.json +14 -7
- package/public/THIRD_PARTY_NOTICES.txt +2 -2
- package/public/assets/index-CcNnhfM3.js +78 -0
- package/public/index.html +1 -1
- package/ui/package-lock.json +56 -37
- package/ui/package.json +1 -0
- package/ui/src/App.jsx +29 -14
- package/ui/src/components/ChatSidebar.jsx +69 -12
- package/ui/src/lib/api.js +76 -0
- package/ui/src/lib/dashboard-token.js +41 -0
- package/ui/src/main.jsx +22 -5
- package/ui/src/pages/Dashboard.jsx +22 -36
- package/ui/third-party-notices.js +119 -0
- package/ui/vite.config.js +5 -1
- package/docs/releases/v0.18.12.md +0 -59
- package/memhouse/COMPETITION.md +0 -221
- package/memhouse/DESIGN.md +0 -222
- package/memhouse/RESUME-AND-UPDATE.md +0 -182
- package/public/assets/index-CnWzrjF3.js +0 -78
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
-- memhouse schema v3, layout 3.2 (plan v4, docs/design/sessions-engine.md): a session belongs to its
|
|
2
|
+
-- harness store (host, harness, harness_path, session_id, incarnation); content that left the source is
|
|
3
|
+
-- kept as a removed version (removed = 1), tombstones only for duplicates.
|
|
4
|
+
-- Origin: docs/design/source-identity.md, draft 5.
|
|
5
|
+
-- Placeholders: {db} = the house database (e.g. mem), {m} = the member (e.g. alice).
|
|
6
|
+
-- Conversation layer lives in {db}; capture layer in {db}_archive.
|
|
7
|
+
-- Text indexes need SETTINGS allow_experimental_full_text_index = 1 on 25.11 (as today).
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS {db}.{m}_turns (
|
|
10
|
+
harness LowCardinality(String),
|
|
11
|
+
harness_version LowCardinality(String),
|
|
12
|
+
harness_path LowCardinality(String), -- the harness's store: its data folder, absolute and symlink-resolved (~/.claude, a playbook dir, ~/.codex); for OpenCode, the DB file
|
|
13
|
+
session_id String,
|
|
14
|
+
incarnation String DEFAULT '', -- '' unless the harness reuses ids inside one store
|
|
15
|
+
agent_id String DEFAULT '', -- '' = main conversation; else the sub-agent's id
|
|
16
|
+
message_id String, -- native id (Codex: payload.id, else '@<byte offset>')
|
|
17
|
+
part_id String DEFAULT '', -- the part's id within the message (native, a ':call'/':result' suffix, or an index)
|
|
18
|
+
part_ord UInt32 DEFAULT 0, -- the part's order within the message
|
|
19
|
+
parent_id String DEFAULT '',
|
|
20
|
+
response_id String DEFAULT '', -- billing unit: Claude message.id, Codex turn id
|
|
21
|
+
ts DateTime64(3, 'UTC'), -- the source's own; never changes (§2)
|
|
22
|
+
ord UInt64, -- display tie-break within equal ts (capture order)
|
|
23
|
+
rev UInt64, -- fgen·2^40 + upto (§2)
|
|
24
|
+
is_deleted UInt8 DEFAULT 0, -- physical delete marker: duplicates only (dedupe, adapter upgrade)
|
|
25
|
+
removed UInt8 DEFAULT 0, -- 1 = left the source; kept, searchable, not current
|
|
26
|
+
state LowCardinality(String) DEFAULT '', -- the editor's message state: '' | current | reverted | retracted
|
|
27
|
+
role LowCardinality(String), -- user | assistant | system | tool
|
|
28
|
+
kind LowCardinality(String), -- text | thinking | tool_call | tool_result | summary | usage | none (header)
|
|
29
|
+
model LowCardinality(String) DEFAULT '',
|
|
30
|
+
effort LowCardinality(String) DEFAULT '',
|
|
31
|
+
input_tokens UInt64 DEFAULT 0, -- on exactly one row per response_id
|
|
32
|
+
output_tokens UInt64 DEFAULT 0,
|
|
33
|
+
cache_read_tokens UInt64 DEFAULT 0,
|
|
34
|
+
cache_write_tokens UInt64 DEFAULT 0,
|
|
35
|
+
reasoning_tokens UInt64 DEFAULT 0,
|
|
36
|
+
text String CODEC(ZSTD(3)), -- full text, tool output included (layout 3.2)
|
|
37
|
+
text_len UInt32,
|
|
38
|
+
folder String DEFAULT '',
|
|
39
|
+
project String DEFAULT '',
|
|
40
|
+
host LowCardinality(String),
|
|
41
|
+
origin LowCardinality(String) DEFAULT 'ship', -- ship | import | legacy
|
|
42
|
+
extra String DEFAULT '{}',
|
|
43
|
+
adapter_ver UInt32,
|
|
44
|
+
user_id String MATERIALIZED currentUser(),
|
|
45
|
+
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3),
|
|
46
|
+
-- Two lowercase copies, not one: ClickHouse allows ONE text index per column (measured on
|
|
47
|
+
-- 25.11.9.34 and 26.7.4.58: "Column `text_lc` must not have more than one text index").
|
|
48
|
+
-- Same names and shape as the 0.18 rooms, so the read layer's search predicates carry over.
|
|
49
|
+
text_ngram String MATERIALIZED lower(text),
|
|
50
|
+
text_word String MATERIALIZED lower(text),
|
|
51
|
+
INDEX idx_text_ngram text_ngram TYPE text(tokenizer = ngrams(3)) GRANULARITY 1,
|
|
52
|
+
INDEX idx_text_word text_word TYPE text(tokenizer = splitByNonAlpha) GRANULARITY 1,
|
|
53
|
+
INDEX idx_ts ts TYPE minmax GRANULARITY 1
|
|
54
|
+
) ENGINE = ReplacingMergeTree(rev, is_deleted)
|
|
55
|
+
-- By month: a key's versions share its ts, so they share a partition; FINAL and the tombstone
|
|
56
|
+
-- cleanup work one month at a time, and a cleanup rewrites only the months that hold tombstones.
|
|
57
|
+
PARTITION BY toYYYYMM(ts)
|
|
58
|
+
ORDER BY (toDate(ts), harness, host, harness_path, session_id, incarnation, ts, message_id, part_id)
|
|
59
|
+
-- 512, not 2048: a term that appears in many sessions still lands in only the granules that hold it
|
|
60
|
+
-- (search read 30–60% fewer rows on a real house, 2026-10-16). Cleanup: tombstones are dropped by
|
|
61
|
+
-- OPTIMIZE … FINAL CLEANUP (memhouse/shipper/maintenance.js).
|
|
62
|
+
SETTINGS index_granularity = 512, allow_experimental_replacing_merge_with_cleanup = 1;
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS {db}.{m}_calls (
|
|
65
|
+
harness LowCardinality(String),
|
|
66
|
+
harness_path LowCardinality(String) DEFAULT '',
|
|
67
|
+
session_id String,
|
|
68
|
+
incarnation String DEFAULT '',
|
|
69
|
+
agent_id String DEFAULT '',
|
|
70
|
+
call_id String, -- native: Claude tool_use.id, Codex call_id
|
|
71
|
+
message_id String,
|
|
72
|
+
ts DateTime64(3, 'UTC'), -- when the call was issued; never changes
|
|
73
|
+
rev UInt64,
|
|
74
|
+
is_deleted UInt8 DEFAULT 0,
|
|
75
|
+
removed UInt8 DEFAULT 0,
|
|
76
|
+
tool_name LowCardinality(String),
|
|
77
|
+
args String DEFAULT '{}',
|
|
78
|
+
result_ts Nullable(DateTime64(3, 'UTC')),
|
|
79
|
+
is_error Bool DEFAULT false,
|
|
80
|
+
result_len UInt32 DEFAULT 0,
|
|
81
|
+
model LowCardinality(String) DEFAULT '',
|
|
82
|
+
folder String DEFAULT '',
|
|
83
|
+
project String DEFAULT '',
|
|
84
|
+
host LowCardinality(String),
|
|
85
|
+
adapter_ver UInt32,
|
|
86
|
+
user_id String MATERIALIZED currentUser(),
|
|
87
|
+
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3),
|
|
88
|
+
INDEX idx_tool tool_name TYPE set(1024) GRANULARITY 1,
|
|
89
|
+
INDEX idx_ts ts TYPE minmax GRANULARITY 1
|
|
90
|
+
) ENGINE = ReplacingMergeTree(rev, is_deleted)
|
|
91
|
+
PARTITION BY toYYYYMM(ts)
|
|
92
|
+
ORDER BY (toDate(ts), harness, host, harness_path, session_id, incarnation, ts, call_id)
|
|
93
|
+
SETTINGS index_granularity = 2048, allow_experimental_replacing_merge_with_cleanup = 1;
|
|
94
|
+
|
|
95
|
+
CREATE TABLE IF NOT EXISTS {db}.{m}_sessions (
|
|
96
|
+
harness LowCardinality(String),
|
|
97
|
+
session_id String,
|
|
98
|
+
incarnation String DEFAULT '',
|
|
99
|
+
harness_version LowCardinality(String),
|
|
100
|
+
harness_path LowCardinality(String),
|
|
101
|
+
host LowCardinality(String), -- sessions-model/host/v0: from the OS machine id, never the hostname
|
|
102
|
+
host_label LowCardinality(String) DEFAULT '', -- the machine's short hostname, for display only; never in a key
|
|
103
|
+
name String,
|
|
104
|
+
mode LowCardinality(String),
|
|
105
|
+
folder String,
|
|
106
|
+
project String,
|
|
107
|
+
git_branch String,
|
|
108
|
+
created_at Nullable(DateTime64(3, 'UTC')),
|
|
109
|
+
updated_at Nullable(DateTime64(3, 'UTC')),
|
|
110
|
+
started DateTime64(3, 'UTC'),
|
|
111
|
+
ended DateTime64(3, 'UTC'),
|
|
112
|
+
messages UInt32, -- kind = 'text' rows only (parity with legacy counts)
|
|
113
|
+
user_messages UInt32,
|
|
114
|
+
assistant_messages UInt32,
|
|
115
|
+
agent_messages UInt32,
|
|
116
|
+
tool_calls UInt32,
|
|
117
|
+
input_tokens UInt64,
|
|
118
|
+
output_tokens UInt64,
|
|
119
|
+
cache_read_tokens UInt64,
|
|
120
|
+
cache_write_tokens UInt64,
|
|
121
|
+
reasoning_tokens UInt64,
|
|
122
|
+
user_chars UInt64,
|
|
123
|
+
assistant_chars UInt64,
|
|
124
|
+
first_prompt String, -- first user row of the main conversation
|
|
125
|
+
models Array(LowCardinality(String)),
|
|
126
|
+
dominant_model LowCardinality(String),
|
|
127
|
+
days Array(Date), -- days with activity: readSession reads only these
|
|
128
|
+
usage Nested(
|
|
129
|
+
hour DateTime('UTC'),
|
|
130
|
+
model LowCardinality(String),
|
|
131
|
+
messages UInt32,
|
|
132
|
+
input UInt64,
|
|
133
|
+
output UInt64,
|
|
134
|
+
cache_read UInt64,
|
|
135
|
+
cache_write UInt64,
|
|
136
|
+
reasoning UInt64
|
|
137
|
+
),
|
|
138
|
+
tools Nested(name LowCardinality(String), calls UInt32, errors UInt32),
|
|
139
|
+
agents Nested( -- readable sub-agent metadata (opus)
|
|
140
|
+
agent_id String,
|
|
141
|
+
type LowCardinality(String),
|
|
142
|
+
description String,
|
|
143
|
+
workflow String,
|
|
144
|
+
messages UInt32,
|
|
145
|
+
first_ts DateTime64(3, 'UTC')
|
|
146
|
+
),
|
|
147
|
+
links Nested( -- sessions-model links stated by this session (log session record)
|
|
148
|
+
kind LowCardinality(String), -- fork | subagent | rewind_copy | clear
|
|
149
|
+
origin String, -- the other session's native id (same store unless origin_store)
|
|
150
|
+
origin_store String,
|
|
151
|
+
evidence LowCardinality(String), -- explicit | inferred
|
|
152
|
+
at String,
|
|
153
|
+
through String
|
|
154
|
+
),
|
|
155
|
+
usage_basis LowCardinality(String), -- per_response | per_record (legacy rows)
|
|
156
|
+
origin LowCardinality(String) DEFAULT 'ship',
|
|
157
|
+
rev UInt64, -- Σ files.ver of the session: rises on any change
|
|
158
|
+
is_deleted UInt8 DEFAULT 0, -- duplicates only
|
|
159
|
+
removed UInt8 DEFAULT 0, -- every source file gone; kept
|
|
160
|
+
user_id String MATERIALIZED currentUser(),
|
|
161
|
+
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
|
|
162
|
+
) ENGINE = ReplacingMergeTree(rev, is_deleted)
|
|
163
|
+
ORDER BY (harness, host, harness_path, session_id, incarnation);
|
|
164
|
+
|
|
165
|
+
CREATE TABLE IF NOT EXISTS {db}_archive.{m}_records (
|
|
166
|
+
harness LowCardinality(String),
|
|
167
|
+
harness_path LowCardinality(String) DEFAULT '',
|
|
168
|
+
session_id String,
|
|
169
|
+
incarnation String DEFAULT '',
|
|
170
|
+
file_key String, -- path relative to the session
|
|
171
|
+
fgen UInt32,
|
|
172
|
+
pos UInt64, -- byte offset (or a first-seen ordinal for non-file sources)
|
|
173
|
+
chunk UInt16,
|
|
174
|
+
sha FixedString(64), -- SHA-256 of the exact bytes, lowercase hex (JSONEachRow cannot carry raw bytes)
|
|
175
|
+
kind LowCardinality(String), -- record | fragment | side-file
|
|
176
|
+
enc LowCardinality(String), -- '' | 'b64'
|
|
177
|
+
raw String CODEC(ZSTD(3)),
|
|
178
|
+
host LowCardinality(String),
|
|
179
|
+
user_id String MATERIALIZED currentUser(),
|
|
180
|
+
captured_at DateTime64(3, 'UTC') DEFAULT now64(3)
|
|
181
|
+
) ENGINE = ReplacingMergeTree
|
|
182
|
+
PRIMARY KEY (harness, host, harness_path, session_id, incarnation, file_key, fgen, pos)
|
|
183
|
+
ORDER BY (harness, host, harness_path, session_id, incarnation, file_key, fgen, pos, chunk, sha);
|
|
184
|
+
|
|
185
|
+
CREATE TABLE IF NOT EXISTS {db}_archive.{m}_files (
|
|
186
|
+
harness LowCardinality(String),
|
|
187
|
+
harness_path LowCardinality(String) DEFAULT '',
|
|
188
|
+
session_id String,
|
|
189
|
+
incarnation String DEFAULT '',
|
|
190
|
+
file_key String,
|
|
191
|
+
agent_id String DEFAULT '',
|
|
192
|
+
role LowCardinality(String), -- main | agent | side
|
|
193
|
+
agent_type LowCardinality(String),
|
|
194
|
+
agent_desc String,
|
|
195
|
+
fgen UInt32,
|
|
196
|
+
upto UInt64,
|
|
197
|
+
reparse_from UInt64, -- start of the earliest open unit
|
|
198
|
+
derive_state String CODEC(ZSTD(1)), -- open units, their keys, contributions, fallback timestamps
|
|
199
|
+
adapter_ver UInt32,
|
|
200
|
+
size UInt64,
|
|
201
|
+
inode UInt64,
|
|
202
|
+
anchor_sha FixedString(64), -- SHA-256, lowercase hex, as records.sha
|
|
203
|
+
prefix_sha FixedString(64),
|
|
204
|
+
is_deleted UInt8 DEFAULT 0,
|
|
205
|
+
contrib String CODEC(ZSTD(1)), -- this file's exact share of the session totals
|
|
206
|
+
first_ts DateTime64(3, 'UTC'),
|
|
207
|
+
last_ts DateTime64(3, 'UTC'),
|
|
208
|
+
ver UInt64 MATERIALIZED (toUInt64(fgen) * 1099511627776) + upto,
|
|
209
|
+
host LowCardinality(String),
|
|
210
|
+
user_id String MATERIALIZED currentUser(),
|
|
211
|
+
committed_at DateTime64(3, 'UTC') DEFAULT now64(3)
|
|
212
|
+
) ENGINE = ReplacingMergeTree(ver)
|
|
213
|
+
ORDER BY (harness, host, harness_path, session_id, incarnation, file_key);
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// claude.ai data export -> conversations, ready for a writer. Layout-free: this module knows
|
|
3
|
+
// the export and nothing about tables. It computes every key a row is stored under (the session
|
|
4
|
+
// id, each part's message_id and part_id, each call's call_id) once, here; a writer copies them
|
|
5
|
+
// and never derives its own, so every layout stores one conversation under the same keys.
|
|
6
|
+
//
|
|
7
|
+
// Three inputs, one result:
|
|
8
|
+
// archive a folder that files the export's conversations one per file, as the export holds
|
|
9
|
+
// them: projects/<slug>/chats/*.json, chats-unfiled/<year>/*.json, design-chats/*.json.
|
|
10
|
+
// The project is the folder's slug (the export itself has none).
|
|
11
|
+
// export the folder a claude.ai export unpacks to: conversations.json and design_chats/*.json.
|
|
12
|
+
// zip the export's .zip, read in place.
|
|
13
|
+
//
|
|
14
|
+
// Mapping (one part per stored block, in message order):
|
|
15
|
+
// text, thinking one part each; an empty or whitespace-only block is skipped
|
|
16
|
+
// tool_use a 'tool_call' part, text `Name(args)`, plus one call. Its result is
|
|
17
|
+
// the tool_result with its id, or — for blocks with no ids — the next
|
|
18
|
+
// id-less tool_result after it in the same message (by order)
|
|
19
|
+
// tool_result a 'tool_result' part, its content flattened to text
|
|
20
|
+
// attachment with extracted text, an extra user 'text' part (part_id att:<n>)
|
|
21
|
+
// conversation summary one 'summary' part at the time of the last message
|
|
22
|
+
// anything else counted as skipped (block:<type>), never stored
|
|
23
|
+
// A design chat's messages are one 'text' part each.
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const py = require('./pyjson');
|
|
28
|
+
const { openZip, isZip, ZipError } = require('./zip');
|
|
29
|
+
|
|
30
|
+
const HARNESS = 'claude-ai';
|
|
31
|
+
// Where a claude.ai session lives: the service, not a machine. It is the session's host,
|
|
32
|
+
// harness_path and host label alike.
|
|
33
|
+
const STORE = 'claude.ai';
|
|
34
|
+
const PROJECT_UNFILED = 'claude-ai-unfiled'; // an archive chat in no claude.ai project
|
|
35
|
+
const PROJECT_DESIGN = 'claude-ai-design'; // a design chat
|
|
36
|
+
const PROJECT_UNKNOWN = 'claude-ai'; // a chat read from the export, which records no project
|
|
37
|
+
// The parser's version, stored as adapter_ver: raise it when a change alters any row.
|
|
38
|
+
const PARSER_VERSION = 1;
|
|
39
|
+
|
|
40
|
+
class ImportInputError extends Error {}
|
|
41
|
+
|
|
42
|
+
const TS = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:?\d{2})?$/;
|
|
43
|
+
|
|
44
|
+
/** An export timestamp (ISO 8601, any fraction, Z or an offset) as 'YYYY-MM-DD HH:MM:SS.mmm' UTC. */
|
|
45
|
+
function chTs(iso) {
|
|
46
|
+
const m = TS.exec(py.pyStrip(String(iso === undefined || iso === null ? '' : iso)));
|
|
47
|
+
if (!m) throw new ImportInputError(`bad timestamp: ${JSON.stringify(iso)}`);
|
|
48
|
+
const [, y, mo, d, h, mi, s, frac, tz] = m;
|
|
49
|
+
const base = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
|
|
50
|
+
const check = new Date(base);
|
|
51
|
+
if (check.getUTCFullYear() !== Number(y) || check.getUTCMonth() !== Number(mo) - 1 || check.getUTCDate() !== Number(d)
|
|
52
|
+
|| Number(h) > 23 || Number(mi) > 59 || Number(s) > 59) {
|
|
53
|
+
throw new ImportInputError(`bad timestamp: ${JSON.stringify(iso)}`);
|
|
54
|
+
}
|
|
55
|
+
const micro = Number(`${frac || ''}000000`.slice(0, 6));
|
|
56
|
+
let ms = base + Math.floor(micro / 1000);
|
|
57
|
+
if (tz && tz !== 'Z') {
|
|
58
|
+
const sign = tz[0] === '+' ? 1 : -1;
|
|
59
|
+
ms -= sign * (Number(tz.slice(1, 3)) * 60 + Number(tz.slice(-2))) * 60000;
|
|
60
|
+
}
|
|
61
|
+
return new Date(ms).toISOString().slice(0, 23).replace('T', ' ');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Python's truthiness, for the `x or default` the mapping was first written with: an empty list
|
|
65
|
+
// or dict is false there and true here.
|
|
66
|
+
function truthy(v) {
|
|
67
|
+
if (!v) return false;
|
|
68
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
69
|
+
if (typeof v === 'object') return Object.keys(v).length > 0;
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
const or = (v, dflt) => (truthy(v) ? v : dflt);
|
|
73
|
+
const orEmpty = (v) => or(v, '');
|
|
74
|
+
const hasText = (v) => typeof v === 'string' && py.pyStrip(v) !== '';
|
|
75
|
+
|
|
76
|
+
/** A tool result's content as one text: text items as they are, known items described, the rest as JSON. */
|
|
77
|
+
function flattenResult(content) {
|
|
78
|
+
const out = [];
|
|
79
|
+
// Always a list in the export. A bare string is taken as one text (the first importer would
|
|
80
|
+
// have split it into characters; no export has held one).
|
|
81
|
+
const items = Array.isArray(content) ? content : typeof content === 'string' && content ? [content] : [];
|
|
82
|
+
items.forEach((it, k) => {
|
|
83
|
+
if (!it || typeof it !== 'object' || Array.isArray(it)) { out.push(py.pyStr(it, py.numSource(items, k))); return; }
|
|
84
|
+
const t = it.type;
|
|
85
|
+
if (t === 'text') out.push(orEmpty(it.text));
|
|
86
|
+
else if (t === 'knowledge') {
|
|
87
|
+
const head = [it.title, it.url].filter(truthy).join(' ');
|
|
88
|
+
out.push((head ? `${head}\n` : '') + orEmpty(it.text));
|
|
89
|
+
} else if (t === 'local_resource') out.push(`[file: ${or(it.file_path, or(it.name, ''))}]`);
|
|
90
|
+
else if (t === 'image') out.push('[image]');
|
|
91
|
+
else out.push(py.dumps(it));
|
|
92
|
+
});
|
|
93
|
+
return out.filter((x) => x).join('\n\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function newSession(sessionId, name, mode, folder, project, created, updated) {
|
|
97
|
+
return { sessionId, name, mode, folder, project, created, updated, parts: [], calls: [], skipped: {} };
|
|
98
|
+
}
|
|
99
|
+
const skip = (s, why) => { s.skipped[why] = (s.skipped[why] || 0) + 1; };
|
|
100
|
+
|
|
101
|
+
function blockType(t) {
|
|
102
|
+
if (t === null || t === undefined) return 'None';
|
|
103
|
+
return typeof t === 'string' ? t : py.pyStr(t);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One chat conversation (a conversations.json entry, or an archive chat file). */
|
|
107
|
+
function fromConversation(conv, folder, project) {
|
|
108
|
+
if (!conv || typeof conv !== 'object' || !conv.uuid) throw new ImportInputError('a conversation has no uuid');
|
|
109
|
+
const s = newSession(String(conv.uuid), py.pyStrip(orEmpty(conv.name)) || '(untitled)', 'chat', folder, project,
|
|
110
|
+
chTs(conv.created_at), chTs(conv.updated_at));
|
|
111
|
+
let lastTs = null;
|
|
112
|
+
for (const m of or(conv.chat_messages, [])) {
|
|
113
|
+
if (!m || !m.uuid) throw new ImportInputError('a message has no uuid');
|
|
114
|
+
const mu = String(m.uuid);
|
|
115
|
+
const ts = chTs(m.created_at);
|
|
116
|
+
lastTs = ts;
|
|
117
|
+
const role = m.sender === 'human' ? 'user' : 'assistant';
|
|
118
|
+
const parent = orEmpty(m.parent_message_uuid);
|
|
119
|
+
const resp = role === 'assistant' ? mu : '';
|
|
120
|
+
const files = or(m.files, []).map((f) => f && f.file_name).filter(truthy);
|
|
121
|
+
let ord = 0;
|
|
122
|
+
let blocks = or(m.content, []);
|
|
123
|
+
if (!truthy(blocks) && hasText(orEmpty(m.text))) blocks = [{ type: 'text', text: m.text }];
|
|
124
|
+
const results = new Map();
|
|
125
|
+
for (const b of blocks) if (b.type === 'tool_result' && b.tool_use_id) results.set(b.tool_use_id, b);
|
|
126
|
+
// tool_use blocks without an id and tool_result blocks without a tool_use_id pair by order:
|
|
127
|
+
// the k-th id-less call takes the next id-less result after it in the same message.
|
|
128
|
+
const idless = [];
|
|
129
|
+
blocks.forEach((b, k) => { if (b.type === 'tool_result' && !b.tool_use_id) idless.push([k, b]); });
|
|
130
|
+
const resultFor = (k, b) => {
|
|
131
|
+
if (b.id) return results.get(b.id) || null;
|
|
132
|
+
while (idless.length && idless[0][0] < k) idless.shift();
|
|
133
|
+
return idless.length ? idless.shift()[1] : null;
|
|
134
|
+
};
|
|
135
|
+
// A tool call's result block, once paired: its tool_result part then carries the call's id.
|
|
136
|
+
const paired = new Map();
|
|
137
|
+
// `more`: what a layout with typed parts needs beside the text (a call's id, name and input).
|
|
138
|
+
const emit = (partId, r, kind, text, extra = null, more = null) => {
|
|
139
|
+
let ex = extra;
|
|
140
|
+
if (ord === 0 && files.length) ex = { ...(ex || {}), files };
|
|
141
|
+
s.parts.push({
|
|
142
|
+
messageId: mu, partId, partOrd: ord, parentId: parent, responseId: r === 'assistant' ? resp : '',
|
|
143
|
+
ts, role: r, kind, text, extra: ex, messageRole: role, ...(more || {}),
|
|
144
|
+
});
|
|
145
|
+
ord++;
|
|
146
|
+
};
|
|
147
|
+
blocks.forEach((b, k) => {
|
|
148
|
+
const t = b.type;
|
|
149
|
+
if (t === 'text') {
|
|
150
|
+
if (hasText(orEmpty(b.text))) emit(String(k), role, 'text', b.text); else skip(s, 'empty_text_block');
|
|
151
|
+
} else if (t === 'thinking') {
|
|
152
|
+
if (hasText(orEmpty(b.thinking))) emit(String(k), 'assistant', 'thinking', b.thinking); else skip(s, 'empty_thinking_block');
|
|
153
|
+
} else if (t === 'tool_use') {
|
|
154
|
+
const input = or(b.input, {});
|
|
155
|
+
const args = py.dumps(input, py.numSource(b, 'input'));
|
|
156
|
+
const callId = b.id ? String(b.id) : `${mu}:${k}`;
|
|
157
|
+
emit(String(k), 'assistant', 'tool_call', `${orEmpty(b.name)}(${args})`, null, { callId, toolName: orEmpty(b.name), input });
|
|
158
|
+
const r = resultFor(k, b);
|
|
159
|
+
if (r) paired.set(r, callId);
|
|
160
|
+
const rtext = r ? flattenResult(r.content) : '';
|
|
161
|
+
s.calls.push({
|
|
162
|
+
callId, messageId: mu, ts, toolName: orEmpty(b.name), args,
|
|
163
|
+
resultTs: r ? ts : null, isError: Boolean(r && r.is_error), resultLen: py.pyLen(rtext),
|
|
164
|
+
});
|
|
165
|
+
} else if (t === 'tool_result') {
|
|
166
|
+
emit(String(k), 'tool', 'tool_result', flattenResult(b.content),
|
|
167
|
+
{ tool_use_id: orEmpty(b.tool_use_id), is_error: Boolean(b.is_error) },
|
|
168
|
+
{ callId: paired.get(b) || orEmpty(b.tool_use_id), isError: Boolean(b.is_error) });
|
|
169
|
+
} else {
|
|
170
|
+
skip(s, `block:${blockType(t)}`);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
or(m.attachments, []).forEach((a, j) => {
|
|
174
|
+
const body = orEmpty(a && a.extracted_content);
|
|
175
|
+
if (hasText(body)) {
|
|
176
|
+
const fname = orEmpty(a.file_name);
|
|
177
|
+
emit(`att:${j}`, 'user', 'text', `[attachment: ${fname}]\n${body}`, { attachment: fname });
|
|
178
|
+
} else skip(s, 'empty_attachment');
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (hasText(orEmpty(conv.summary)) && lastTs) {
|
|
182
|
+
s.parts.push({
|
|
183
|
+
messageId: `${s.sessionId}:summary`, partId: 'summary', partOrd: 0, parentId: '', responseId: '',
|
|
184
|
+
ts: lastTs, role: 'system', kind: 'summary', text: conv.summary, extra: null, messageRole: 'system',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return s;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One design chat (a design_chats/*.json file). */
|
|
191
|
+
function fromDesignChat(d, folder, project) {
|
|
192
|
+
if (!d || typeof d !== 'object' || !d.uuid) throw new ImportInputError('a design chat has no uuid');
|
|
193
|
+
const s = newSession(String(d.uuid), py.pyStrip(orEmpty(d.title)) || '(untitled)', 'design', folder, project,
|
|
194
|
+
chTs(d.created_at), chTs(d.updated_at));
|
|
195
|
+
for (const m of or(d.messages, [])) {
|
|
196
|
+
const inner = m.content && typeof m.content === 'object' && !Array.isArray(m.content) ? m.content : {};
|
|
197
|
+
let text = or(inner.content, '');
|
|
198
|
+
const role = m.role === 'user' || m.role === 'assistant' ? m.role : 'assistant';
|
|
199
|
+
if (typeof text !== 'string') text = py.dumps(text, py.numSource(inner, 'content'));
|
|
200
|
+
if (hasText(text)) {
|
|
201
|
+
s.parts.push({
|
|
202
|
+
messageId: String(m.uuid), partId: '0', partOrd: 0, parentId: '', responseId: role === 'assistant' ? String(m.uuid) : '',
|
|
203
|
+
ts: chTs(m.created_at), role, kind: 'text', text, extra: null, messageRole: role,
|
|
204
|
+
});
|
|
205
|
+
} else skip(s, 'empty_design_message');
|
|
206
|
+
}
|
|
207
|
+
return s;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── inputs ─────────────────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
const visible = (n) => !n.startsWith('.');
|
|
213
|
+
const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
|
|
214
|
+
const isFile = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
|
|
215
|
+
const sortedNames = (dir) => (isDir(dir) ? fs.readdirSync(dir).filter(visible).sort() : []);
|
|
216
|
+
// Only folders are walked into and only files are read: a stray file beside the project folders,
|
|
217
|
+
// or a folder named like a .json file, is passed over.
|
|
218
|
+
const sortedDirs = (dir) => sortedNames(dir).filter((n) => isDir(path.join(dir, n)));
|
|
219
|
+
const jsonFiles = (dir) => sortedNames(dir).filter((n) => n.endsWith('.json')).map((n) => path.join(dir, n)).filter(isFile);
|
|
220
|
+
|
|
221
|
+
function readJson(file, what) {
|
|
222
|
+
try { return py.parse(fs.readFileSync(file, 'utf-8')); } catch (e) {
|
|
223
|
+
throw new ImportInputError(`${what}: ${e.message}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const zipsIn = (dir) => sortedNames(dir).filter((n) => /\.zip$/i.test(n)).map((n) => path.join(dir, n)).filter(isFile);
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* What a path is: 'zip' (a .zip file), 'export' (an unpacked export), 'archive', or 'parts' (a
|
|
231
|
+
* folder holding the export's part zips); throws when it is none.
|
|
232
|
+
*/
|
|
233
|
+
function inputKind(p) {
|
|
234
|
+
if (!fs.existsSync(p)) throw new ImportInputError(`no such file or folder: ${p}`);
|
|
235
|
+
if (!isDir(p)) {
|
|
236
|
+
if (isZip(p)) return 'zip';
|
|
237
|
+
throw new ImportInputError(`${p} is neither a folder nor a .zip file`);
|
|
238
|
+
}
|
|
239
|
+
if (fs.existsSync(path.join(p, 'conversations.json'))) return 'export';
|
|
240
|
+
if (['projects', 'chats-unfiled', 'design-chats'].some((d) => isDir(path.join(p, d)))) return 'archive';
|
|
241
|
+
if (zipsIn(p).length) return 'parts';
|
|
242
|
+
throw new ImportInputError(`${p} holds no claude.ai export: no conversations.json, no part zips (conversations-000.zip, …), no archive (projects/, chats-unfiled/, design-chats/)`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Files in the order a sorted glob of the full paths lists them.
|
|
246
|
+
const byPath = (a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
|
|
247
|
+
|
|
248
|
+
function fromArchive(root) {
|
|
249
|
+
const out = [];
|
|
250
|
+
const filed = [];
|
|
251
|
+
for (const slug of sortedDirs(path.join(root, 'projects'))) {
|
|
252
|
+
for (const f of jsonFiles(path.join(root, 'projects', slug, 'chats'))) filed.push([f, slug]);
|
|
253
|
+
}
|
|
254
|
+
for (const [f, slug] of filed.sort(byPath)) {
|
|
255
|
+
out.push(['project', fromConversation(readJson(f, f), path.join(root, 'projects', slug), slug)]);
|
|
256
|
+
}
|
|
257
|
+
const unfiled = path.join(root, 'chats-unfiled');
|
|
258
|
+
const loose = [];
|
|
259
|
+
for (const year of sortedDirs(unfiled)) for (const f of jsonFiles(path.join(unfiled, year))) loose.push([f]);
|
|
260
|
+
for (const [f] of loose.sort(byPath)) out.push(['unfiled', fromConversation(readJson(f, f), unfiled, PROJECT_UNFILED)]);
|
|
261
|
+
const design = path.join(root, 'design-chats');
|
|
262
|
+
for (const f of jsonFiles(design)) out.push(['design', fromDesignChat(readJson(f, f), design, PROJECT_DESIGN)]);
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function parseExportJson(text, what) {
|
|
267
|
+
try { return py.parse(text); } catch (e) { throw new ImportInputError(`${what}: ${e.message}`); }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function conversationItems(text, what) {
|
|
271
|
+
const convs = parseExportJson(text, what);
|
|
272
|
+
if (!Array.isArray(convs)) throw new ImportInputError(`${what} is not a list`);
|
|
273
|
+
return convs.map((c) => ['chat', fromConversation(c, '', PROJECT_UNKNOWN)]);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function designItems(designTexts) {
|
|
277
|
+
return designTexts.map(([name, text]) => ['design', fromDesignChat(parseExportJson(text, name), '', PROJECT_DESIGN)]);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function fromExportDir(root) {
|
|
281
|
+
const designs = jsonFiles(path.join(root, 'design_chats')).map((f) => [path.basename(f), fs.readFileSync(f, 'utf-8')]);
|
|
282
|
+
return [...conversationItems(fs.readFileSync(path.join(root, 'conversations.json'), 'utf-8'), 'conversations.json'), ...designItems(designs)];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Run fn over an open zip. A damaged or unsupported zip is a bad input, like bad JSON: refused, not a failure. */
|
|
286
|
+
function withZip(file, fn) {
|
|
287
|
+
let z = null;
|
|
288
|
+
try {
|
|
289
|
+
z = openZip(file);
|
|
290
|
+
return fn(z);
|
|
291
|
+
} catch (e) {
|
|
292
|
+
if (e instanceof ZipError) throw new ImportInputError(`${path.basename(file)}: ${e.message}`);
|
|
293
|
+
throw e;
|
|
294
|
+
} finally { if (z) z.close(); }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The zip's conversations.json: at its root, or one folder down in a re-zipped export. */
|
|
298
|
+
const conversationsEntry = (z) => z.names.filter((n) => n === 'conversations.json' || n.endsWith('/conversations.json'))
|
|
299
|
+
.sort((a, b) => a.length - b.length)[0];
|
|
300
|
+
|
|
301
|
+
const designEntries = (z, prefix) => z.names
|
|
302
|
+
.filter((n) => n.startsWith(`${prefix}design_chats/`) && n.endsWith('.json') && !n.slice(prefix.length + 13).includes('/')
|
|
303
|
+
&& visible(path.posix.basename(n)))
|
|
304
|
+
.sort()
|
|
305
|
+
.map((n) => [path.posix.basename(n), z.read(n).toString('utf-8')]);
|
|
306
|
+
|
|
307
|
+
/** The single-zip export: every file in one zip. */
|
|
308
|
+
function fromWholeZip(file) {
|
|
309
|
+
return withZip(file, (z) => {
|
|
310
|
+
const conv = conversationsEntry(z);
|
|
311
|
+
if (!conv) throw new ImportInputError(`${path.basename(file)} holds no conversations.json — is it a claude.ai export?`);
|
|
312
|
+
const prefix = conv.slice(0, -'conversations.json'.length);
|
|
313
|
+
return [...conversationItems(z.read(conv).toString('utf-8'), 'conversations.json'), ...designItems(designEntries(z, prefix))];
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// The export as it arrives now: one zip per kind and index, <kind>-<NNN>.zip — conversations-000.zip,
|
|
318
|
+
// design_chats-000.zip, memories-000.zip and so on. A browser's re-download suffix (" (1)") keeps
|
|
319
|
+
// the kind and index, so a second copy of a part is recognised as one.
|
|
320
|
+
const PART = /^(.+?)-(\d{3,})(?: \(\d+\))?\.zip$/i;
|
|
321
|
+
const PART_KINDS = ['conversations', 'design_chats'];
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Zips -> items. Each is a part (by name: conversations or design_chats, read; any other kind,
|
|
325
|
+
* ignored and named), or a whole single-zip export (it holds conversations.json). Parts are read
|
|
326
|
+
* in index order: every conversations part, then every design_chats part.
|
|
327
|
+
*/
|
|
328
|
+
function fromZips(files) {
|
|
329
|
+
const parts = { conversations: [], design_chats: [] };
|
|
330
|
+
const whole = [];
|
|
331
|
+
const ignored = [];
|
|
332
|
+
const seen = new Map();
|
|
333
|
+
for (const f of files) {
|
|
334
|
+
if (!isZip(f)) throw new ImportInputError(`${path.basename(f)} is not a zip file`);
|
|
335
|
+
const m = PART.exec(path.basename(f));
|
|
336
|
+
const kind = m ? m[1].toLowerCase() : null;
|
|
337
|
+
if (PART_KINDS.includes(kind)) {
|
|
338
|
+
const key = `${kind}-${Number(m[2])}`;
|
|
339
|
+
if (seen.has(key)) {
|
|
340
|
+
throw new ImportInputError(`${path.basename(seen.get(key))} and ${path.basename(f)} are the same part (${kind} ${m[2]}): keep one`);
|
|
341
|
+
}
|
|
342
|
+
seen.set(key, f);
|
|
343
|
+
parts[kind].push({ file: f, index: Number(m[2]) });
|
|
344
|
+
} else if (withZip(f, (z) => Boolean(conversationsEntry(z)))) whole.push(f);
|
|
345
|
+
else ignored.push(path.basename(f));
|
|
346
|
+
}
|
|
347
|
+
const nParts = parts.conversations.length + parts.design_chats.length;
|
|
348
|
+
if (whole.length > 1) throw new ImportInputError(`${whole.map((f) => path.basename(f)).join(' and ')} are each a whole export: import one at a time`);
|
|
349
|
+
if (whole.length && nParts) throw new ImportInputError(`${path.basename(whole[0])} is a whole export; give it alone, or give the part zips without it`);
|
|
350
|
+
if (whole.length) return { kind: 'zip', items: fromWholeZip(whole[0]), ignored, read: [path.basename(whole[0])] };
|
|
351
|
+
if (!parts.conversations.length) {
|
|
352
|
+
throw new ImportInputError(`no conversations part among the inputs: pass conversations-000.zip (and any -001, -002, …) with the other parts${ignored.length ? `; got ${ignored.join(', ')}` : ''}`);
|
|
353
|
+
}
|
|
354
|
+
const byIndex = (a, b) => a.index - b.index;
|
|
355
|
+
const items = [];
|
|
356
|
+
const read = [];
|
|
357
|
+
for (const p of parts.conversations.sort(byIndex)) {
|
|
358
|
+
const name = path.basename(p.file);
|
|
359
|
+
withZip(p.file, (z) => {
|
|
360
|
+
const e = conversationsEntry(z);
|
|
361
|
+
if (!e) throw new ImportInputError(`${name} holds no conversations.json`);
|
|
362
|
+
items.push(...conversationItems(z.read(e).toString('utf-8'), `${name}: conversations.json`));
|
|
363
|
+
});
|
|
364
|
+
read.push(name);
|
|
365
|
+
}
|
|
366
|
+
for (const p of parts.design_chats.sort(byIndex)) {
|
|
367
|
+
withZip(p.file, (z) => {
|
|
368
|
+
const e = z.names.find((n) => /(^|\/)design_chats\/[^/]+\.json$/.test(n));
|
|
369
|
+
items.push(...designItems(designEntries(z, e ? e.slice(0, e.indexOf('design_chats/')) : '')));
|
|
370
|
+
});
|
|
371
|
+
read.push(path.basename(p.file));
|
|
372
|
+
}
|
|
373
|
+
return { kind: 'parts', items, ignored, read };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Read an input: one path, or several zips. Returns { kind, sessions, duplicates, empty, skipped }:
|
|
378
|
+
* kind archive, export (an unpacked export), zip (a whole single-zip export), parts
|
|
379
|
+
* sessions one per conversation that has something to store, first occurrence kept
|
|
380
|
+
* duplicates conversations met again under the same id (an archive can file one twice; parts
|
|
381
|
+
* can repeat one)
|
|
382
|
+
* empty conversations with nothing to store
|
|
383
|
+
* skipped blocks not stored, by reason
|
|
384
|
+
* categories sessions per category (project, unfiled, chat, design)
|
|
385
|
+
* read the zips read; ignored the zips passed over (other parts of the export)
|
|
386
|
+
*/
|
|
387
|
+
function readInput(inputs) {
|
|
388
|
+
const list = (Array.isArray(inputs) ? inputs : [inputs]).map((p) => path.resolve(p));
|
|
389
|
+
if (!list.length) throw new ImportInputError('no input given');
|
|
390
|
+
let r;
|
|
391
|
+
if (list.length === 1) {
|
|
392
|
+
const kind = inputKind(list[0]);
|
|
393
|
+
if (kind === 'export') r = { kind, items: fromExportDir(list[0]), ignored: [], read: [] };
|
|
394
|
+
else if (kind === 'archive') r = { kind, items: fromArchive(list[0]), ignored: [], read: [] };
|
|
395
|
+
else r = fromZips(kind === 'parts' ? zipsIn(list[0]) : list);
|
|
396
|
+
} else {
|
|
397
|
+
for (const p of list) {
|
|
398
|
+
if (!fs.existsSync(p)) throw new ImportInputError(`no such file or folder: ${p}`);
|
|
399
|
+
if (isDir(p)) throw new ImportInputError(`${p} is a folder: several inputs must all be .zip files, and a folder goes alone`);
|
|
400
|
+
}
|
|
401
|
+
r = fromZips(list);
|
|
402
|
+
}
|
|
403
|
+
const seen = new Set();
|
|
404
|
+
const out = { kind: r.kind, sessions: [], duplicates: 0, empty: 0, skipped: {}, categories: {}, read: r.read, ignored: r.ignored };
|
|
405
|
+
for (const [category, s] of r.items) {
|
|
406
|
+
if (seen.has(s.sessionId)) { out.duplicates++; continue; }
|
|
407
|
+
seen.add(s.sessionId);
|
|
408
|
+
for (const [k, v] of Object.entries(s.skipped)) out.skipped[k] = (out.skipped[k] || 0) + v;
|
|
409
|
+
if (!s.parts.length) { out.empty++; continue; }
|
|
410
|
+
s.category = category;
|
|
411
|
+
out.categories[category] = (out.categories[category] || 0) + 1;
|
|
412
|
+
out.sessions.push(s);
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
module.exports = {
|
|
418
|
+
HARNESS, STORE, PROJECT_UNFILED, PROJECT_DESIGN, PROJECT_UNKNOWN, PARSER_VERSION, ImportInputError,
|
|
419
|
+
chTs, flattenResult, fromConversation, fromDesignChat, inputKind, readInput,
|
|
420
|
+
};
|