lattris 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/dist/extract-claude-jsonl.js +253 -0
- package/dist/main.js +6587 -0
- package/dist/save-session-chain.js +136 -0
- package/package.json +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# lattris
|
|
2
|
+
|
|
3
|
+
First-party command-line client for [Lattris](https://lattris.com) — sign in, capture
|
|
4
|
+
notes and sessions, and read your own tree from the terminal.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install -g lattris
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Requires Node.js 22 or newer. The package ships compiled JavaScript only; there is no
|
|
13
|
+
build step and no postinstall script.
|
|
14
|
+
|
|
15
|
+
## Sign in
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
lattris login
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That opens your browser and completes an authorization-code sign-in with PKCE. On a
|
|
22
|
+
machine with no browser — a server, a container, a remote shell — use the device flow
|
|
23
|
+
instead and enter the code it prints on any other device:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
lattris login --device
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Both leave you signed in on that machine. `lattris logout` ends it.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
lattris whoami
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
confirms who you are signed in as, and exits non-zero when you are not signed in, so it
|
|
36
|
+
is safe to use as a guard in a script.
|
|
37
|
+
|
|
38
|
+
**You need an account first.** Lattris is invite-only; the CLI signs you in, it does not
|
|
39
|
+
sign you up.
|
|
40
|
+
|
|
41
|
+
## What it does
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
lattris capture write a note or start a session
|
|
45
|
+
lattris note a single note
|
|
46
|
+
lattris session a session and its turns
|
|
47
|
+
lattris recording a recording and its transcript
|
|
48
|
+
lattris memory read and manage blocks
|
|
49
|
+
lattris link connect two blocks
|
|
50
|
+
lattris get read a block
|
|
51
|
+
lattris ask ask a question of a tree
|
|
52
|
+
lattris invite invite someone
|
|
53
|
+
lattris contact manage contacts
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Run `lattris help <command>` for any of them, or `lattris --help` for the full list.
|
|
57
|
+
|
|
58
|
+
## Where it keeps things
|
|
59
|
+
|
|
60
|
+
Credentials live in your user config directory and are readable only by you. `lattris
|
|
61
|
+
logout` removes them. Nothing is written to the directory you run it from.
|
|
62
|
+
|
|
63
|
+
## Verifying what you installed
|
|
64
|
+
|
|
65
|
+
Releases published through CI carry [npm provenance](https://docs.npmjs.com/generating-provenance-statements):
|
|
66
|
+
the registry records an attestation linking the tarball to the commit on `main` it was
|
|
67
|
+
built from and the workflow run that built it. The first published version was published
|
|
68
|
+
by hand to claim the name and carries no attestation, so do not assume one — check:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm audit signatures
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
That is a statement about the version you installed from the registry. It says nothing
|
|
75
|
+
about a copy obtained any other way.
|
|
76
|
+
|
|
77
|
+
## Source, terms and reporting
|
|
78
|
+
|
|
79
|
+
This is proprietary software. The source is not public, and `UNLICENSED` is
|
|
80
|
+
deliberate rather than an oversight: formal terms of use are still to follow.
|
|
81
|
+
Nothing here grants you rights beyond installing and running the tool, and no
|
|
82
|
+
permission should be inferred from the absence of a licence file.
|
|
83
|
+
|
|
84
|
+
There is no public issue tracker, so this package names none — a link you cannot
|
|
85
|
+
open is worse than no link. For terms, faults or anything else, write to
|
|
86
|
+
series@lattris.com.
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Deterministic extractor: a Claude Code transcript
|
|
2
|
+
// (~/.claude/projects/<project>/<session>.jsonl) in, ordered epoch turns out
|
|
3
|
+
// (session-block spec §Skill — each rule verified against a real
|
|
4
|
+
// transcript corpus, never inferred from vendor docs).
|
|
5
|
+
// Extraction is MECHANICAL — one place for this vendor's shape, zero
|
|
6
|
+
// judgment: the same file always yields the same epochs, byte for byte.
|
|
7
|
+
// The AI-judgment touchpoints (when to save, title, ai_summary) live in the
|
|
8
|
+
// skill, never here.
|
|
9
|
+
//
|
|
10
|
+
// An internal module behind `lattris capture session <transcript.jsonl>`
|
|
11
|
+
// (main.ts) — not a command of its own.
|
|
12
|
+
//
|
|
13
|
+
// The settled extraction rules, in the spec's own words:
|
|
14
|
+
// - The transcript is a TREE, not a list: every entry carries uuid/
|
|
15
|
+
// parentUuid; the conversation is the walk from the live leaf up, never
|
|
16
|
+
// file line order. Dead branches (rewinds) stay in the file and are simply
|
|
17
|
+
// not on the walked path.
|
|
18
|
+
// - The chain break at compaction: the system/compact_boundary entry has
|
|
19
|
+
// parentUuid null; continuity is its logicalParentUuid — a naive
|
|
20
|
+
// parent-walk silently truncates at the last compaction, so the walk
|
|
21
|
+
// bridges it. Each boundary starts a NEW EPOCH; the summary rides the
|
|
22
|
+
// isCompactSummary user entry and becomes the next epoch's opening
|
|
23
|
+
// kind: 'compaction-summary' marker turn.
|
|
24
|
+
// - Most "user" entries are not the person: promptSource 'typed' +
|
|
25
|
+
// origin.kind 'human' is the person; tool_result carriers, meta entries
|
|
26
|
+
// and task notifications are machinery; isSidechain entries are subagent
|
|
27
|
+
// noise — all stripped.
|
|
28
|
+
// - Thinking blocks are OUT (private scratchpad, not dialogue); an
|
|
29
|
+
// assistant turn is its message's text blocks only, and one API message
|
|
30
|
+
// spans MULTIPLE jsonl entries (one content block each, same message.id)
|
|
31
|
+
// — concatenated in path order.
|
|
32
|
+
// - Peer-session messages are stamped as their own speaker
|
|
33
|
+
// (peer:<origin.name>), never the owner; the body is origin.body (the
|
|
34
|
+
// raw message, without the harness's delivery wrapper).
|
|
35
|
+
// - Two file shapes. A MAIN transcript is the person's session; sidechain
|
|
36
|
+
// entries inside it are a subagent's turns interleaved, and noise. A
|
|
37
|
+
// SUBAGENT transcript (…/<session>/subagents/agent-<id>.jsonl) is that
|
|
38
|
+
// subagent's own file: every path entry is isSidechain with an agentId,
|
|
39
|
+
// its first user entry is the parent's prompt (no promptSource, no
|
|
40
|
+
// origin), and its later user entries are tool_result carriers. The
|
|
41
|
+
// shape is read from the file and named on the result, and the same
|
|
42
|
+
// chain of epochs comes out of either.
|
|
43
|
+
//
|
|
44
|
+
// Zero imports beyond node built-ins, mirroring main.ts's own discipline.
|
|
45
|
+
const parseLines = (jsonl) => {
|
|
46
|
+
const entries = [];
|
|
47
|
+
for (const line of jsonl.split('\n')) {
|
|
48
|
+
if (line.trim().length === 0)
|
|
49
|
+
continue;
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(line);
|
|
52
|
+
if (parsed && typeof parsed === 'object')
|
|
53
|
+
entries.push(parsed);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// A torn tail line (the file is appended live) is not a conversation
|
|
57
|
+
// fact — skipped, deterministically.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return entries;
|
|
61
|
+
};
|
|
62
|
+
// Path-bearing entries: the tree the walk runs over. Everything else
|
|
63
|
+
// (attachments, file-history, queue bookkeeping…) never carries dialogue.
|
|
64
|
+
const isPathEntry = (e) => typeof e.uuid === 'string' &&
|
|
65
|
+
(e.type === 'user' || e.type === 'assistant' || e.type === 'system');
|
|
66
|
+
// The ACTIVE path, live leaf up, oldest-first on return.
|
|
67
|
+
// The live leaf is the LAST path-bearing entry in file order — the file is
|
|
68
|
+
// an append log, so its tail is the current tip; every dead branch hangs off
|
|
69
|
+
// an earlier fork and is unreachable walking UP from here. The compaction
|
|
70
|
+
// bridge: parentUuid null + logicalParentUuid set continues the walk into
|
|
71
|
+
// the previous epoch.
|
|
72
|
+
export const walkActivePath = (entries) => {
|
|
73
|
+
const byUuid = new Map();
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
if (typeof e.uuid === 'string')
|
|
76
|
+
byUuid.set(e.uuid, e);
|
|
77
|
+
}
|
|
78
|
+
let cursor = [...entries].reverse().find(isPathEntry);
|
|
79
|
+
const path = [];
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
while (cursor) {
|
|
82
|
+
if (typeof cursor.uuid !== 'string' || seen.has(cursor.uuid))
|
|
83
|
+
break; // cycle guard
|
|
84
|
+
seen.add(cursor.uuid);
|
|
85
|
+
path.push(cursor);
|
|
86
|
+
const parentId = cursor.parentUuid ?? cursor.logicalParentUuid;
|
|
87
|
+
cursor = typeof parentId === 'string' ? byUuid.get(parentId) : undefined;
|
|
88
|
+
}
|
|
89
|
+
return path.reverse();
|
|
90
|
+
};
|
|
91
|
+
const textOf = (content) => {
|
|
92
|
+
if (typeof content === 'string')
|
|
93
|
+
return content;
|
|
94
|
+
if (!Array.isArray(content))
|
|
95
|
+
return '';
|
|
96
|
+
return content
|
|
97
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
98
|
+
.map((b) => b.text)
|
|
99
|
+
.join('\n\n');
|
|
100
|
+
};
|
|
101
|
+
// The shape of a FILE, judged over every path-bearing entry before any
|
|
102
|
+
// walk: a subagent's own file has all of them marked isSidechain with an
|
|
103
|
+
// agentId; anything else is a main transcript, where a sidechain entry is
|
|
104
|
+
// another agent's turn and noise, even at the file's tail. Judged before
|
|
105
|
+
// the walk because the walk starts at the last path entry in file order,
|
|
106
|
+
// and a main file whose tail is a subagent's appended turns would
|
|
107
|
+
// otherwise be walked from the subagent's leaf.
|
|
108
|
+
export const shapeOf = (entries) => {
|
|
109
|
+
const bearing = entries.filter(isPathEntry);
|
|
110
|
+
return bearing.length > 0 &&
|
|
111
|
+
bearing.every((e) => e.isSidechain === true && typeof e.agentId === 'string')
|
|
112
|
+
? 'subagent'
|
|
113
|
+
: 'main';
|
|
114
|
+
};
|
|
115
|
+
const carriesToolResult = (content) => Array.isArray(content) &&
|
|
116
|
+
content.some((b) => b && typeof b === 'object' && b.type === 'tool_result');
|
|
117
|
+
export const classify = (e, shape = 'main') => {
|
|
118
|
+
if (shape === 'main' && e.isSidechain === true)
|
|
119
|
+
return { kind: 'noise' };
|
|
120
|
+
if (e.type === 'system') {
|
|
121
|
+
return e.subtype === 'compact_boundary'
|
|
122
|
+
? { kind: 'epoch-boundary', entry: e }
|
|
123
|
+
: { kind: 'noise' };
|
|
124
|
+
}
|
|
125
|
+
if (e.type === 'user') {
|
|
126
|
+
// The compaction summary — identified by its flag BEFORE any
|
|
127
|
+
// person/machinery classification (it is neither).
|
|
128
|
+
if (e.isCompactSummary === true) {
|
|
129
|
+
const text = textOf(e.message?.content);
|
|
130
|
+
return text.trim().length > 0 ? { kind: 'marker', text, entry: e } : { kind: 'noise' };
|
|
131
|
+
}
|
|
132
|
+
if (e.origin?.kind === 'peer') {
|
|
133
|
+
const name = typeof e.origin.name === 'string' && e.origin.name.length > 0 ? e.origin.name : 'unknown';
|
|
134
|
+
// origin.body is the raw peer message; the entry's own content wraps
|
|
135
|
+
// it in the harness's delivery boilerplate, which is machinery.
|
|
136
|
+
const body = typeof e.origin.body === 'string' ? e.origin.body : textOf(e.message?.content);
|
|
137
|
+
return body.trim().length > 0
|
|
138
|
+
? { kind: 'peer', speaker: `peer:${name}`, text: body, entry: e }
|
|
139
|
+
: { kind: 'noise' };
|
|
140
|
+
}
|
|
141
|
+
if (e.promptSource === 'typed' && e.origin?.kind === 'human' && e.isMeta !== true) {
|
|
142
|
+
const text = textOf(e.message?.content);
|
|
143
|
+
return text.trim().length > 0 ? { kind: 'person', text, entry: e } : { kind: 'noise' };
|
|
144
|
+
}
|
|
145
|
+
// In a subagent's own file the parent's prompt carries no promptSource
|
|
146
|
+
// and no origin: it is the user entry that is words and not a
|
|
147
|
+
// tool_result carrier.
|
|
148
|
+
if (shape === 'subagent' && e.isMeta !== true && !carriesToolResult(e.message?.content)) {
|
|
149
|
+
const text = textOf(e.message?.content);
|
|
150
|
+
return text.trim().length > 0 ? { kind: 'person', text, entry: e } : { kind: 'noise' };
|
|
151
|
+
}
|
|
152
|
+
// tool_result carriers, meta entries, task notifications, queued
|
|
153
|
+
// machinery — not dialogue.
|
|
154
|
+
return { kind: 'noise' };
|
|
155
|
+
}
|
|
156
|
+
if (e.type === 'assistant') {
|
|
157
|
+
// Text blocks only — thinking is OUT (deliberate: private scratchpad,
|
|
158
|
+
// never dialogue), tool_use is
|
|
159
|
+
// machinery. One entry carries one block; same-message entries merge in
|
|
160
|
+
// the caller.
|
|
161
|
+
const text = textOf(e.message?.content);
|
|
162
|
+
return text.trim().length > 0
|
|
163
|
+
? { kind: 'assistant-block', messageId: e.message?.id, text, entry: e }
|
|
164
|
+
: { kind: 'noise' };
|
|
165
|
+
}
|
|
166
|
+
return { kind: 'noise' };
|
|
167
|
+
};
|
|
168
|
+
const toTurn = (speaker, text, entry, kind) => {
|
|
169
|
+
const turn = { speaker, text };
|
|
170
|
+
if (typeof entry.timestamp === 'string')
|
|
171
|
+
turn.saidAt = entry.timestamp;
|
|
172
|
+
if (kind !== undefined)
|
|
173
|
+
turn.kind = kind;
|
|
174
|
+
if (typeof entry.uuid === 'string')
|
|
175
|
+
turn.externalMessageId = entry.uuid;
|
|
176
|
+
const parent = entry.parentUuid ?? entry.logicalParentUuid;
|
|
177
|
+
if (typeof parent === 'string')
|
|
178
|
+
turn.externalParentMessageId = parent;
|
|
179
|
+
return turn;
|
|
180
|
+
};
|
|
181
|
+
// The whole extraction: entries → active path → epochs of ordered turns.
|
|
182
|
+
export const extractChain = (jsonl) => {
|
|
183
|
+
const entries = parseLines(jsonl);
|
|
184
|
+
const shape = shapeOf(entries);
|
|
185
|
+
// In a main file the sidechain entries are noise before the walk too, so
|
|
186
|
+
// the live leaf is the person's last word and never a subagent's.
|
|
187
|
+
const path = walkActivePath(shape === 'main' ? entries.filter((e) => e.isSidechain !== true) : entries);
|
|
188
|
+
const agentId = shape === 'subagent'
|
|
189
|
+
? (path.map((e) => e.agentId).find((a) => typeof a === 'string') ?? null)
|
|
190
|
+
: null;
|
|
191
|
+
const sessionId = path.map((e) => e.sessionId).find((s) => typeof s === 'string') ?? null;
|
|
192
|
+
// A subagent's conversation identity is its own: the parent's session id
|
|
193
|
+
// and its agent id together, distinct from the parent's and from every
|
|
194
|
+
// sibling's, the same on every capture of the same file. The receipts and
|
|
195
|
+
// the conversation nomination key on it, so two siblings captured under
|
|
196
|
+
// one session never extend or fork each other. Joined with a separator a
|
|
197
|
+
// file name can carry, since the receipts file is named by it.
|
|
198
|
+
const conversationId = shape === 'subagent' && sessionId !== null && agentId !== null
|
|
199
|
+
? `${sessionId}--agent-${agentId}`
|
|
200
|
+
: sessionId;
|
|
201
|
+
const epochs = [];
|
|
202
|
+
let current = [];
|
|
203
|
+
// The assistant merge: consecutive path entries sharing message.id are
|
|
204
|
+
// one API message — their text blocks concatenate into ONE turn (path
|
|
205
|
+
// order), stamped with the FIRST entry's ids/time.
|
|
206
|
+
let pendingAssistant = null;
|
|
207
|
+
const flushAssistant = () => {
|
|
208
|
+
if (!pendingAssistant)
|
|
209
|
+
return;
|
|
210
|
+
current.push(toTurn('assistant', pendingAssistant.texts.join('\n\n'), pendingAssistant.entry));
|
|
211
|
+
pendingAssistant = null;
|
|
212
|
+
};
|
|
213
|
+
for (const e of path) {
|
|
214
|
+
const c = classify(e, shape);
|
|
215
|
+
if (c.kind === 'assistant-block') {
|
|
216
|
+
if (pendingAssistant &&
|
|
217
|
+
pendingAssistant.messageId === c.messageId &&
|
|
218
|
+
c.messageId !== undefined) {
|
|
219
|
+
pendingAssistant.texts.push(c.text);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
flushAssistant();
|
|
223
|
+
pendingAssistant = { messageId: c.messageId, texts: [c.text], entry: c.entry };
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (c.kind === 'noise')
|
|
228
|
+
continue;
|
|
229
|
+
flushAssistant();
|
|
230
|
+
if (c.kind === 'epoch-boundary') {
|
|
231
|
+
// A new epoch begins. The boundary itself carries no words — the
|
|
232
|
+
// summary marker (the next epoch's first classified entry) does.
|
|
233
|
+
if (current.length > 0)
|
|
234
|
+
epochs.push(current);
|
|
235
|
+
current = [];
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (c.kind === 'marker') {
|
|
239
|
+
current.push(toTurn('system', c.text, c.entry, 'compaction-summary'));
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (c.kind === 'person') {
|
|
243
|
+
current.push(toTurn('user', c.text, c.entry));
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
current.push(toTurn(c.speaker, c.text, c.entry));
|
|
247
|
+
}
|
|
248
|
+
flushAssistant();
|
|
249
|
+
if (current.length > 0)
|
|
250
|
+
epochs.push(current);
|
|
251
|
+
return { source: 'claude-code', conversationId, shape, agentId, epochs };
|
|
252
|
+
};
|
|
253
|
+
//# sourceMappingURL=extract-claude-jsonl.js.map
|