lattris 0.1.3 → 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.
- package/README.md +2 -2
- package/dist/build.json +4 -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 +181 -0
- package/dist/dispatch-words.js +137 -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 -6877
- package/dist/memory/capture-common.js +67 -0
- package/dist/memory/capture-help.js +186 -0
- package/dist/memory/capture-note.js +190 -0
- package/dist/memory/capture-session.js +166 -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 +63 -0
- package/dist/memory/get.js +213 -0
- package/dist/memory/history.js +134 -0
- package/dist/memory/recording-update.js +128 -0
- package/dist/memory/recording.js +249 -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 +85 -0
- package/dist/memory/update-help.js +126 -0
- package/dist/memory/update.js +220 -0
- package/dist/memory/views.js +138 -0
- package/dist/nouns/consent.js +29 -0
- package/dist/nouns/contact.js +142 -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 +34 -0
- package/dist/nouns/invite.js +98 -0
- package/dist/nouns/questions.js +116 -0
- package/dist/nouns/share.js +105 -0
- package/dist/nouns/words.js +76 -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 +110 -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 +49 -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 +4 -5
- 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
|