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 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