lattris 0.1.4 → 0.2.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.
Files changed (78) hide show
  1. package/README.md +2 -2
  2. package/dist/build.json +4 -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 +181 -0
  7. package/dist/dispatch-words.js +137 -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/capture-common.js +67 -0
  17. package/dist/memory/capture-help.js +186 -0
  18. package/dist/memory/capture-note.js +190 -0
  19. package/dist/memory/capture-session.js +166 -0
  20. package/dist/memory/delete.js +88 -0
  21. package/dist/memory/erase.js +67 -0
  22. package/dist/{extract-claude-jsonl.js → memory/extract-claude-jsonl.js} +5 -94
  23. package/dist/memory/get-help.js +63 -0
  24. package/dist/memory/get.js +213 -0
  25. package/dist/memory/history.js +134 -0
  26. package/dist/memory/recording-update.js +128 -0
  27. package/dist/memory/recording.js +249 -0
  28. package/dist/memory/resend.js +82 -0
  29. package/dist/memory/restore.js +73 -0
  30. package/dist/memory/save-session-chain.js +59 -0
  31. package/dist/memory/transcript.js +85 -0
  32. package/dist/memory/update-help.js +126 -0
  33. package/dist/memory/update.js +220 -0
  34. package/dist/memory/views.js +138 -0
  35. package/dist/nouns/consent.js +29 -0
  36. package/dist/nouns/contact.js +142 -0
  37. package/dist/nouns/datasets.js +109 -0
  38. package/dist/nouns/edges.js +106 -0
  39. package/dist/nouns/form.js +2 -0
  40. package/dist/nouns/index.js +34 -0
  41. package/dist/nouns/invite.js +98 -0
  42. package/dist/nouns/questions.js +116 -0
  43. package/dist/nouns/share.js +105 -0
  44. package/dist/nouns/words.js +76 -0
  45. package/dist/registry/datasets.js +15 -0
  46. package/dist/registry/entry.js +51 -0
  47. package/dist/registry/index.js +17 -0
  48. package/dist/registry/memory.js +110 -0
  49. package/dist/render/datasets.js +77 -0
  50. package/dist/render/door.js +39 -0
  51. package/dist/render/output.js +19 -0
  52. package/dist/render/prompt.js +4 -0
  53. package/dist/render/rows.js +49 -0
  54. package/dist/render/text.js +21 -0
  55. package/dist/request/access-token.js +67 -0
  56. package/dist/request/api-fetch.js +76 -0
  57. package/dist/request/callback.js +86 -0
  58. package/dist/request/door.js +74 -0
  59. package/dist/request/flags.js +38 -0
  60. package/dist/request/io.js +2 -0
  61. package/dist/request/paste-and-device.js +99 -0
  62. package/dist/request/stdin.js +22 -0
  63. package/dist/request/token.js +66 -0
  64. package/dist/request/upload.js +6 -0
  65. package/dist/request/version-check.js +79 -0
  66. package/dist/store/chains.js +25 -0
  67. package/dist/store/drafts.js +48 -0
  68. package/dist/store/environment.js +57 -0
  69. package/dist/store/exit.js +13 -0
  70. package/dist/store/io.js +2 -0
  71. package/dist/store/json-file.js +9 -0
  72. package/dist/store/session-home.js +189 -0
  73. package/dist/store/session-lock.js +169 -0
  74. package/dist/store/session.js +70 -0
  75. package/dist/store/write-identity.js +153 -0
  76. package/dist/version.js +16 -0
  77. package/package.json +4 -5
  78. package/dist/save-session-chain.js +0 -136
@@ -0,0 +1,67 @@
1
+ import { renderRunHint, sanitizeForTerminal } from "../render/text.js";
2
+ export const buildCreateNoteBody = (content, flags, allowBannedWords = false) => {
3
+ const body = { content };
4
+ if (flags.intent !== undefined)
5
+ body.intent = flags.intent;
6
+ if (flags.title !== undefined)
7
+ body.title = flags.title;
8
+ if (flags.capturedAt !== undefined)
9
+ body.capturedAt = flags.capturedAt;
10
+ if (flags.shareWith !== undefined)
11
+ body.audience = flags.shareWith;
12
+ if (flags.contributor !== undefined)
13
+ body.contributor = flags.contributor;
14
+ if (flags.aiSummary !== undefined)
15
+ body.aiSummary = flags.aiSummary;
16
+ if (allowBannedWords)
17
+ body.allowBannedWords = true;
18
+ return body;
19
+ };
20
+ export const formatSaved = (b, address) => {
21
+ const lines = [`Saved memory ${b.id} (type: ${b.type})`];
22
+ if (b.intent)
23
+ lines.push(` intent ${b.intent}`);
24
+ if (b.title)
25
+ lines.push(` title ${sanitizeForTerminal(b.title)}`);
26
+ if (b.aiSummary)
27
+ lines.push(` aiSummary ${sanitizeForTerminal(b.aiSummary, Infinity)}`);
28
+ lines.push(` capturedAt ${b.capturedAt}`);
29
+ if (b.fingerprint)
30
+ lines.push(` fingerprint ${b.fingerprint}`);
31
+ if (b.revisionNo)
32
+ lines.push(` revisionNo ${b.revisionNo}`);
33
+ if (address)
34
+ lines.push(` address ${address}`);
35
+ return lines.join('\n');
36
+ };
37
+ export const CAPTURE_MEMORY_FLAG_NAMES = {
38
+ '--idempotency-key': 'idempotencyKey',
39
+ '--type': 'type',
40
+ '--intent': 'intent',
41
+ '--title': 'title',
42
+ '--captured-at': 'capturedAt',
43
+ '--share-with': 'shareWith',
44
+ '--source': 'source',
45
+ '--conversation-id': 'conversationId',
46
+ '--extends': 'extends',
47
+ '--from': 'from',
48
+ '--continues-from': 'continuesFrom',
49
+ '--forked-from': 'forkedFrom',
50
+ '--ai-summary': 'aiSummary',
51
+ '--contributor': 'contributor',
52
+ '--linked-from': 'linkedFrom',
53
+ '--links-to': 'linksTo',
54
+ '--verb': 'verb',
55
+ '--reason': 'reason'
56
+ };
57
+ export const SESSION_ONLY_FLAGS = [
58
+ ['--source', 'source'],
59
+ ['--conversation-id', 'conversationId'],
60
+ ['--extends', 'extends'],
61
+ ['--from', 'from'],
62
+ ['--continues-from', 'continuesFrom'],
63
+ ['--forked-from', 'forkedFrom']
64
+ ];
65
+ export const retryWords = (path) => renderRunHint(['resend', path]);
66
+ export const SHARE_AUDIENCES = ['public'];
67
+ //# sourceMappingURL=capture-common.js.map
@@ -0,0 +1,186 @@
1
+ export const CAPTURE_INDEX = [
2
+ "capture — keep a record of what's real.",
3
+ '',
4
+ 'Types:',
5
+ ' note written words — --type defaults to it',
6
+ ' session a conversation with an AI, kept as an append-only ledger',
7
+ ' recording audio, its source, and its transcript',
8
+ ' rows rows of a dataset, newline-delimited JSON',
9
+ '',
10
+ 'Go one word deeper:',
11
+ ' lattris capture memory how to save a note (capture note accepted too)',
12
+ ' echo "…" | lattris capture memory save one',
13
+ ' lattris capture memory --type session how to save a conversation (capture session accepted too)',
14
+ ' lattris capture session <transcript.jsonl> save a Claude Code conversation, whole chain',
15
+ ' lattris capture recording how to save audio + transcript',
16
+ ' lattris capture rows how to save rows into a dataset',
17
+ '',
18
+ 'To read what you have kept: lattris get memories',
19
+ ''
20
+ ].join('\n');
21
+ export const CAPTURE_NOTE_HELP = `capture — keep a record of what's real.
22
+
23
+ Better information leads to better decisions. So aim for the record that's as close
24
+ to true as it can be right now — every word one they'd read back and recognise as
25
+ their own.
26
+
27
+ Nothing is saved until they've seen exactly what will be saved and said yes. That yes
28
+ is them deciding this is worth keeping, in these words.
29
+
30
+ The intent says how far they've taken it. Don't push for a higher intent — help them
31
+ get the record closer to personal truth, and the intent follows. Effort spent
32
+ producing a record is not distance travelled with it: a long summary nobody has read
33
+ back is still no intent.
34
+
35
+ No intent is fine, and common — it means held, not yet placed. A two-word note to
36
+ self and a careful research summary both sit here. It's kept, and it's found later.
37
+ It just doesn't speak for who they are, and that's what the intents are for.
38
+
39
+ If it came from somewhere else, say so in the words.
40
+
41
+ Offer a one-line title — what this is about. It's how they'll find it later.
42
+
43
+ Which intent? Take the first that fits.
44
+
45
+ already done it → action
46
+ choosing what will happen, and why → decision
47
+ their own words, and they stand behind it → truth
48
+ reviewed it and agree with it → learning
49
+ what they observed or thought at the time → noticed
50
+ none of these → no intent
51
+
52
+ Examples
53
+
54
+ action "Cancelled the gym membership."
55
+ decision "We're moving in March — the school run is killing us."
56
+ truth "I am the problem, therefore I am the solution."
57
+ learning "Sleep matters more than the extra hour of work."
58
+ noticed "Mum seemed quieter than usual today."
59
+ "I think I've been avoiding that call."
60
+ no intent "That thing about the school waiting list — look into it."
61
+ "Summary of the article on sleep — haven't decided what I make of it."
62
+
63
+ to save text arrives on stdin — no quoting; up to 5MB.
64
+
65
+ lattris capture which forms exist
66
+ lattris capture memory prints this (capture note accepted too)
67
+ echo "..." | lattris capture memory saves it, no intent
68
+
69
+ --type <name> optional; default note (session is the other form)
70
+ --intent <name> optional; default none
71
+ --title <text> optional; one line, what this is about
72
+ --captured-at <time> optional; when it happened, if not now
73
+ --share-with public optional; makes it public immediately — one
74
+ confirmation covers both facts. Omit to stay
75
+ private (the default). Requires --yes: there
76
+ is no prompt here, the content already
77
+ arrived on a pipe — pass it once the person
78
+ has confirmed both saving AND publishing.
79
+
80
+ --ai-summary <text> optional; an AI-written abstract beside the
81
+ title — a finding aid, never the record;
82
+ correctable forever (up to 2000 chars).
83
+
84
+ --allow-banned-words optional; keep flagged wording deliberately.
85
+ Without it, certain words in the text or
86
+ title refuse, and the refusal names every
87
+ word found — rephrase first, override only
88
+ when the wording is meant.
89
+
90
+ --linked-from <id> optional; the EXISTING memory points at the
91
+ new one (how the new one becomes reachable
92
+ from the tree). Full id or a >=4-char
93
+ prefix. Requires --verb and --reason.
94
+ --links-to <id> optional; the NEW memory points outward at
95
+ an existing one. FULL id only (the wire
96
+ addresses the edge by it). Requires --verb
97
+ and --reason. One direction per capture —
98
+ a second edge is its own \`lattris link\`.
99
+ --verb <verb> with a direction flag; the server's registry
100
+ owns the vocabulary — an unknown verb
101
+ refuses with the accepted set for the pair.
102
+ --reason <text> with a direction flag; required, one line —
103
+ written to the future walker: when is this
104
+ edge worth following. Never invented.
105
+
106
+ --contributor <uuid> optional; whose expression this is — a contact id.
107
+ The block is attributed to that person's voice.
108
+ Correctable later via PATCH (provenance, not authorship).
109
+
110
+ saved → prints the id and what was stored.
111
+
112
+ a conversation is a different form: lattris capture memory --type session`;
113
+ export const CAPTURE_SESSION_HELP = `capture session — keep a conversation as a ledger.
114
+
115
+ One context EPOCH is one block: the dialogue as it occurred — who spoke, what they
116
+ said, when — kept append-only; a turn, once written, never changes. Save at any
117
+ checkpoint, as often as wanted; only what's new lands. The server decides
118
+ create-vs-extend from the words themselves — send the thread, read the receipt.
119
+
120
+ When the context resets (a compaction, or a new-file restart), the conversation
121
+ CHAINS: save the next epoch as its OWN block with --continues-from naming the
122
+ previous epoch's block id (from your own prior receipt) — the compaction summary
123
+ rides as the new block's opening kind:"compaction-summary" marker turn, so every
124
+ block reads self-contained. Appending forever into one block stays available via
125
+ --extends. Blocks stay chapter-sized; the chain is the conversation.
126
+
127
+ From a Claude Code transcript, the WHOLE chain lands in one command:
128
+
129
+ lattris capture session <transcript.jsonl> [--title "…"] [--ai-summary "…"]
130
+
131
+ It extracts the conversation deterministically (the active path only — dead
132
+ rewind branches excluded, tool noise and thinking stripped; a compaction
133
+ boundary opens a new epoch) and materialises every epoch oldest-first: extends
134
+ where a prior save exists, creates-and-links where one doesn't, saves a fork
135
+ where the words diverged — server-verified, receipts cached, idempotent: run
136
+ it as often as wanted. --title and --ai-summary apply to the live epoch's
137
+ save; every other flag below belongs to the stdin form (the drive derives
138
+ those facts itself). Prints one receipt line per epoch. With a transcript
139
+ file named, stdin is ignored entirely — the file is the content — so the
140
+ command works the same under a pipe as at a terminal.
141
+
142
+ For any other source, turns arrive on stdin as a JSON array, in order:
143
+
144
+ [{"speaker":"user","text":"…","saidAt":"2026-08-20T10:00:00Z"}, …]
145
+
146
+ speaker and text are required (they ARE a turn's identity); saidAt, kind
147
+ ("compaction-summary" opens a post-reset epoch), externalMessageId and
148
+ externalParentMessageId are optional. Strip tool noise before saving — the record
149
+ is the conversation, not the machinery.
150
+
151
+ echo "$TURNS" | lattris capture memory --type session
152
+
153
+ --title <text> optional; one line — proposed at save, stays correctable
154
+ --ai-summary <text> optional; the AI-written abstract beside the title —
155
+ a finding aid, never the record; correctable forever
156
+ --intent <name> optional; default none
157
+ --captured-at <time> optional; when the conversation happened (backfill door)
158
+ --source <name> optional; where — claude-code, chatgpt, codex
159
+ --conversation-id <id> optional; the vendor's own conversation id. It only
160
+ NOMINATES prior saves for verification — the words decide
161
+ --extends <id> optional; a block id from a prior save — the receipt that
162
+ makes a partial thread lawful
163
+ --from <n> optional; the position the thread starts at (default 0 —
164
+ the whole thread; nonzero requires --extends)
165
+ --continues-from <id> optional; the PREVIOUS epoch's block id — the new block
166
+ and its continued_as link land together
167
+ --as-new save as a deliberate NEW block, bypassing conversation-id
168
+ matching — the fork capture after a divergence refusal
169
+ --forked-from <id> optional; with --as-new only: the origin this thread
170
+ rewound from. The server verifies the claim against the
171
+ words (a real shared prefix AND a real divergence)
172
+ before the forked_into link exists
173
+ --contributor <uuid> optional; whose expression — a contact id, attributed
174
+ to that person's voice. Applied to EVERY epoch in a
175
+ transcript chain (re-running heals older epochs too)
176
+ --share-with public optional; public from birth — requires --yes, same law
177
+ as a note
178
+ --yes confirms save AND publish together
179
+
180
+ A divergence refusal (the thread differs from the stored session) means a fork:
181
+ nothing was written; re-run with --as-new --forked-from <that block> once the
182
+ person confirms it is its own record.
183
+
184
+ saved → prints the receipt: the id, the outcome (created | extended | unchanged),
185
+ and the stored turn count — the position a later partial save resumes from.`;
186
+ //# sourceMappingURL=capture-help.js.map
@@ -0,0 +1,190 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { ambiguousText, formatEdgeReceipt } from "../render/rows.js";
3
+ import { parseArgs } from "../request/flags.js";
4
+ import { CAPTURE_MEMORY_FLAG_NAMES, SESSION_ONLY_FLAGS, SHARE_AUDIENCES, buildCreateNoteBody, formatSaved, retryWords } from "../memory/capture-common.js";
5
+ import { CAPTURE_NOTE_HELP, CAPTURE_SESSION_HELP } from "../memory/capture-help.js";
6
+ import { captureSession, captureSessionTranscript } from "../memory/capture-session.js";
7
+ import { composeAddressFor } from "../memory/views.js";
8
+ import { emit } from "../render/output.js";
9
+ import { renderRunHint, sanitizeForTerminal } from "../render/text.js";
10
+ import { refusalOf } from "../request/api-fetch.js";
11
+ import { saveRequestDraft } from "../store/drafts.js";
12
+ import { EXIT, isRefusal, refusal } from "../store/exit.js";
13
+ import { isUuidKey, noteWriteHeaders, requireWriteIdentity } from "../store/write-identity.js";
14
+ import { signedIn } from "../request/access-token.js";
15
+ const edgeBody = (verb, toBlockId, reason) => ({
16
+ verb,
17
+ toBlockId,
18
+ reason
19
+ });
20
+ export const asksForSessionForm = (args) => {
21
+ let last;
22
+ for (let i = 0; i < args.length; i++) {
23
+ if (args[i] === '--type') {
24
+ last = args[i + 1];
25
+ i++;
26
+ }
27
+ else if (args[i].startsWith('--type=')) {
28
+ last = args[i].slice('--type='.length);
29
+ }
30
+ }
31
+ return last === 'session';
32
+ };
33
+ export const captureMemory = async (cfg, io, args) => {
34
+ const yes = args.includes('--yes');
35
+ const asNew = args.includes('--as-new');
36
+ const allowBanned = args.includes('--allow-banned-words');
37
+ const { flags, positionals, unknown } = parseArgs(CAPTURE_MEMORY_FLAG_NAMES, args.filter((a) => a !== '--yes' && a !== '--as-new' && a !== '--allow-banned-words'));
38
+ const piped = !io.stdin.isTTY && positionals.length === 0;
39
+ const wantsNote = !asksForSessionForm(args) && (flags.type ?? 'note') === 'note';
40
+ const early = piped && wantsNote ? requireWriteIdentity(io.store, io.stdin.isTTY) : undefined;
41
+ if (early !== undefined && isRefusal(early))
42
+ return early;
43
+ const content = io.stdin.isTTY || positionals.length > 0 ? '' : await io.stdin.read();
44
+ if (content.length === 0 && positionals.length === 0) {
45
+ io.stdout.write(`${asksForSessionForm(args) ? CAPTURE_SESSION_HELP : CAPTURE_NOTE_HELP}\n`);
46
+ return;
47
+ }
48
+ if (unknown) {
49
+ return refusal(`Unknown flag: ${unknown}\n`);
50
+ }
51
+ if (flags.contributor !== undefined) {
52
+ const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
53
+ if (!UUID_SHAPE.test(flags.contributor)) {
54
+ return refusal(`--contributor must be a valid UUID (contact id): ${flags.contributor}\n`);
55
+ }
56
+ }
57
+ const type = flags.type ?? 'note';
58
+ if (type !== 'note' && type !== 'session') {
59
+ return refusal(`Unknown type: ${type}. Accepted: note, session.\n`);
60
+ }
61
+ if (type === 'session') {
62
+ if (allowBanned) {
63
+ return refusal(`--allow-banned-words applies to note captures only.\n`);
64
+ }
65
+ for (const [name, key] of [
66
+ ['--linked-from', 'linkedFrom'],
67
+ ['--links-to', 'linksTo'],
68
+ ['--verb', 'verb'],
69
+ ['--reason', 'reason']
70
+ ]) {
71
+ if (flags[key] !== undefined) {
72
+ return refusal(`${name} applies to note captures only.\n`);
73
+ }
74
+ }
75
+ if (positionals.length > 1) {
76
+ return refusal(`Unexpected argument: ${positionals[1]}\n`);
77
+ }
78
+ if (positionals.length === 1) {
79
+ return captureSessionTranscript(cfg, io, positionals[0], flags, asNew);
80
+ }
81
+ return captureSession(cfg, io, content, flags, { yes, asNew });
82
+ }
83
+ if (positionals.length > 0) {
84
+ return refusal(`Unexpected argument: ${positionals[0]}\n`);
85
+ }
86
+ for (const [name, key] of SESSION_ONLY_FLAGS) {
87
+ if (flags[key] !== undefined) {
88
+ return refusal(`${name} applies to --type session only.\n`);
89
+ }
90
+ }
91
+ if (asNew) {
92
+ return refusal(`--as-new applies to --type session only.\n`);
93
+ }
94
+ if (flags.linkedFrom !== undefined && flags.linksTo !== undefined) {
95
+ return refusal(`--linked-from and --links-to are two different edges — capture with one, then \`lattris link\` the other.\n`);
96
+ }
97
+ const direction = flags.linkedFrom !== undefined || flags.linksTo !== undefined;
98
+ if (direction) {
99
+ const missing = [
100
+ flags.verb === undefined ? '--verb' : undefined,
101
+ flags.reason === undefined ? '--reason' : undefined
102
+ ].filter((n) => n !== undefined);
103
+ if (missing.length > 0) {
104
+ return refusal(`${flags.linkedFrom !== undefined ? '--linked-from' : '--links-to'} requires ${missing.join(' and ')}.\n`);
105
+ }
106
+ }
107
+ else if (flags.verb !== undefined || flags.reason !== undefined) {
108
+ return refusal(`${flags.verb !== undefined ? '--verb' : '--reason'} rides a link — pass --linked-from or --links-to with it.\n`);
109
+ }
110
+ if (flags.linksTo !== undefined && !isUuidKey(flags.linksTo)) {
111
+ return refusal(`--links-to must be the FULL block id (the wire addresses the edge by it; it does not search): ${flags.linksTo}\n`);
112
+ }
113
+ if (flags.shareWith !== undefined) {
114
+ if (!SHARE_AUDIENCES.includes(flags.shareWith)) {
115
+ return refusal(`Unknown audience: ${flags.shareWith}. Accepted: ${SHARE_AUDIENCES.join(', ')}\n`);
116
+ }
117
+ if (!yes) {
118
+ return refusal('This will save the memory AND make it public — anyone, including people ' +
119
+ 'without accounts. There is no prompt here (the content already arrived on ' +
120
+ 'a pipe) — pass --yes once the person has confirmed both facts.\n');
121
+ }
122
+ }
123
+ const identity = early ?? requireWriteIdentity(io.store, io.stdin.isTTY);
124
+ if (isRefusal(identity))
125
+ return identity;
126
+ if (flags.idempotencyKey !== undefined && !isUuidKey(flags.idempotencyKey)) {
127
+ return refusal(`--idempotency-key must be the UUID a draft printed: ${flags.idempotencyKey}\n`);
128
+ }
129
+ const idempotencyKey = flags.idempotencyKey ?? randomUUID();
130
+ const accessToken = await signedIn(io, cfg);
131
+ if (typeof accessToken !== 'string')
132
+ return accessToken;
133
+ const createBody = buildCreateNoteBody(content, flags, allowBanned);
134
+ const requestDraft = {
135
+ door: 'POST /memories/notes',
136
+ idempotencyKey,
137
+ sessionId: identity.sessionId,
138
+ agentLabel: identity.agentLabel,
139
+ body: createBody
140
+ };
141
+ const keepDraft = () => saveRequestDraft(io.store, 'create', requestDraft);
142
+ let result;
143
+ try {
144
+ result = await io.api(accessToken, `${cfg.apiUrl}/memories/notes`, {
145
+ method: 'POST',
146
+ headers: {
147
+ 'Content-Type': 'application/json',
148
+ ...noteWriteHeaders(identity, idempotencyKey)
149
+ },
150
+ body: JSON.stringify(createBody)
151
+ });
152
+ }
153
+ catch (e) {
154
+ const path = keepDraft();
155
+ return refusal(`The response was lost (${sanitizeForTerminal(e instanceof Error ? e.message : String(e))}). ` +
156
+ `The request is kept at ${path}. Resend it exactly, same session and same key, with ${retryWords(path)} — ` +
157
+ 'if it landed, the door answers the same receipt and makes no second note.\n', EXIT.GENERIC);
158
+ }
159
+ if (!result.ok) {
160
+ if (result.exit === EXIT.USAGE || result.exit === EXIT.NOAUTH)
161
+ return refusalOf(result);
162
+ const path = keepDraft();
163
+ return refusalOf(result, `The request is kept at ${path}. Once the cause above is settled, resend it exactly with ${retryWords(path)}\n`);
164
+ }
165
+ const record = result.data;
166
+ const address = await composeAddressFor(cfg, io, accessToken, record);
167
+ const { href, ...fields } = record;
168
+ const receipt = {
169
+ ...fields,
170
+ ...(href === undefined ? {} : { href }),
171
+ ...(address === undefined ? {} : { address })
172
+ };
173
+ emit(io, receipt, JSON.stringify(receipt), () => formatSaved(record, address));
174
+ if (flags.linkedFrom !== undefined || flags.linksTo !== undefined) {
175
+ const fromRef = flags.linkedFrom ?? record.id;
176
+ const toId = flags.linkedFrom !== undefined ? record.id : flags.linksTo;
177
+ const edge = await io.api(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(fromRef)}/edges`, {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json' },
180
+ body: JSON.stringify(edgeBody(flags.verb, toId, flags.reason))
181
+ });
182
+ if (!edge.ok) {
183
+ return refusalOf(edge, ambiguousText(edge.body) +
184
+ `The memory was saved; only the link was not made. Finish it with:\n` +
185
+ ` ${renderRunHint(['link', fromRef, toId, '--verb', flags.verb, '--reason', flags.reason])}\n`);
186
+ }
187
+ io.stdout.write(`\n${formatEdgeReceipt('Linked.', edge.data)}\n`);
188
+ }
189
+ };
190
+ //# sourceMappingURL=capture-note.js.map
@@ -0,0 +1,166 @@
1
+ import { extractChain } from "./extract-claude-jsonl.js";
2
+ import { ChainSaveError, driveChain } from "./save-session-chain.js";
3
+ import { loadReceipts, saveReceipts } from "../store/chains.js";
4
+ import { SHARE_AUDIENCES } from "../memory/capture-common.js";
5
+ import { emit } from "../render/output.js";
6
+ import { sanitizeForTerminal } from "../render/text.js";
7
+ import { classifyApiError } from "../request/door.js";
8
+ import { EXIT, refusal } from "../store/exit.js";
9
+ import { refusalOf } from "../request/api-fetch.js";
10
+ import { signedIn } from "../request/access-token.js";
11
+ export const formatSavedSession = (r) => {
12
+ const lines = [
13
+ `Saved session ${r.block.id} (outcome: ${r.outcome}, turns stored: ${r.turnCount})`
14
+ ];
15
+ if (r.block.title)
16
+ lines.push(` title ${sanitizeForTerminal(r.block.title)}`);
17
+ lines.push(` capturedAt ${r.block.capturedAt}`);
18
+ return lines.join('\n');
19
+ };
20
+ export const buildCaptureSessionBody = (turns, flags, asNew) => {
21
+ const body = { turns };
22
+ if (flags.title !== undefined)
23
+ body.title = flags.title;
24
+ if (flags.intent !== undefined)
25
+ body.intent = flags.intent;
26
+ if (flags.capturedAt !== undefined)
27
+ body.capturedAt = flags.capturedAt;
28
+ if (flags.source !== undefined)
29
+ body.source = flags.source;
30
+ if (flags.conversationId !== undefined)
31
+ body.externalConversationId = flags.conversationId;
32
+ if (flags.extends !== undefined)
33
+ body.extends = flags.extends;
34
+ if (flags.from !== undefined)
35
+ body.from = Number(flags.from);
36
+ if (asNew)
37
+ body.asNewBlock = true;
38
+ if (flags.shareWith !== undefined)
39
+ body.audience = flags.shareWith;
40
+ if (flags.continuesFrom !== undefined)
41
+ body.continuesFrom = flags.continuesFrom;
42
+ if (flags.forkedFrom !== undefined)
43
+ body.forkedFrom = flags.forkedFrom;
44
+ if (flags.aiSummary !== undefined)
45
+ body.aiSummary = flags.aiSummary;
46
+ if (flags.contributor !== undefined)
47
+ body.contributor = flags.contributor;
48
+ return body;
49
+ };
50
+ export const captureSession = async (cfg, io, content, flags, opts) => {
51
+ let turns;
52
+ try {
53
+ turns = JSON.parse(content);
54
+ }
55
+ catch {
56
+ return refusal('stdin must be a JSON array of turns — [{"speaker":"user","text":"…"}, …]. ' +
57
+ 'Run `lattris capture memory --type session` for the form.\n');
58
+ }
59
+ if (!Array.isArray(turns) || turns.length === 0) {
60
+ return refusal('stdin must be a NON-EMPTY JSON array of turns — a session is at least one turn.\n');
61
+ }
62
+ if (flags.from !== undefined && !/^\d+$/.test(flags.from)) {
63
+ return refusal(`--from must be a non-negative integer, got: ${flags.from}\n`);
64
+ }
65
+ if (flags.shareWith !== undefined) {
66
+ if (!SHARE_AUDIENCES.includes(flags.shareWith)) {
67
+ return refusal(`Unknown audience: ${flags.shareWith}. Accepted: ${SHARE_AUDIENCES.join(', ')}\n`);
68
+ }
69
+ if (!opts.yes) {
70
+ return refusal('This will save the session AND make it public — anyone, including people ' +
71
+ 'without accounts. There is no prompt here (the turns already arrived on ' +
72
+ 'a pipe) — pass --yes once the person has confirmed both facts.\n');
73
+ }
74
+ }
75
+ const accessToken = await signedIn(io, cfg);
76
+ if (typeof accessToken !== 'string')
77
+ return accessToken;
78
+ const result = await io.api(accessToken, `${cfg.apiUrl}/memories/sessions`, {
79
+ method: 'POST',
80
+ headers: { 'Content-Type': 'application/json' },
81
+ body: JSON.stringify(buildCaptureSessionBody(turns, flags, opts.asNew))
82
+ });
83
+ if (!result.ok)
84
+ return refusalOf(result);
85
+ io.stdout.write(`${formatSavedSession(result.data)}\n`);
86
+ };
87
+ const TRANSCRIPT_INAPPLICABLE_FLAGS = [
88
+ ['--intent', 'intent'],
89
+ ['--captured-at', 'capturedAt'],
90
+ ['--share-with', 'shareWith'],
91
+ ['--source', 'source'],
92
+ ['--conversation-id', 'conversationId'],
93
+ ['--extends', 'extends'],
94
+ ['--from', 'from'],
95
+ ['--continues-from', 'continuesFrom'],
96
+ ['--forked-from', 'forkedFrom']
97
+ ];
98
+ const formatChainEpoch = (r) => `epoch ${r.epoch}: ${r.outcome}${r.forked ? ' (forked)' : ''} — block ${r.blockId}, ${r.turnCount} turns stored`;
99
+ export const captureSessionTranscript = async (cfg, io, file, flags, asNew) => {
100
+ for (const [name, key] of TRANSCRIPT_INAPPLICABLE_FLAGS) {
101
+ if (flags[key] !== undefined) {
102
+ return refusal(`${name} does not apply when capturing from a transcript — the chain drive ` +
103
+ `derives it. Only --title and --ai-summary ride this form.\n`);
104
+ }
105
+ }
106
+ if (asNew) {
107
+ return refusal('--as-new does not apply when capturing from a transcript — a fork is detected ' +
108
+ 'and saved by the drive itself.\n');
109
+ }
110
+ let jsonl;
111
+ try {
112
+ jsonl = io.store.files.readFileSync(file, 'utf8');
113
+ }
114
+ catch (e) {
115
+ const code = e.code ?? 'error';
116
+ return refusal(`Cannot read transcript ${file} (${code}).\n`);
117
+ }
118
+ const chain = extractChain(jsonl);
119
+ const shapeLine = chain.shape === 'subagent'
120
+ ? `Read a subagent transcript (agent ${chain.agentId ?? '?'}): ${chain.epochs.length} epoch(s).\n`
121
+ : `Read a main-session transcript: ${chain.epochs.length} epoch(s).\n`;
122
+ if (chain.shape === 'subagent' || io.stdout.isTTY)
123
+ io.stderr.write(shapeLine);
124
+ if (chain.epochs.length === 0) {
125
+ return refusal('No dialogue found on the active path — nothing to save.\n', EXIT.GENERIC);
126
+ }
127
+ const accessToken = await signedIn(io, cfg);
128
+ if (typeof accessToken !== 'string')
129
+ return accessToken;
130
+ const save = async (body) => {
131
+ const res = await io.api(accessToken, `${cfg.apiUrl}/memories/sessions`, {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify(body)
135
+ });
136
+ const text = res.rawText;
137
+ let parsed;
138
+ try {
139
+ const b = JSON.parse(text);
140
+ parsed = b && typeof b === 'object' ? b : {};
141
+ }
142
+ catch {
143
+ parsed = { message: text.slice(0, 200) };
144
+ }
145
+ return { status: res.status, body: parsed };
146
+ };
147
+ const receipts = loadReceipts(io.store, chain.conversationId);
148
+ let results;
149
+ try {
150
+ results = await driveChain(chain, receipts, { title: flags.title, aiSummary: flags.aiSummary, contributor: flags.contributor }, save);
151
+ }
152
+ catch (e) {
153
+ if (e instanceof ChainSaveError) {
154
+ const { message, next, exit } = classifyApiError(e.status, e.body);
155
+ return {
156
+ message: '',
157
+ exit,
158
+ answer: { kind: 'door', message: `epoch ${e.epoch}: ${message}`, next }
159
+ };
160
+ }
161
+ throw e;
162
+ }
163
+ saveReceipts(io.store, chain.conversationId, receipts);
164
+ emit(io, results, JSON.stringify(results), (rs) => rs.map(formatChainEpoch).join('\n'));
165
+ };
166
+ //# sourceMappingURL=capture-session.js.map