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.
- package/README.md +2 -2
- package/dist/build.json +1 -1
- package/dist/datasets/capture.js +118 -0
- package/dist/datasets/page.js +64 -0
- package/dist/datasets/pieces.js +78 -0
- package/dist/dispatch-form.js +202 -0
- package/dist/dispatch-words.js +133 -0
- package/dist/dispatch.js +67 -0
- package/dist/entry/session.js +219 -0
- package/dist/entry/set.js +130 -0
- package/dist/entry/setup-boot.js +151 -0
- package/dist/entry/setup.js +145 -0
- package/dist/entry/use.js +127 -0
- package/dist/help.js +65 -0
- package/dist/main.js +143 -6887
- package/dist/memory/access.js +88 -0
- package/dist/memory/capture-common.js +171 -0
- package/dist/memory/capture-help.js +195 -0
- package/dist/memory/capture-note.js +200 -0
- package/dist/memory/capture-session.js +168 -0
- package/dist/memory/delete.js +88 -0
- package/dist/memory/erase.js +67 -0
- package/dist/{extract-claude-jsonl.js → memory/extract-claude-jsonl.js} +5 -94
- package/dist/memory/get-help.js +72 -0
- package/dist/memory/get.js +246 -0
- package/dist/memory/history.js +134 -0
- package/dist/memory/recording-update.js +128 -0
- package/dist/memory/recording.js +258 -0
- package/dist/memory/resend.js +82 -0
- package/dist/memory/restore.js +73 -0
- package/dist/memory/save-session-chain.js +59 -0
- package/dist/memory/transcript.js +88 -0
- package/dist/memory/update-help.js +126 -0
- package/dist/memory/update.js +220 -0
- package/dist/memory/views.js +146 -0
- package/dist/nouns/consent.js +29 -0
- package/dist/nouns/contact.js +203 -0
- package/dist/nouns/datasets.js +109 -0
- package/dist/nouns/edges.js +106 -0
- package/dist/nouns/form.js +2 -0
- package/dist/nouns/index.js +48 -0
- package/dist/nouns/invite.js +98 -0
- package/dist/nouns/questions.js +116 -0
- package/dist/nouns/share.js +266 -0
- package/dist/nouns/words.js +81 -0
- package/dist/registry/datasets.js +15 -0
- package/dist/registry/entry.js +51 -0
- package/dist/registry/index.js +17 -0
- package/dist/registry/memory.js +116 -0
- package/dist/render/datasets.js +77 -0
- package/dist/render/door.js +39 -0
- package/dist/render/output.js +19 -0
- package/dist/render/prompt.js +4 -0
- package/dist/render/rows.js +82 -0
- package/dist/render/text.js +21 -0
- package/dist/request/access-token.js +67 -0
- package/dist/request/api-fetch.js +76 -0
- package/dist/request/callback.js +86 -0
- package/dist/request/door.js +74 -0
- package/dist/request/flags.js +38 -0
- package/dist/request/io.js +2 -0
- package/dist/request/paste-and-device.js +99 -0
- package/dist/request/stdin.js +22 -0
- package/dist/request/token.js +66 -0
- package/dist/request/upload.js +6 -0
- package/dist/request/version-check.js +79 -0
- package/dist/store/chains.js +25 -0
- package/dist/store/drafts.js +48 -0
- package/dist/store/environment.js +57 -0
- package/dist/store/exit.js +13 -0
- package/dist/store/io.js +2 -0
- package/dist/store/json-file.js +9 -0
- package/dist/store/session-home.js +189 -0
- package/dist/store/session-lock.js +169 -0
- package/dist/store/session.js +70 -0
- package/dist/store/write-identity.js +153 -0
- package/dist/version.js +16 -0
- package/package.json +3 -4
- 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
|