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,88 @@
1
+ import { HELP_TOKENS, parseArgs } from "../request/flags.js";
2
+ import { emit } from "../render/output.js";
3
+ import { ambiguousText, describeForContact, formatAccessRow, formatContact, resolveContact } from "../render/rows.js";
4
+ import { renderRunHint, sanitizeForTerminal } from "../render/text.js";
5
+ import { signedIn } from "../request/access-token.js";
6
+ import { refusalOf } from "../request/api-fetch.js";
7
+ import { EXIT, refusal } from "../store/exit.js";
8
+ export const GET_ACCESS_HELP = [
9
+ 'get access — may one contact, or one phone number, see a memory of yours,',
10
+ 'and because of which share.',
11
+ '',
12
+ 'Usage:',
13
+ ' lattris get access <memory-id-or-prefix> --for <contact | number>',
14
+ '',
15
+ ' --for an exact contact name or id from `lattris contact list`, or a',
16
+ ' phone number in full international form (+ then digits). A',
17
+ ' number no contact holds is a stranger, who sees public memories',
18
+ ' only.',
19
+ '',
20
+ 'Answers open or shut, and the rows that decided it: the denies that reach',
21
+ 'the contact when one does, the allows that reach them when open, or',
22
+ 'nothing when nothing is set.'
23
+ ].join('\n');
24
+ const ACCESS_FLAGS = { '--for': 'for' };
25
+ const isPhoneWord = (word) => word.startsWith('+');
26
+ const formatAccessDecision = (d, asked) => {
27
+ const who = d.forContact === null
28
+ ? sanitizeForTerminal(asked)
29
+ : sanitizeForTerminal(d.forContact.displayName);
30
+ const stranger = d.forContact === null ? ` (${describeForContact(null, asked)})` : '';
31
+ const verdict = d.open
32
+ ? `open — ${who} may see it${stranger}`
33
+ : `shut — ${who} may not see it${stranger}`;
34
+ const because = d.decidedBy.length === 0
35
+ ? d.forContact === null
36
+ ? 'because: no public share'
37
+ : 'because: nothing is set for them'
38
+ : `because: ${d.decidedBy.map(formatAccessRow).join('; ')}`;
39
+ return `${verdict}\n${because}`;
40
+ };
41
+ export const contactIdFor = async (cfg, io, accessToken, word) => {
42
+ const contacts = await io.api(accessToken, `${cfg.apiUrl}/contacts`);
43
+ if (!contacts.ok)
44
+ return refusalOf(contacts);
45
+ const shown = sanitizeForTerminal(word);
46
+ const resolution = resolveContact(contacts.data, word.trim());
47
+ if (resolution.kind === 'ambiguous') {
48
+ const list = resolution.matches.map((c) => ` ${formatContact(c)}`).join('\n');
49
+ return refusal(`${shown} matches more than one of your contacts:\n${list}\nUse a contact id instead.\n`);
50
+ }
51
+ if (resolution.kind !== 'found') {
52
+ return refusal(`No contact ${shown} among yours. ${renderRunHint(['contact', 'list'])} shows them.\n`, EXIT.GENERIC);
53
+ }
54
+ return { id: resolution.contact.id };
55
+ };
56
+ export const getAccess = async (cfg, io, rest) => {
57
+ if (rest.length === 0 || rest.some((t) => HELP_TOKENS.has(t))) {
58
+ io.stdout.write(`${GET_ACCESS_HELP}\n`);
59
+ return;
60
+ }
61
+ const { flags, positionals, unknown, notAnId } = parseArgs(ACCESS_FLAGS, rest, [0]);
62
+ if (unknown)
63
+ return refusal(`Unknown flag: ${unknown}\n`);
64
+ if (notAnId)
65
+ return refusal(notAnId);
66
+ if (positionals.length !== 1 || flags.for === undefined) {
67
+ return refusal(`Usage: lattris get access <memory-id-or-prefix> --for <contact | number>\n`);
68
+ }
69
+ const accessToken = await signedIn(io, cfg);
70
+ if (typeof accessToken !== 'string')
71
+ return accessToken;
72
+ const url = new URL(`${cfg.apiUrl}/memories/${encodeURIComponent(positionals[0])}/access`);
73
+ if (isPhoneWord(flags.for)) {
74
+ url.searchParams.set('forPhone', flags.for);
75
+ }
76
+ else {
77
+ const contact = await contactIdFor(cfg, io, accessToken, flags.for);
78
+ if ('exit' in contact)
79
+ return contact;
80
+ url.searchParams.set('forContact', contact.id);
81
+ }
82
+ const result = await io.api(accessToken, url);
83
+ if (!result.ok)
84
+ return refusalOf(result, ambiguousText(result.body));
85
+ const asked = flags.for;
86
+ emit(io, result.data, result.rawText, (d) => formatAccessDecision(d, asked));
87
+ };
88
+ //# sourceMappingURL=access.js.map
@@ -0,0 +1,171 @@
1
+ import { formatContact, groupsNotBuilt, isGroupWord, resolveContact, waitsForNameLines } from "../render/rows.js";
2
+ import { renderRunHint, sanitizeForTerminal } from "../render/text.js";
3
+ import { refusalOf } from "../request/api-fetch.js";
4
+ import { EXIT, refusal } from "../store/exit.js";
5
+ const PUBLIC_WORDS = 'public — anyone, including people without accounts';
6
+ export const takeShareFlags = (args) => {
7
+ const rest = [];
8
+ const shareWith = [];
9
+ const deny = [];
10
+ for (let i = 0; i < args.length; i++) {
11
+ const arg = args[i];
12
+ const eq = arg.indexOf('=');
13
+ const name = eq === -1 ? arg : arg.slice(0, eq);
14
+ if (name !== '--share-with' && name !== '--deny') {
15
+ rest.push(arg);
16
+ continue;
17
+ }
18
+ let value;
19
+ if (eq !== -1)
20
+ value = arg.slice(eq + 1);
21
+ else {
22
+ value = args[i + 1];
23
+ if (value === undefined || value.startsWith('--')) {
24
+ return { rest, shareWith, deny, unknown: name };
25
+ }
26
+ i++;
27
+ }
28
+ (name === '--share-with' ? shareWith : deny).push(value);
29
+ }
30
+ return { rest, shareWith, deny };
31
+ };
32
+ export const checkShareWords = (shareWith, deny) => {
33
+ const group = [...shareWith, ...deny].find(isGroupWord);
34
+ if (group !== undefined)
35
+ return refusal(groupsNotBuilt(group));
36
+ if (deny.includes('public')) {
37
+ return refusal('public cannot be denied: --deny names a contact or all-contacts. Leave --share-with public off to keep it private.\n');
38
+ }
39
+ const seen = new Set();
40
+ for (const word of [...shareWith, ...deny]) {
41
+ const key = word.trim().toLowerCase();
42
+ if (seen.has(key)) {
43
+ return refusal(`${sanitizeForTerminal(word)} is named twice — name each audience once, with one effect.\n`);
44
+ }
45
+ seen.add(key);
46
+ }
47
+ return undefined;
48
+ };
49
+ export const allowWords = (shareWith) => shareWith.map((w) => w === 'public'
50
+ ? PUBLIC_WORDS
51
+ : w === 'all-contacts'
52
+ ? 'all your contacts'
53
+ : sanitizeForTerminal(w));
54
+ const sharePhrase = (allows) => allows.length === 1 && allows[0] === PUBLIC_WORDS
55
+ ? `make it ${PUBLIC_WORDS}`
56
+ : `share it with ${allows.join(', ')}`;
57
+ export const needsYesWords = (noun, allows) => {
58
+ const arrived = noun === 'session' ? 'the turns' : 'the content';
59
+ return (`This will save the ${noun} AND ${sharePhrase(allows)}. There is no prompt here (${arrived} already ` +
60
+ 'arrived on a pipe) — pass --yes once the person has confirmed both facts.\n');
61
+ };
62
+ export const resolveBornShares = async (cfg, io, accessToken, shareWith, deny) => {
63
+ const shares = { access: [], allows: allowWords(shareWith) };
64
+ const words = [
65
+ ...shareWith.map((w) => [w, 'allow']),
66
+ ...deny.map((w) => [w, 'deny'])
67
+ ];
68
+ let contacts;
69
+ for (const [word, effect] of words) {
70
+ if (word === 'public') {
71
+ shares.audience = 'public';
72
+ continue;
73
+ }
74
+ if (word === 'all-contacts') {
75
+ shares.access.push({ kind: 'all_contacts', effect });
76
+ continue;
77
+ }
78
+ if (contacts === undefined) {
79
+ const read = await io.api(accessToken, `${cfg.apiUrl}/contacts`);
80
+ if (!read.ok)
81
+ return refusalOf(read);
82
+ contacts = read.data;
83
+ }
84
+ const shown = sanitizeForTerminal(word);
85
+ const resolution = resolveContact(contacts, word.trim());
86
+ if (resolution.kind === 'ambiguous') {
87
+ const list = resolution.matches.map((c) => ` ${formatContact(c)}`).join('\n');
88
+ return refusal(`${shown} matches more than one of your contacts:\n${list}\nUse a contact id instead.\n`);
89
+ }
90
+ if (resolution.kind !== 'found') {
91
+ return refusal(`No contact ${shown} among yours. ${renderRunHint(['contact', 'list'])} shows them.\n`, EXIT.GENERIC);
92
+ }
93
+ if (shares.access.some((a) => a.kind === 'contact' && a.contactId === resolution.contact.id)) {
94
+ return refusal(`${shown} is named twice — name each audience once, with one effect.\n`);
95
+ }
96
+ shares.access.push({ kind: 'contact', contactId: resolution.contact.id, effect });
97
+ }
98
+ return shares;
99
+ };
100
+ export const isRefusalValue = (value) => typeof value === 'object' && value !== null && 'exit' in value && 'message' in value;
101
+ export const withShares = (body, shares) => {
102
+ if (shares?.audience !== undefined)
103
+ body.audience = shares.audience;
104
+ if (shares !== undefined && shares.access.length > 0)
105
+ body.access = shares.access;
106
+ };
107
+ export const buildCreateNoteBody = (content, flags, allowBannedWords = false, shares) => {
108
+ const body = { content };
109
+ if (flags.intent !== undefined)
110
+ body.intent = flags.intent;
111
+ if (flags.title !== undefined)
112
+ body.title = flags.title;
113
+ if (flags.capturedAt !== undefined)
114
+ body.capturedAt = flags.capturedAt;
115
+ withShares(body, shares);
116
+ if (flags.contributor !== undefined)
117
+ body.contributor = flags.contributor;
118
+ if (flags.aiSummary !== undefined)
119
+ body.aiSummary = flags.aiSummary;
120
+ if (allowBannedWords)
121
+ body.allowBannedWords = true;
122
+ return body;
123
+ };
124
+ export const formatSaved = (b, address) => {
125
+ const lines = [`Saved memory ${b.id} (type: ${b.type})`];
126
+ if (b.intent)
127
+ lines.push(` intent ${b.intent}`);
128
+ if (b.title)
129
+ lines.push(` title ${sanitizeForTerminal(b.title)}`);
130
+ if (b.aiSummary)
131
+ lines.push(` aiSummary ${sanitizeForTerminal(b.aiSummary, Infinity)}`);
132
+ lines.push(` capturedAt ${b.capturedAt}`);
133
+ if (b.fingerprint)
134
+ lines.push(` fingerprint ${b.fingerprint}`);
135
+ if (b.revisionNo)
136
+ lines.push(` revisionNo ${b.revisionNo}`);
137
+ if (address)
138
+ lines.push(` address ${address}`);
139
+ for (const sentence of waitsForNameLines(b.access))
140
+ lines.push(` website ${sentence}`);
141
+ return lines.join('\n');
142
+ };
143
+ export const CAPTURE_MEMORY_FLAG_NAMES = {
144
+ '--idempotency-key': 'idempotencyKey',
145
+ '--type': 'type',
146
+ '--intent': 'intent',
147
+ '--title': 'title',
148
+ '--captured-at': 'capturedAt',
149
+ '--source': 'source',
150
+ '--conversation-id': 'conversationId',
151
+ '--extends': 'extends',
152
+ '--from': 'from',
153
+ '--continues-from': 'continuesFrom',
154
+ '--forked-from': 'forkedFrom',
155
+ '--ai-summary': 'aiSummary',
156
+ '--contributor': 'contributor',
157
+ '--linked-from': 'linkedFrom',
158
+ '--links-to': 'linksTo',
159
+ '--verb': 'verb',
160
+ '--reason': 'reason'
161
+ };
162
+ export const SESSION_ONLY_FLAGS = [
163
+ ['--source', 'source'],
164
+ ['--conversation-id', 'conversationId'],
165
+ ['--extends', 'extends'],
166
+ ['--from', 'from'],
167
+ ['--continues-from', 'continuesFrom'],
168
+ ['--forked-from', 'forkedFrom']
169
+ ];
170
+ export const retryWords = (path) => renderRunHint(['resend', path]);
171
+ //# sourceMappingURL=capture-common.js.map
@@ -0,0 +1,195 @@
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 <who> optional, repeatable; who may see it from birth:
74
+ public, all-contacts, or one of your contacts
75
+ by name or id; group:<name> is reserved for
76
+ groups, which are not built yet. Omit to stay private (the
77
+ default). Requires --yes: there is no prompt
78
+ here, the content already arrived on a pipe —
79
+ pass it once the person has confirmed both
80
+ saving AND sharing.
81
+ --deny <who> optional, repeatable; who may not, whatever else
82
+ allows it: all-contacts or one contact. Needs
83
+ no --yes. public cannot be denied, and a deny
84
+ beside --share-with public is refused.
85
+
86
+ --ai-summary <text> optional; an AI-written abstract beside the
87
+ title — a finding aid, never the record;
88
+ correctable forever (up to 2000 chars).
89
+
90
+ --allow-banned-words optional; keep flagged wording deliberately.
91
+ Without it, certain words in the text or
92
+ title refuse, and the refusal names every
93
+ word found — rephrase first, override only
94
+ when the wording is meant.
95
+
96
+ --linked-from <id> optional; the EXISTING memory points at the
97
+ new one (how the new one becomes reachable
98
+ from the tree). Full id or a >=4-char
99
+ prefix. Requires --verb and --reason.
100
+ --links-to <id> optional; the NEW memory points outward at
101
+ an existing one. FULL id only (the wire
102
+ addresses the edge by it). Requires --verb
103
+ and --reason. One direction per capture —
104
+ a second edge is its own \`lattris link\`.
105
+ --verb <verb> with a direction flag; the server's registry
106
+ owns the vocabulary — an unknown verb
107
+ refuses with the accepted set for the pair.
108
+ --reason <text> with a direction flag; required, one line —
109
+ written to the future walker: when is this
110
+ edge worth following. Never invented.
111
+
112
+ --contributor <uuid> optional; whose expression this is — a contact id.
113
+ The block is attributed to that person's voice.
114
+ Correctable later via PATCH (provenance, not authorship).
115
+
116
+ saved → prints the id and what was stored.
117
+
118
+ a conversation is a different form: lattris capture memory --type session`;
119
+ export const CAPTURE_SESSION_HELP = `capture session — keep a conversation as a ledger.
120
+
121
+ One context EPOCH is one block: the dialogue as it occurred — who spoke, what they
122
+ said, when — kept append-only; a turn, once written, never changes. Save at any
123
+ checkpoint, as often as wanted; only what's new lands. The server decides
124
+ create-vs-extend from the words themselves — send the thread, read the receipt.
125
+
126
+ When the context resets (a compaction, or a new-file restart), the conversation
127
+ CHAINS: save the next epoch as its OWN block with --continues-from naming the
128
+ previous epoch's block id (from your own prior receipt) — the compaction summary
129
+ rides as the new block's opening kind:"compaction-summary" marker turn, so every
130
+ block reads self-contained. Appending forever into one block stays available via
131
+ --extends. Blocks stay chapter-sized; the chain is the conversation.
132
+
133
+ From a Claude Code transcript, the WHOLE chain lands in one command:
134
+
135
+ lattris capture session <transcript.jsonl> [--title "…"] [--ai-summary "…"]
136
+
137
+ It extracts the conversation deterministically (the active path only — dead
138
+ rewind branches excluded, tool noise and thinking stripped; a compaction
139
+ boundary opens a new epoch) and materialises every epoch oldest-first: extends
140
+ where a prior save exists, creates-and-links where one doesn't, saves a fork
141
+ where the words diverged — server-verified, receipts cached, idempotent: run
142
+ it as often as wanted. --title and --ai-summary apply to the live epoch's
143
+ save; every other flag below belongs to the stdin form (the drive derives
144
+ those facts itself). Prints one receipt line per epoch. With a transcript
145
+ file named, stdin is ignored entirely — the file is the content — so the
146
+ command works the same under a pipe as at a terminal.
147
+
148
+ For any other source, turns arrive on stdin as a JSON array, in order:
149
+
150
+ [{"speaker":"user","text":"…","saidAt":"2026-08-20T10:00:00Z"}, …]
151
+
152
+ speaker and text are required (they ARE a turn's identity); saidAt, kind
153
+ ("compaction-summary" opens a post-reset epoch), externalMessageId and
154
+ externalParentMessageId are optional. Strip tool noise before saving — the record
155
+ is the conversation, not the machinery.
156
+
157
+ echo "$TURNS" | lattris capture memory --type session
158
+
159
+ --title <text> optional; one line — proposed at save, stays correctable
160
+ --ai-summary <text> optional; the AI-written abstract beside the title —
161
+ a finding aid, never the record; correctable forever
162
+ --intent <name> optional; default none
163
+ --captured-at <time> optional; when the conversation happened (backfill door)
164
+ --source <name> optional; where — claude-code, chatgpt, codex
165
+ --conversation-id <id> optional; the vendor's own conversation id. It only
166
+ NOMINATES prior saves for verification — the words decide
167
+ --extends <id> optional; a block id from a prior save — the receipt that
168
+ makes a partial thread lawful
169
+ --from <n> optional; the position the thread starts at (default 0 —
170
+ the whole thread; nonzero requires --extends)
171
+ --continues-from <id> optional; the PREVIOUS epoch's block id — the new block
172
+ and its continued_as link land together
173
+ --as-new save as a deliberate NEW block, bypassing conversation-id
174
+ matching — the fork capture after a divergence refusal
175
+ --forked-from <id> optional; with --as-new only: the origin this thread
176
+ rewound from. The server verifies the claim against the
177
+ words (a real shared prefix AND a real divergence)
178
+ before the forked_into link exists
179
+ --contributor <uuid> optional; whose expression — a contact id, attributed
180
+ to that person's voice. Applied to EVERY epoch in a
181
+ transcript chain (re-running heals older epochs too)
182
+ --share-with <who> optional, repeatable; public, all-contacts or a contact,
183
+ from birth — requires --yes, same law as a note;
184
+ group:<name> is reserved for groups, not built yet
185
+ --deny <who> optional, repeatable; all-contacts or a contact shut out
186
+ from birth, whatever else allows it
187
+ --yes confirms save AND share together
188
+
189
+ A divergence refusal (the thread differs from the stored session) means a fork:
190
+ nothing was written; re-run with --as-new --forked-from <that block> once the
191
+ person confirms it is its own record.
192
+
193
+ saved → prints the receipt: the id, the outcome (created | extended | unchanged),
194
+ and the stored turn count — the position a later partial save resumes from.`;
195
+ //# sourceMappingURL=capture-help.js.map
@@ -0,0 +1,200 @@
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, allowWords, buildCreateNoteBody, checkShareWords, formatSaved, isRefusalValue, needsYesWords, resolveBornShares, retryWords, takeShareFlags } 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 taken = takeShareFlags(args.filter((a) => a !== '--yes' && a !== '--as-new' && a !== '--allow-banned-words'));
38
+ const { flags, positionals, unknown } = parseArgs(CAPTURE_MEMORY_FLAG_NAMES, taken.rest);
39
+ const sharesNamed = taken.shareWith.length > 0 || taken.deny.length > 0;
40
+ const piped = !io.stdin.isTTY && positionals.length === 0;
41
+ const wantsNote = !asksForSessionForm(args) && (flags.type ?? 'note') === 'note';
42
+ const early = piped && wantsNote ? requireWriteIdentity(io.store, io.stdin.isTTY) : undefined;
43
+ if (early !== undefined && isRefusal(early))
44
+ return early;
45
+ const content = io.stdin.isTTY || positionals.length > 0 ? '' : await io.stdin.read();
46
+ if (content.length === 0 && positionals.length === 0) {
47
+ io.stdout.write(`${asksForSessionForm(args) ? CAPTURE_SESSION_HELP : CAPTURE_NOTE_HELP}\n`);
48
+ return;
49
+ }
50
+ if (unknown ?? taken.unknown) {
51
+ return refusal(`Unknown flag: ${unknown ?? taken.unknown}\n`);
52
+ }
53
+ const badShareWords = checkShareWords(taken.shareWith, taken.deny);
54
+ if (badShareWords)
55
+ return badShareWords;
56
+ if (flags.contributor !== undefined) {
57
+ const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
58
+ if (!UUID_SHAPE.test(flags.contributor)) {
59
+ return refusal(`--contributor must be a valid UUID (contact id): ${flags.contributor}\n`);
60
+ }
61
+ }
62
+ const type = flags.type ?? 'note';
63
+ if (type !== 'note' && type !== 'session') {
64
+ return refusal(`Unknown type: ${type}. Accepted: note, session.\n`);
65
+ }
66
+ if (type === 'session') {
67
+ if (allowBanned) {
68
+ return refusal(`--allow-banned-words applies to note captures only.\n`);
69
+ }
70
+ for (const [name, key] of [
71
+ ['--linked-from', 'linkedFrom'],
72
+ ['--links-to', 'linksTo'],
73
+ ['--verb', 'verb'],
74
+ ['--reason', 'reason']
75
+ ]) {
76
+ if (flags[key] !== undefined) {
77
+ return refusal(`${name} applies to note captures only.\n`);
78
+ }
79
+ }
80
+ if (positionals.length > 1) {
81
+ return refusal(`Unexpected argument: ${positionals[1]}\n`);
82
+ }
83
+ if (positionals.length === 1) {
84
+ return captureSessionTranscript(cfg, io, positionals[0], flags, asNew, sharesNamed);
85
+ }
86
+ return captureSession(cfg, io, content, flags, {
87
+ yes,
88
+ asNew,
89
+ shareWith: taken.shareWith,
90
+ deny: taken.deny
91
+ });
92
+ }
93
+ if (positionals.length > 0) {
94
+ return refusal(`Unexpected argument: ${positionals[0]}\n`);
95
+ }
96
+ for (const [name, key] of SESSION_ONLY_FLAGS) {
97
+ if (flags[key] !== undefined) {
98
+ return refusal(`${name} applies to --type session only.\n`);
99
+ }
100
+ }
101
+ if (asNew) {
102
+ return refusal(`--as-new applies to --type session only.\n`);
103
+ }
104
+ if (flags.linkedFrom !== undefined && flags.linksTo !== undefined) {
105
+ return refusal(`--linked-from and --links-to are two different edges — capture with one, then \`lattris link\` the other.\n`);
106
+ }
107
+ const direction = flags.linkedFrom !== undefined || flags.linksTo !== undefined;
108
+ if (direction) {
109
+ const missing = [
110
+ flags.verb === undefined ? '--verb' : undefined,
111
+ flags.reason === undefined ? '--reason' : undefined
112
+ ].filter((n) => n !== undefined);
113
+ if (missing.length > 0) {
114
+ return refusal(`${flags.linkedFrom !== undefined ? '--linked-from' : '--links-to'} requires ${missing.join(' and ')}.\n`);
115
+ }
116
+ }
117
+ else if (flags.verb !== undefined || flags.reason !== undefined) {
118
+ return refusal(`${flags.verb !== undefined ? '--verb' : '--reason'} rides a link — pass --linked-from or --links-to with it.\n`);
119
+ }
120
+ if (flags.linksTo !== undefined && !isUuidKey(flags.linksTo)) {
121
+ return refusal(`--links-to must be the FULL block id (the wire addresses the edge by it; it does not search): ${flags.linksTo}\n`);
122
+ }
123
+ if (taken.shareWith.length > 0 && !yes) {
124
+ return refusal(needsYesWords('memory', allowWords(taken.shareWith)));
125
+ }
126
+ const identity = early ?? requireWriteIdentity(io.store, io.stdin.isTTY);
127
+ if (isRefusal(identity))
128
+ return identity;
129
+ if (flags.idempotencyKey !== undefined && !isUuidKey(flags.idempotencyKey)) {
130
+ return refusal(`--idempotency-key must be the UUID a draft printed: ${flags.idempotencyKey}\n`);
131
+ }
132
+ const idempotencyKey = flags.idempotencyKey ?? randomUUID();
133
+ const accessToken = await signedIn(io, cfg);
134
+ if (typeof accessToken !== 'string')
135
+ return accessToken;
136
+ let shares;
137
+ if (sharesNamed) {
138
+ const resolved = await resolveBornShares(cfg, io, accessToken, taken.shareWith, taken.deny);
139
+ if (isRefusalValue(resolved))
140
+ return resolved;
141
+ shares = resolved;
142
+ }
143
+ const createBody = buildCreateNoteBody(content, flags, allowBanned, shares);
144
+ const requestDraft = {
145
+ door: 'POST /memories/notes',
146
+ idempotencyKey,
147
+ sessionId: identity.sessionId,
148
+ agentLabel: identity.agentLabel,
149
+ body: createBody
150
+ };
151
+ const keepDraft = () => saveRequestDraft(io.store, 'create', requestDraft);
152
+ let result;
153
+ try {
154
+ result = await io.api(accessToken, `${cfg.apiUrl}/memories/notes`, {
155
+ method: 'POST',
156
+ headers: {
157
+ 'Content-Type': 'application/json',
158
+ ...noteWriteHeaders(identity, idempotencyKey)
159
+ },
160
+ body: JSON.stringify(createBody)
161
+ });
162
+ }
163
+ catch (e) {
164
+ const path = keepDraft();
165
+ return refusal(`The response was lost (${sanitizeForTerminal(e instanceof Error ? e.message : String(e))}). ` +
166
+ `The request is kept at ${path}. Resend it exactly, same session and same key, with ${retryWords(path)} — ` +
167
+ 'if it landed, the door answers the same receipt and makes no second note.\n', EXIT.GENERIC);
168
+ }
169
+ if (!result.ok) {
170
+ if (result.exit === EXIT.USAGE || result.exit === EXIT.NOAUTH)
171
+ return refusalOf(result);
172
+ const path = keepDraft();
173
+ return refusalOf(result, `The request is kept at ${path}. Once the cause above is settled, resend it exactly with ${retryWords(path)}\n`);
174
+ }
175
+ const record = result.data;
176
+ const address = await composeAddressFor(cfg, io, accessToken, record);
177
+ const { href, ...fields } = record;
178
+ const receipt = {
179
+ ...fields,
180
+ ...(href === undefined ? {} : { href }),
181
+ ...(address === undefined ? {} : { address })
182
+ };
183
+ emit(io, receipt, JSON.stringify(receipt), () => formatSaved(record, address));
184
+ if (flags.linkedFrom !== undefined || flags.linksTo !== undefined) {
185
+ const fromRef = flags.linkedFrom ?? record.id;
186
+ const toId = flags.linkedFrom !== undefined ? record.id : flags.linksTo;
187
+ const edge = await io.api(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(fromRef)}/edges`, {
188
+ method: 'POST',
189
+ headers: { 'Content-Type': 'application/json' },
190
+ body: JSON.stringify(edgeBody(flags.verb, toId, flags.reason))
191
+ });
192
+ if (!edge.ok) {
193
+ return refusalOf(edge, ambiguousText(edge.body) +
194
+ `The memory was saved; only the link was not made. Finish it with:\n` +
195
+ ` ${renderRunHint(['link', fromRef, toId, '--verb', flags.verb, '--reason', flags.reason])}\n`);
196
+ }
197
+ io.stdout.write(`\n${formatEdgeReceipt('Linked.', edge.data)}\n`);
198
+ }
199
+ };
200
+ //# sourceMappingURL=capture-note.js.map