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.
@@ -0,0 +1,136 @@
1
+ // The whole-chain materialiser (session-block spec: "the extractor
2
+ // materialises the WHOLE chain oldest-epoch-first on every save — extend an
3
+ // epoch's block where it exists, create-and-link where it doesn't —
4
+ // complete, idempotent, regardless of when the person last saved"; block-
5
+ // graph spec: "The whole loop — epoch detection, chain materialisation,
6
+ // fork handling, edge writing, reasons — is a deterministic script reading
7
+ // the jsonl on one side and the CLI/API's own responses on the other").
8
+ //
9
+ // An internal module behind `lattris capture session <transcript.jsonl>`
10
+ // (main.ts), which owns the IO: the token, the POST, the receipts dir, the
11
+ // door rendering. What lives here is the deterministic drive loop and the
12
+ // receipts store — machine-read receipts and refusal CODES, never parsed
13
+ // prose. Per epoch, oldest first:
14
+ // - a RECEIPT HINT exists for this epoch (a prior run's own receipt,
15
+ // cached under the state dir): save with `extends` — the server's
16
+ // fingerprints decide; a divergence 409 is the scripted fork signal →
17
+ // re-submit `asNewBlock` + `forkedFrom` that block (the server verifies
18
+ // THAT claim too — the hint only ever nominates, per the law: "receipts
19
+ // and external ids are hints; the server's fingerprint comparison is
20
+ // the truth").
21
+ // - no hint: a plain save (`externalConversationId` nomination resolves a
22
+ // known epoch; otherwise create), with `continuesFrom` naming the
23
+ // PREVIOUS epoch's block from THIS run's receipts — the chain edge.
24
+ // A forked block carries its `forked_into` edge INSTEAD of a chain edge
25
+ // (the door's one-born-with-edge rule); continuity stays derivable
26
+ // through the origin's own chain — edge-economy, nothing duplicated.
27
+ // - the receipt (outcome + block id + turn count) is recorded back into
28
+ // the hint file, so the next run resumes exactly where the truth is.
29
+ //
30
+ // The AI-judgment inputs are --title, --ai-summary (applied to the LIVE —
31
+ // last — epoch's save only), and --contributor (applied to EVERY epoch's
32
+ // save — attribution is a whole-chain fact, not an epoch-specific one, and
33
+ // an extend's own save carries it so re-running with the flag heals older
34
+ // epochs of the same chain); everything else is mechanical.
35
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ // A save the drive loop could not scriptedly react to (anything beyond the
38
+ // divergence-fork and stale-hint outcomes above). Typed, carrying the wire's
39
+ // own status and body, so the command can render it through the CLI's one
40
+ // door rule (classifyApiError → renderDoor) with the house exit codes — a
41
+ // 401 mid-chain must read as NOAUTH, not a generic failure.
42
+ export class ChainSaveError extends Error {
43
+ // Explicit fields, not constructor parameter properties — those are
44
+ // TS-only syntax the no-build `node --experimental-strip-types` run
45
+ // cannot erase (strip-only mode refuses them outright).
46
+ epoch;
47
+ status;
48
+ body;
49
+ constructor(epoch, status, body) {
50
+ super(`epoch ${epoch} save failed (${status} ${body.code ?? ''}): ${body.message ?? 'no message'}`);
51
+ this.epoch = epoch;
52
+ this.status = status;
53
+ this.body = body;
54
+ }
55
+ }
56
+ export const driveChain = async (chain, receipts, judgment, save) => {
57
+ const results = [];
58
+ let previousBlockId;
59
+ for (let k = 0; k < chain.epochs.length; k++) {
60
+ const turns = chain.epochs[k];
61
+ const isLive = k === chain.epochs.length - 1;
62
+ const base = { turns, source: chain.source };
63
+ if (chain.conversationId !== null)
64
+ base.externalConversationId = chain.conversationId;
65
+ if (isLive && judgment.title !== undefined)
66
+ base.title = judgment.title;
67
+ if (isLive && judgment.aiSummary !== undefined)
68
+ base.aiSummary = judgment.aiSummary;
69
+ // contributor rides EVERY epoch, not just the live one — attribution is a
70
+ // whole-chain fact, and an extend re-running with the flag heals older
71
+ // epochs whose original save predated the contact.
72
+ if (judgment.contributor !== undefined)
73
+ base.contributor = judgment.contributor;
74
+ const hinted = receipts.epochs[String(k)];
75
+ let outcome;
76
+ let forked = false;
77
+ if (hinted !== undefined) {
78
+ outcome = await save({ ...base, extends: hinted });
79
+ if (outcome.status === 409 && outcome.body.code === 'memory:session-diverged') {
80
+ // The scripted fork reaction: the walked path rewound-and-diverged
81
+ // from what that block holds — a NEW record, its origin claimed and
82
+ // server-verified. No continuesFrom here (the door's one-edge rule);
83
+ // the chain stays derivable through the origin.
84
+ outcome = await save({ ...base, asNewBlock: true, forkedFrom: hinted });
85
+ forked = true;
86
+ }
87
+ else if (outcome.status === 400 && outcome.body.code === 'memory:unknown-extends') {
88
+ // The hint went stale (the block was deleted) — hints never decide:
89
+ // fall back to the no-hint path.
90
+ outcome = await save(previousBlockId !== undefined ? { ...base, continuesFrom: previousBlockId } : base);
91
+ }
92
+ }
93
+ else {
94
+ outcome = await save(previousBlockId !== undefined && k > 0 ? { ...base, continuesFrom: previousBlockId } : base);
95
+ }
96
+ if (outcome.status !== 200 || !outcome.body.block?.id) {
97
+ throw new ChainSaveError(k, outcome.status, outcome.body);
98
+ }
99
+ const blockId = outcome.body.block.id;
100
+ receipts.epochs[String(k)] = blockId;
101
+ results.push({
102
+ epoch: k,
103
+ blockId,
104
+ outcome: outcome.body.outcome ?? 'unknown',
105
+ turnCount: outcome.body.turnCount ?? turns.length,
106
+ forked
107
+ });
108
+ previousBlockId = blockId;
109
+ }
110
+ return results;
111
+ };
112
+ // ─── the receipts store (hints, never decisions) ────────────────────────────
113
+ // Lives under the CLI's own state dir — the command passes sessionDir() in, so
114
+ // this module never re-derives the LATTRIS_HOME/XDG resolution main.ts owns.
115
+ // Same 0700/0600 hygiene as the session file: a receipt names block ids in the
116
+ // person's own record.
117
+ const receiptsPath = (dir, conversationId) => join(dir, 'chains', `${conversationId}.json`);
118
+ export const loadReceipts = (dir, conversationId) => {
119
+ if (conversationId === null)
120
+ return { epochs: {} };
121
+ try {
122
+ return JSON.parse(readFileSync(receiptsPath(dir, conversationId), 'utf8'));
123
+ }
124
+ catch {
125
+ return { epochs: {} };
126
+ }
127
+ };
128
+ export const saveReceipts = (dir, conversationId, receipts) => {
129
+ if (conversationId === null)
130
+ return;
131
+ mkdirSync(join(dir, 'chains'), { recursive: true, mode: 0o700 });
132
+ writeFileSync(receiptsPath(dir, conversationId), JSON.stringify(receipts, null, 2), {
133
+ mode: 0o600
134
+ });
135
+ };
136
+ //# sourceMappingURL=save-session-chain.js.map
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "lattris",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "First-party CLI for Lattris — sign in, capture notes and sessions, and read your own tree from the terminal.",
6
+ "keywords": [
7
+ "lattris",
8
+ "cli",
9
+ "notes",
10
+ "knowledge"
11
+ ],
12
+ "license": "UNLICENSED",
13
+ "homepage": "https://lattris.com",
14
+ "bin": {
15
+ "lattris": "./dist/main.js"
16
+ },
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "files": [
21
+ "dist/main.js",
22
+ "dist/extract-claude-jsonl.js",
23
+ "dist/save-session-chain.js",
24
+ "dist/build.json"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ }
29
+ }