lattris 0.1.4 → 0.2.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.
Files changed (79) hide show
  1. package/README.md +2 -2
  2. package/dist/build.json +1 -1
  3. package/dist/datasets/capture.js +118 -0
  4. package/dist/datasets/page.js +64 -0
  5. package/dist/datasets/pieces.js +78 -0
  6. package/dist/dispatch-form.js +202 -0
  7. package/dist/dispatch-words.js +133 -0
  8. package/dist/dispatch.js +67 -0
  9. package/dist/entry/session.js +219 -0
  10. package/dist/entry/set.js +130 -0
  11. package/dist/entry/setup-boot.js +151 -0
  12. package/dist/entry/setup.js +145 -0
  13. package/dist/entry/use.js +127 -0
  14. package/dist/help.js +65 -0
  15. package/dist/main.js +143 -6887
  16. package/dist/memory/access.js +88 -0
  17. package/dist/memory/capture-common.js +171 -0
  18. package/dist/memory/capture-help.js +195 -0
  19. package/dist/memory/capture-note.js +200 -0
  20. package/dist/memory/capture-session.js +168 -0
  21. package/dist/memory/delete.js +88 -0
  22. package/dist/memory/erase.js +67 -0
  23. package/dist/{extract-claude-jsonl.js → memory/extract-claude-jsonl.js} +5 -94
  24. package/dist/memory/get-help.js +72 -0
  25. package/dist/memory/get.js +246 -0
  26. package/dist/memory/history.js +134 -0
  27. package/dist/memory/recording-update.js +128 -0
  28. package/dist/memory/recording.js +258 -0
  29. package/dist/memory/resend.js +82 -0
  30. package/dist/memory/restore.js +73 -0
  31. package/dist/memory/save-session-chain.js +59 -0
  32. package/dist/memory/transcript.js +88 -0
  33. package/dist/memory/update-help.js +126 -0
  34. package/dist/memory/update.js +220 -0
  35. package/dist/memory/views.js +146 -0
  36. package/dist/nouns/consent.js +29 -0
  37. package/dist/nouns/contact.js +203 -0
  38. package/dist/nouns/datasets.js +109 -0
  39. package/dist/nouns/edges.js +106 -0
  40. package/dist/nouns/form.js +2 -0
  41. package/dist/nouns/index.js +48 -0
  42. package/dist/nouns/invite.js +98 -0
  43. package/dist/nouns/questions.js +116 -0
  44. package/dist/nouns/share.js +266 -0
  45. package/dist/nouns/words.js +81 -0
  46. package/dist/registry/datasets.js +15 -0
  47. package/dist/registry/entry.js +51 -0
  48. package/dist/registry/index.js +17 -0
  49. package/dist/registry/memory.js +116 -0
  50. package/dist/render/datasets.js +77 -0
  51. package/dist/render/door.js +39 -0
  52. package/dist/render/output.js +19 -0
  53. package/dist/render/prompt.js +4 -0
  54. package/dist/render/rows.js +82 -0
  55. package/dist/render/text.js +21 -0
  56. package/dist/request/access-token.js +67 -0
  57. package/dist/request/api-fetch.js +76 -0
  58. package/dist/request/callback.js +86 -0
  59. package/dist/request/door.js +74 -0
  60. package/dist/request/flags.js +38 -0
  61. package/dist/request/io.js +2 -0
  62. package/dist/request/paste-and-device.js +99 -0
  63. package/dist/request/stdin.js +22 -0
  64. package/dist/request/token.js +66 -0
  65. package/dist/request/upload.js +6 -0
  66. package/dist/request/version-check.js +79 -0
  67. package/dist/store/chains.js +25 -0
  68. package/dist/store/drafts.js +48 -0
  69. package/dist/store/environment.js +57 -0
  70. package/dist/store/exit.js +13 -0
  71. package/dist/store/io.js +2 -0
  72. package/dist/store/json-file.js +9 -0
  73. package/dist/store/session-home.js +189 -0
  74. package/dist/store/session-lock.js +169 -0
  75. package/dist/store/session.js +70 -0
  76. package/dist/store/write-identity.js +153 -0
  77. package/dist/version.js +16 -0
  78. package/package.json +3 -4
  79. package/dist/save-session-chain.js +0 -136
@@ -0,0 +1,169 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { dirname, join } from 'node:path';
3
+ import { sessionPath } from "./session-home.js";
4
+ export const RENEWAL_TIMEOUT_MS = 10_000;
5
+ const STALE_AFTER_MS = 3 * RENEWAL_TIMEOUT_MS;
6
+ export const WAIT_AT_MOST_MS = STALE_AFTER_MS + RENEWAL_TIMEOUT_MS;
7
+ const POLL_EVERY_MS = 50;
8
+ const SAY_WAITING_AFTER_MS = 2_000;
9
+ const NONCE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
10
+ export class SessionLockHeld extends Error {
11
+ path;
12
+ constructor(path) {
13
+ super('the session lock is held');
14
+ this.path = path;
15
+ }
16
+ }
17
+ const errorCode = (e) => e?.code;
18
+ const removeQuietly = (store, path) => {
19
+ try {
20
+ store.files.unlinkSync(path);
21
+ }
22
+ catch { }
23
+ };
24
+ const removeLock = (store, path) => {
25
+ try {
26
+ store.files.unlinkSync(path);
27
+ return true;
28
+ }
29
+ catch {
30
+ try {
31
+ store.files.rmdirSync(path);
32
+ return true;
33
+ }
34
+ catch (e) {
35
+ return errorCode(e) === 'ENOENT';
36
+ }
37
+ }
38
+ };
39
+ const parseHolder = (text) => {
40
+ try {
41
+ const held = JSON.parse(text);
42
+ const whole = typeof held?.nonce === 'string' &&
43
+ NONCE.test(held.nonce) &&
44
+ Number.isSafeInteger(held.pid) &&
45
+ Number(held.pid) > 0 &&
46
+ Number.isFinite(held.since);
47
+ return whole ? held : undefined;
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ };
53
+ const readHolder = (store, path) => {
54
+ try {
55
+ return parseHolder(store.files.readFileSync(path, 'utf8'));
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ };
61
+ const isUnreadable = (store, path) => {
62
+ try {
63
+ return parseHolder(store.files.readFileSync(path, 'utf8')) === undefined;
64
+ }
65
+ catch (e) {
66
+ return errorCode(e) !== 'ENOENT';
67
+ }
68
+ };
69
+ const holderNow = (store) => ({
70
+ pid: store.pid,
71
+ since: store.now(),
72
+ nonce: randomUUID()
73
+ });
74
+ const isStale = (store, held) => store.now() - held.since > STALE_AFTER_MS;
75
+ const isAbandoned = (store, held) => isStale(store, held) || held.since - store.now() > STALE_AFTER_MS || !store.alive(held.pid);
76
+ const createWhole = (store, path, holder) => {
77
+ const draft = `${path}.${holder.nonce}.new`;
78
+ try {
79
+ store.files.writeFileSync(draft, JSON.stringify(holder), { mode: 0o600, flag: 'wx' });
80
+ store.files.linkSync(draft, path);
81
+ return true;
82
+ }
83
+ catch (e) {
84
+ if (errorCode(e) === 'EEXIST')
85
+ return false;
86
+ throw e;
87
+ }
88
+ finally {
89
+ removeQuietly(store, draft);
90
+ }
91
+ };
92
+ const takeOver = (store, path, claim, still) => {
93
+ if (!createWhole(store, claim, holderNow(store))) {
94
+ const taker = readHolder(store, claim);
95
+ if (taker && isAbandoned(store, taker))
96
+ removeQuietly(store, claim);
97
+ return false;
98
+ }
99
+ try {
100
+ return still() && removeLock(store, path);
101
+ }
102
+ finally {
103
+ removeQuietly(store, claim);
104
+ }
105
+ };
106
+ const takeOverAbandoned = (store, path) => {
107
+ const held = readHolder(store, path);
108
+ if (!held) {
109
+ return (isUnreadable(store, path) &&
110
+ takeOver(store, path, `${path}.unreadable`, () => isUnreadable(store, path)));
111
+ }
112
+ return (isAbandoned(store, held) &&
113
+ takeOver(store, path, `${path}.${held.nonce}`, () => readHolder(store, path)?.nonce === held.nonce));
114
+ };
115
+ const acquire = async (store, path, sayWaiting) => {
116
+ const startedAt = store.now();
117
+ let said = false;
118
+ for (;;) {
119
+ const mine = holderNow(store);
120
+ if (createWhole(store, path, mine))
121
+ return mine;
122
+ if (takeOverAbandoned(store, path))
123
+ continue;
124
+ const waited = store.now() - startedAt;
125
+ if (waited >= WAIT_AT_MOST_MS)
126
+ throw new SessionLockHeld(path);
127
+ if (waited > SAY_WAITING_AFTER_MS && !said) {
128
+ said = true;
129
+ sayWaiting();
130
+ }
131
+ await store.wait(POLL_EVERY_MS);
132
+ }
133
+ };
134
+ const release = (store, path, mine) => {
135
+ if (readHolder(store, path)?.nonce === mine.nonce)
136
+ removeQuietly(store, path);
137
+ };
138
+ const lockPath = (store) => `${sessionPath(store)}.lock`;
139
+ export const withSessionLock = async (store, work, sayWaiting) => {
140
+ const path = lockPath(store);
141
+ const mine = await acquire(store, path, sayWaiting);
142
+ try {
143
+ return await work();
144
+ }
145
+ finally {
146
+ release(store, path, mine);
147
+ }
148
+ };
149
+ export const clearLeftovers = (store) => {
150
+ const session = sessionPath(store);
151
+ const lock = lockPath(store);
152
+ const dir = dirname(session);
153
+ let names;
154
+ try {
155
+ names = store.files.readdirSync(dir);
156
+ }
157
+ catch {
158
+ return;
159
+ }
160
+ for (const name of names) {
161
+ const path = join(dir, name);
162
+ const leftover = path === lock ||
163
+ path.startsWith(`${lock}.`) ||
164
+ (path.startsWith(`${session}.`) && path.endsWith('.new'));
165
+ if (leftover)
166
+ removeLock(store, path);
167
+ }
168
+ };
169
+ //# sourceMappingURL=session-lock.js.map
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { resolveSessionHome, sessionDir, sessionPath } from "./session-home.js";
4
+ import { clearLeftovers } from "./session-lock.js";
5
+ export const sessionIdentityWarning = (store, asFlag) => {
6
+ const home = resolveSessionHome(store);
7
+ if (!asFlag)
8
+ return undefined;
9
+ const session = loadSession(store);
10
+ if (!session)
11
+ return undefined;
12
+ if (!session.personId || !session.handle) {
13
+ return (`Warning: ${home.dir}/session.json has no recorded identity — ` +
14
+ `run \`lattris login\` to populate it.\n`);
15
+ }
16
+ const dirName = home.dir.split('/').filter(Boolean).at(-1) ?? '';
17
+ if (session.email &&
18
+ dirName.includes('@') &&
19
+ dirName.toLowerCase() !== session.email.toLowerCase()) {
20
+ return (`Warning: ${home.dir}/session.json records ${session.email}, but the directory is ` +
21
+ `keyed to ${dirName} — fix it by logging in as that identity again: \`lattris login\`.\n`);
22
+ }
23
+ return undefined;
24
+ };
25
+ export const notLoggedInWords = (machine) => {
26
+ const home = resolveSessionHome(machine);
27
+ if (home.bindingFile && !machine.files.existsSync(join(home.dir, 'session.json'))) {
28
+ return (`Bound to ${home.dir} (via ${home.bindingFile}) — no session there. ` +
29
+ `Run lattris login from this directory, or lattris use --clear.`);
30
+ }
31
+ return 'Not logged in.';
32
+ };
33
+ export const saveSession = (store, s) => {
34
+ const dir = sessionDir(store);
35
+ store.files.mkdirSync(dir, { recursive: true, mode: 0o700 });
36
+ store.files.chmodSync(dir, 0o700);
37
+ const file = sessionPath(store);
38
+ const draft = `${file}.${randomUUID()}.new`;
39
+ try {
40
+ store.files.writeFileSync(draft, JSON.stringify(s, null, 2), { mode: 0o600 });
41
+ store.files.chmodSync(draft, 0o600);
42
+ store.files.renameSync(draft, file);
43
+ }
44
+ catch (e) {
45
+ try {
46
+ store.files.unlinkSync(draft);
47
+ }
48
+ catch { }
49
+ throw e;
50
+ }
51
+ };
52
+ export const loadSession = (store) => {
53
+ try {
54
+ return JSON.parse(store.files.readFileSync(sessionPath(store), 'utf8'));
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ };
60
+ export const clearSession = (store) => {
61
+ try {
62
+ store.files.rmSync(sessionPath(store));
63
+ }
64
+ catch (e) {
65
+ if (e.code !== 'ENOENT')
66
+ throw e;
67
+ }
68
+ clearLeftovers(store);
69
+ };
70
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1,153 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { EXIT, refusal } from "./exit.js";
4
+ import { sessionDir } from "./session-home.js";
5
+ const SESSION_ID_MAX = 128;
6
+ const psField = (machine, pid, field) => {
7
+ const out = machine.run('ps', ['-o', `${field}=`, '-p', String(pid)]);
8
+ if (out.status !== 0)
9
+ return undefined;
10
+ const value = out.stdout.trim();
11
+ return value.length > 0 ? value : undefined;
12
+ };
13
+ const terminalKeyFrom = (input) => {
14
+ const tty = input.tty?.trim();
15
+ if (!tty || tty === '??' || tty === '-')
16
+ return undefined;
17
+ const device = tty.replace(/^\/dev\//, '');
18
+ const leader = input.leader();
19
+ if (!leader)
20
+ return undefined;
21
+ const component = (raw) => raw.replace(/[^A-Za-z0-9:]+/g, '-');
22
+ const shell = `leader-${leader.pid}-${component(leader.started)}`;
23
+ const name = `tty-${device.replace(/\//g, '_')}`;
24
+ for (const line of input.who.split('\n')) {
25
+ const columns = line.trim().split(/\s+/);
26
+ if (columns[1] === device) {
27
+ const moment = columns
28
+ .slice(2)
29
+ .filter((c) => !c.startsWith('('))
30
+ .join(' ');
31
+ if (moment.length > 0)
32
+ return `${name}-login-${component(moment)}-${shell}`;
33
+ }
34
+ }
35
+ return `${name}-${shell}`;
36
+ };
37
+ const terminalLeader = (machine, device) => {
38
+ let pid = machine.pid;
39
+ for (let hops = 0; hops < 64; hops += 1) {
40
+ const parent = Number(psField(machine, pid, 'ppid'));
41
+ if (!Number.isFinite(parent) || parent <= 1)
42
+ break;
43
+ const parentTty = psField(machine, parent, 'tty');
44
+ if (parentTty === undefined || parentTty.replace(/^\/dev\//, '') !== device)
45
+ break;
46
+ pid = parent;
47
+ }
48
+ const started = psField(machine, pid, 'lstart');
49
+ return started ? { pid, started } : undefined;
50
+ };
51
+ const terminalKey = (machine, atTerminal) => {
52
+ if (!atTerminal)
53
+ return undefined;
54
+ try {
55
+ const tty = psField(machine, machine.pid, 'tty');
56
+ const who = machine.run('who', []);
57
+ return terminalKeyFrom({
58
+ tty,
59
+ who: who.status === 0 ? who.stdout : '',
60
+ leader: () => (tty ? terminalLeader(machine, tty.replace(/^\/dev\//, '')) : undefined)
61
+ });
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ };
67
+ const mintTerminalIdentity = (machine, dir, key) => {
68
+ const { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } = machine.files;
69
+ const file = join(dir, key);
70
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
71
+ const read = () => {
72
+ try {
73
+ const existing = readFileSync(file, 'utf8').trim();
74
+ return existing.length > 0 ? existing : undefined;
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ };
80
+ const already = read();
81
+ if (already)
82
+ return already;
83
+ if (key.includes('/') || key.includes('\0'))
84
+ throw new Error(`a session key is one filename: ${key}`);
85
+ const minted = `tty-${randomUUID()}`;
86
+ const temp = join(dir, `.${key}.${machine.pid}.${randomBytes(4).toString('hex')}`);
87
+ writeFileSync(temp, `${minted}\n`, { mode: 0o600 });
88
+ try {
89
+ linkSync(temp, file);
90
+ return minted;
91
+ }
92
+ catch (e) {
93
+ if (e.code !== 'EEXIST')
94
+ throw e;
95
+ const theirs = read();
96
+ if (theirs)
97
+ return theirs;
98
+ throw new Error(`could not read the session identity at ${file}`);
99
+ }
100
+ finally {
101
+ try {
102
+ unlinkSync(temp);
103
+ }
104
+ catch { }
105
+ }
106
+ };
107
+ export const SESSION_ID_SHAPE = /^[\x21-\x7e]{1,128}$/;
108
+ export const AGENT_LABEL_SHAPE = /^[\x20-\x7e]{1,128}$/;
109
+ export const resolveWriteIdentityDetail = (store, atTerminal) => {
110
+ const rawLabel = store.env.LATTRIS_AGENT;
111
+ if (rawLabel !== undefined && rawLabel.length > 0 && !AGENT_LABEL_SHAPE.test(rawLabel)) {
112
+ return {
113
+ kind: 'invalid',
114
+ words: `LATTRIS_AGENT must be 1 to ${SESSION_ID_MAX} printable ASCII characters, the only ones a request header carries; ` +
115
+ `this one is ${rawLabel.length} characters. Nothing was written.`
116
+ };
117
+ }
118
+ const agentLabel = rawLabel && rawLabel.length > 0 ? rawLabel : undefined;
119
+ const fromEnv = store.env.LATTRIS_SESSION_ID;
120
+ if (fromEnv !== undefined && fromEnv.length > 0) {
121
+ if (!SESSION_ID_SHAPE.test(fromEnv)) {
122
+ return {
123
+ kind: 'invalid',
124
+ words: `LATTRIS_SESSION_ID must be 1 to ${SESSION_ID_MAX} printable characters with no spaces; ` +
125
+ `this one is ${fromEnv.length} characters. Nothing was written.`
126
+ };
127
+ }
128
+ return { kind: 'ok', identity: { sessionId: fromEnv, agentLabel } };
129
+ }
130
+ const key = terminalKey(store, atTerminal);
131
+ if (key === undefined)
132
+ return { kind: 'none' };
133
+ const sessionId = mintTerminalIdentity(store, join(sessionDir(store), 'sessions'), key);
134
+ return { kind: 'ok', identity: { sessionId, agentLabel } };
135
+ };
136
+ export const requireWriteIdentity = (store, atTerminal) => {
137
+ const r = resolveWriteIdentityDetail(store, atTerminal);
138
+ if (r.kind === 'ok')
139
+ return r.identity;
140
+ return refusal(`${r.kind === 'invalid' ? r.words : SESSION_ID_DOOR}\n`, EXIT.USAGE);
141
+ };
142
+ export const SESSION_ID_DOOR = 'This write changes a note’s history and has to name the session doing it. ' +
143
+ 'Off a terminal there is no session to infer: set LATTRIS_SESSION_ID to a stable ' +
144
+ 'name for this worker or run (1 to 128 characters), and LATTRIS_AGENT to a readable ' +
145
+ 'label if you want one. Nothing was written.';
146
+ export const noteWriteHeaders = (identity, idempotencyKey) => ({
147
+ 'X-Lattris-Session-Id': identity.sessionId,
148
+ ...(identity.agentLabel ? { 'X-Lattris-Agent': identity.agentLabel } : {}),
149
+ ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {})
150
+ });
151
+ const UUID_KEY_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
152
+ export const isUuidKey = (key) => UUID_KEY_SHAPE.test(key);
153
+ //# sourceMappingURL=write-identity.js.map
@@ -0,0 +1,16 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { readJsonObject } from "./store/json-file.js";
4
+ export const currentPackageVersion = (files) => {
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+ const pkg = readJsonObject(files, join(here, '..', 'package.json'));
7
+ return typeof pkg?.version === 'string' ? pkg.version : 'unknown';
8
+ };
9
+ export const printVersion = (term, files) => {
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+ const build = readJsonObject(files, join(here, 'build.json'));
12
+ const version = currentPackageVersion(files);
13
+ const commit = typeof build?.commit === 'string' ? build.commit.slice(0, 12) : undefined;
14
+ term.stdout.write(commit ? `lattris ${version} (${commit})\n` : `lattris ${version}\n`);
15
+ };
16
+ //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lattris",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "First-party CLI for Lattris — sign in, capture notes and sessions, and read your own tree from the terminal.",
6
6
  "keywords": [
@@ -23,9 +23,8 @@
23
23
  "node": ">=22"
24
24
  },
25
25
  "files": [
26
- "dist/main.js",
27
- "dist/extract-claude-jsonl.js",
28
- "dist/save-session-chain.js",
26
+ "dist/**/*.js",
27
+ "!dist/installed-check.js",
29
28
  "dist/build.json"
30
29
  ],
31
30
  "publishConfig": {
@@ -1,136 +0,0 @@
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