lattris 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +2 -2
  2. package/dist/build.json +4 -1
  3. package/dist/datasets/capture.js +118 -0
  4. package/dist/datasets/page.js +64 -0
  5. package/dist/datasets/pieces.js +78 -0
  6. package/dist/dispatch-form.js +181 -0
  7. package/dist/dispatch-words.js +137 -0
  8. package/dist/dispatch.js +67 -0
  9. package/dist/entry/session.js +219 -0
  10. package/dist/entry/set.js +130 -0
  11. package/dist/entry/setup-boot.js +151 -0
  12. package/dist/entry/setup.js +145 -0
  13. package/dist/entry/use.js +127 -0
  14. package/dist/help.js +65 -0
  15. package/dist/main.js +143 -6887
  16. package/dist/memory/capture-common.js +67 -0
  17. package/dist/memory/capture-help.js +186 -0
  18. package/dist/memory/capture-note.js +190 -0
  19. package/dist/memory/capture-session.js +166 -0
  20. package/dist/memory/delete.js +88 -0
  21. package/dist/memory/erase.js +67 -0
  22. package/dist/{extract-claude-jsonl.js → memory/extract-claude-jsonl.js} +5 -94
  23. package/dist/memory/get-help.js +63 -0
  24. package/dist/memory/get.js +213 -0
  25. package/dist/memory/history.js +134 -0
  26. package/dist/memory/recording-update.js +128 -0
  27. package/dist/memory/recording.js +249 -0
  28. package/dist/memory/resend.js +82 -0
  29. package/dist/memory/restore.js +73 -0
  30. package/dist/memory/save-session-chain.js +59 -0
  31. package/dist/memory/transcript.js +85 -0
  32. package/dist/memory/update-help.js +126 -0
  33. package/dist/memory/update.js +220 -0
  34. package/dist/memory/views.js +138 -0
  35. package/dist/nouns/consent.js +29 -0
  36. package/dist/nouns/contact.js +142 -0
  37. package/dist/nouns/datasets.js +109 -0
  38. package/dist/nouns/edges.js +106 -0
  39. package/dist/nouns/form.js +2 -0
  40. package/dist/nouns/index.js +34 -0
  41. package/dist/nouns/invite.js +98 -0
  42. package/dist/nouns/questions.js +116 -0
  43. package/dist/nouns/share.js +105 -0
  44. package/dist/nouns/words.js +76 -0
  45. package/dist/registry/datasets.js +15 -0
  46. package/dist/registry/entry.js +51 -0
  47. package/dist/registry/index.js +17 -0
  48. package/dist/registry/memory.js +110 -0
  49. package/dist/render/datasets.js +77 -0
  50. package/dist/render/door.js +39 -0
  51. package/dist/render/output.js +19 -0
  52. package/dist/render/prompt.js +4 -0
  53. package/dist/render/rows.js +49 -0
  54. package/dist/render/text.js +21 -0
  55. package/dist/request/access-token.js +67 -0
  56. package/dist/request/api-fetch.js +76 -0
  57. package/dist/request/callback.js +86 -0
  58. package/dist/request/door.js +74 -0
  59. package/dist/request/flags.js +38 -0
  60. package/dist/request/io.js +2 -0
  61. package/dist/request/paste-and-device.js +99 -0
  62. package/dist/request/stdin.js +22 -0
  63. package/dist/request/token.js +66 -0
  64. package/dist/request/upload.js +6 -0
  65. package/dist/request/version-check.js +79 -0
  66. package/dist/store/chains.js +25 -0
  67. package/dist/store/drafts.js +48 -0
  68. package/dist/store/environment.js +57 -0
  69. package/dist/store/exit.js +13 -0
  70. package/dist/store/io.js +2 -0
  71. package/dist/store/json-file.js +9 -0
  72. package/dist/store/session-home.js +189 -0
  73. package/dist/store/session-lock.js +169 -0
  74. package/dist/store/session.js +70 -0
  75. package/dist/store/write-identity.js +153 -0
  76. package/dist/version.js +16 -0
  77. package/package.json +4 -5
  78. package/dist/save-session-chain.js +0 -136
@@ -0,0 +1,134 @@
1
+ import { parseArgs } from "../request/flags.js";
2
+ import { formatDeletedPage } from "../memory/views.js";
3
+ import { emit } from "../render/output.js";
4
+ import { renderRunHint, sanitizeForTerminal } from "../render/text.js";
5
+ import { refusal } from "../store/exit.js";
6
+ import { refusalOf } from "../request/api-fetch.js";
7
+ import { signedIn } from "../request/access-token.js";
8
+ import { ambiguousText } from "../render/rows.js";
9
+ export const formatHistoryItem = (i) => {
10
+ const who = i.actor.agentLabel
11
+ ? `${sanitizeForTerminal(i.actor.sessionId)} (${sanitizeForTerminal(i.actor.agentLabel)})`
12
+ : sanitizeForTerminal(i.actor.sessionId);
13
+ const rev = i.revisionNo === null ? '-' : `rev ${i.revisionNo}`;
14
+ const extra = [
15
+ i.restoredFromRevisionNo !== null ? `from rev ${i.restoredFromRevisionNo}` : undefined,
16
+ i.overwriteReason !== null ? `reason: ${sanitizeForTerminal(i.overwriteReason)}` : undefined,
17
+ i.baseline ? 'baseline' : undefined
18
+ ]
19
+ .filter((x) => x !== undefined)
20
+ .join('; ');
21
+ return `${i.recordedAt} ${i.action.padEnd(12)} ${rev.padEnd(9)} ${who}${extra ? ` (${extra})` : ''} ${i.operationId}`;
22
+ };
23
+ export const formatHistoryPage = (page, headFingerprint) => {
24
+ const lines = [];
25
+ if (headFingerprint)
26
+ lines.push(`head fingerprint ${headFingerprint}`, '');
27
+ else
28
+ lines.push('head fingerprint (not on this page)', '');
29
+ lines.push(...page.items.map((i) => formatHistoryItem(i)));
30
+ if (page.items.length === 0)
31
+ lines.push('(no history)');
32
+ lines.push('', `history starts at revision ${page.coverage.startsAtRevisionNo}` +
33
+ (page.coverage.baselineRecordedAt ? ` (baseline ${page.coverage.baselineRecordedAt})` : '') +
34
+ ` \u2014 ${sanitizeForTerminal(page.coverage.note, Infinity)}`);
35
+ if (page.nextBeforeOperation) {
36
+ lines.push(`More: --before ${page.nextBeforeOperation}`);
37
+ }
38
+ return lines.join('\n');
39
+ };
40
+ export const formatRevision = (r) => {
41
+ const lines = [
42
+ `revisionNo ${r.revisionNo ?? '-'}`,
43
+ `fingerprint ${r.fingerprint}`,
44
+ `recordedAt ${r.recordedAt}`,
45
+ `action ${r.action}`,
46
+ `session ${sanitizeForTerminal(r.actor.sessionId)}${r.actor.agentLabel ? ` (${sanitizeForTerminal(r.actor.agentLabel)})` : ''}`
47
+ ];
48
+ if (r.restoredFromRevisionNo !== null)
49
+ lines.push(`restoredFrom rev ${r.restoredFromRevisionNo}`);
50
+ if (r.overwriteReason !== null)
51
+ lines.push(`reason ${sanitizeForTerminal(r.overwriteReason)}`);
52
+ if (r.snapshot.intent)
53
+ lines.push(`intent ${sanitizeForTerminal(r.snapshot.intent)}`);
54
+ if (r.snapshot.capturedAt)
55
+ lines.push(`capturedAt ${r.snapshot.capturedAt}`);
56
+ if (r.snapshot.title)
57
+ lines.push(`title ${sanitizeForTerminal(r.snapshot.title)}`);
58
+ if (r.snapshot.aiSummary)
59
+ lines.push(`aiSummary ${sanitizeForTerminal(r.snapshot.aiSummary, Infinity)}`);
60
+ lines.push('', sanitizeForTerminal(r.snapshot.content, Infinity));
61
+ return lines.join('\n');
62
+ };
63
+ const HISTORY_FLAG_NAMES = {
64
+ '--limit': 'limit',
65
+ '--before': 'before'
66
+ };
67
+ export const getHistory = async (cfg, io, rest) => {
68
+ const { flags, positionals, unknown, notAnId } = parseArgs(HISTORY_FLAG_NAMES, rest, [0]);
69
+ if (unknown) {
70
+ return refusal(`Unknown flag: ${unknown}\n`);
71
+ }
72
+ if (notAnId)
73
+ return refusal(notAnId);
74
+ if (positionals.length !== 1) {
75
+ return refusal(`Usage: lattris get history <id> [--limit <n>] [--before <operation-id>]\n`);
76
+ }
77
+ const accessToken = await signedIn(io, cfg);
78
+ if (typeof accessToken !== 'string')
79
+ return accessToken;
80
+ const url = new URL(`${cfg.apiUrl}/memories/${encodeURIComponent(positionals[0])}/revisions`);
81
+ if (flags.limit !== undefined)
82
+ url.searchParams.set('limit', flags.limit);
83
+ if (flags.before !== undefined)
84
+ url.searchParams.set('beforeOperation', flags.before);
85
+ const result = await io.api(accessToken, url);
86
+ if (!result.ok)
87
+ return refusalOf(result, ambiguousText(result.body));
88
+ emit(io, result.data, result.rawText, (page) => formatHistoryPage(page, page.head?.fingerprint));
89
+ };
90
+ export const REVISION_NO_SHAPE = /^[1-9][0-9]{0,17}$/;
91
+ export const getRevision = async (cfg, io, idOrPrefix, revisionNo) => {
92
+ if (!REVISION_NO_SHAPE.test(revisionNo)) {
93
+ return refusal(`--revision must be a positive number from the history: ${revisionNo}\n`);
94
+ }
95
+ const accessToken = await signedIn(io, cfg);
96
+ if (typeof accessToken !== 'string')
97
+ return accessToken;
98
+ const result = await io.api(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(idOrPrefix)}/revisions/${revisionNo}`);
99
+ if (!result.ok)
100
+ return refusalOf(result, ambiguousText(result.body));
101
+ emit(io, result.data, result.rawText, formatRevision);
102
+ };
103
+ const DELETED_FLAG_NAMES = {
104
+ '--limit': 'limit',
105
+ '--cursor': 'cursor'
106
+ };
107
+ export const getDeleted = async (cfg, io, rest) => {
108
+ const { flags, positionals, unknown } = parseArgs(DELETED_FLAG_NAMES, rest);
109
+ if (unknown) {
110
+ return refusal(`Unknown flag: ${unknown}\n`);
111
+ }
112
+ if (positionals.length > 0) {
113
+ return refusal(`Unexpected argument: ${positionals[0]}\n`);
114
+ }
115
+ const accessToken = await signedIn(io, cfg);
116
+ if (typeof accessToken !== 'string')
117
+ return accessToken;
118
+ const url = new URL(`${cfg.apiUrl}/memories`);
119
+ url.searchParams.set('state', 'deleted');
120
+ if (flags.limit !== undefined)
121
+ url.searchParams.set('limit', flags.limit);
122
+ if (flags.cursor !== undefined)
123
+ url.searchParams.set('cursor', flags.cursor);
124
+ const result = await io.api(accessToken, url);
125
+ if (!result.ok)
126
+ return refusalOf(result);
127
+ emit(io, result.data, result.rawText, (page) => {
128
+ const lines = [formatDeletedPage(page)].filter((l) => l.length > 0);
129
+ if (page.nextCursor)
130
+ lines.push(`More: ${renderRunHint(['get', 'deleted', '--cursor', page.nextCursor])}`);
131
+ return lines.join('\n');
132
+ });
133
+ };
134
+ //# sourceMappingURL=history.js.map
@@ -0,0 +1,128 @@
1
+ import { parseArgs } from "../request/flags.js";
2
+ import { RECORDING_FORMATS, TRANSCRIPT_CONVERTERS, formatRecording } from "../memory/transcript.js";
3
+ import { emit } from "../render/output.js";
4
+ import { EXIT, refusal } from "../store/exit.js";
5
+ import { refusalOf } from "../request/api-fetch.js";
6
+ import { signedIn } from "../request/access-token.js";
7
+ import { ambiguousText } from "../render/rows.js";
8
+ const RECORDING_UPDATE_HELP = [
9
+ 'update recording — correct a recording’s transcript or its speaker map.',
10
+ '',
11
+ 'The transcript is replace-whole (turns AND the speaker map are one record);',
12
+ 'the speaker map is the recording’s one correctable finding aid — who each',
13
+ 'voice is.',
14
+ '',
15
+ 'Usage:',
16
+ ' lattris update recording transcript <id> <file> [--format json|a] [--force]',
17
+ ' lattris update recording speakers <id> <key>=<value> [<key>=<value> …]',
18
+ '',
19
+ ' transcript replaces the whole transcript from <file> (same converters as',
20
+ ' capture recording). --force is REQUIRED when the transcript is',
21
+ ' the recording’s ONLY substance — those words ARE the record,',
22
+ ' and the server refuses to replace them without it and says so.',
23
+ '',
24
+ ' speakers corrects the map, one key=value per declared speaker key:',
25
+ ' <key>=me claim that voice as your own',
26
+ ' <key>=<contact-id> map it to one of your contacts',
27
+ ' <key>=clear remove both (back to label-only)',
28
+ ' A key the recording does not declare is a 404 — the map is',
29
+ ' corrected, never widened. Labels are the record and unreachable',
30
+ ' here (re-send the transcript to change a label).'
31
+ ].join('\n');
32
+ const updateRecordingTranscript = async (cfg, io, args) => {
33
+ const force = args.includes('--force');
34
+ const { flags, positionals, unknown, notAnId } = parseArgs({ '--format': 'format' }, args.filter((a) => a !== '--force'), [0]);
35
+ if (unknown) {
36
+ return refusal(`Unknown flag: ${unknown}\n`);
37
+ }
38
+ if (notAnId)
39
+ return refusal(notAnId);
40
+ if (positionals.length !== 2) {
41
+ return refusal('Usage: lattris update recording transcript <id> <file> [--force]\n');
42
+ }
43
+ const [id, file] = positionals;
44
+ const format = flags.format ?? (file.toLowerCase().endsWith('.json') ? 'json' : 'a');
45
+ const convert = TRANSCRIPT_CONVERTERS[format];
46
+ if (convert === undefined) {
47
+ return refusal(`Unknown --format: ${format}. Accepted: ${RECORDING_FORMATS.join(', ')}.\n`);
48
+ }
49
+ let transcript;
50
+ try {
51
+ transcript = convert(io.store.files.readFileSync(file, 'utf8'));
52
+ }
53
+ catch (e) {
54
+ return refusal(`Could not read or parse ${file} as --format ${format}: ${e instanceof Error ? e.message : 'invalid'}\n`);
55
+ }
56
+ const accessToken = await signedIn(io, cfg);
57
+ if (typeof accessToken !== 'string')
58
+ return accessToken;
59
+ const result = await io.api(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(id)}/transcript`, {
60
+ method: 'PUT',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify(force ? { transcript, force: true } : { transcript })
63
+ });
64
+ if (!result.ok)
65
+ return refusalOf(result, ambiguousText(result.body));
66
+ emit(io, result.data, result.rawText, (r) => `Updated transcript.\n\n${formatRecording(r)}`);
67
+ };
68
+ export const buildSpeakerCorrection = (value) => {
69
+ if (value === 'me')
70
+ return { me: true };
71
+ if (value === 'clear' || value === 'none')
72
+ return { contactId: null };
73
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
74
+ return { contactId: value };
75
+ return null;
76
+ };
77
+ const updateRecordingSpeakers = async (cfg, io, args) => {
78
+ const { positionals, unknown, notAnId } = parseArgs({}, args, [0]);
79
+ if (unknown) {
80
+ return refusal(`Unknown flag: ${unknown}\n`);
81
+ }
82
+ if (notAnId)
83
+ return refusal(notAnId);
84
+ if (positionals.length < 2) {
85
+ return refusal('Usage: lattris update recording speakers <id> <key>=me|<contact-id>|clear [<key>=… …]\n');
86
+ }
87
+ const [id, ...pairs] = positionals;
88
+ const speakers = {};
89
+ for (const pair of pairs) {
90
+ const eq = pair.indexOf('=');
91
+ if (eq <= 0) {
92
+ return refusal(`Each correction is <key>=<value>: ${pair}\n`);
93
+ }
94
+ const key = pair.slice(0, eq);
95
+ if (Object.prototype.hasOwnProperty.call(speakers, key)) {
96
+ return refusal(`speaker "${key}" given twice — one correction per speaker per call.\n`);
97
+ }
98
+ const correction = buildSpeakerCorrection(pair.slice(eq + 1));
99
+ if (correction === null) {
100
+ return refusal(`${key}: a correction is me, a contact id (full UUID), or clear — not "${pair.slice(eq + 1)}".\n`);
101
+ }
102
+ speakers[key] = correction;
103
+ }
104
+ const accessToken = await signedIn(io, cfg);
105
+ if (typeof accessToken !== 'string')
106
+ return accessToken;
107
+ const result = await io.api(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(id)}/speakers`, {
108
+ method: 'PUT',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({ speakers })
111
+ });
112
+ if (!result.ok)
113
+ return refusalOf(result, ambiguousText(result.body));
114
+ emit(io, result.data, result.rawText, (r) => `Updated speakers.\n\n${formatRecording(r)}`);
115
+ };
116
+ export const updateRecording = async (cfg, io, rest) => {
117
+ const sub = rest[0];
118
+ if (sub === 'transcript') {
119
+ return updateRecordingTranscript(cfg, io, rest.slice(1));
120
+ }
121
+ if (sub === 'speakers') {
122
+ return updateRecordingSpeakers(cfg, io, rest.slice(1));
123
+ }
124
+ io.stdout.write(`${RECORDING_UPDATE_HELP}\n`);
125
+ if (sub !== undefined)
126
+ return refusal('', EXIT.USAGE);
127
+ };
128
+ //# sourceMappingURL=recording-update.js.map
@@ -0,0 +1,249 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { parseArgs } from "../request/flags.js";
3
+ import { HELP_TOKENS } from "../request/flags.js";
4
+ import { RECORDING_FORMATS, TRANSCRIPT_CONVERTERS, buildRecordingBody, formatRecording, mimeForAudio } from "../memory/transcript.js";
5
+ import { SHARE_AUDIENCES } from "../memory/capture-common.js";
6
+ import { emit } from "../render/output.js";
7
+ import { confirmAction } from "../render/prompt.js";
8
+ import { sanitizeForTerminal } from "../render/text.js";
9
+ import { classifyApiError, parseErrorBody } from "../request/door.js";
10
+ import { putToSignedUploadUrl } from "../request/upload.js";
11
+ import { EXIT, refusal } from "../store/exit.js";
12
+ import { refusalOf } from "../request/api-fetch.js";
13
+ import { signedIn } from "../request/access-token.js";
14
+ export const CAPTURE_RECORDING_HELP = `capture recording — keep an audio recording, its source, and its transcript.
15
+
16
+ A recording stands on any of THREE substances, at least one:
17
+ • held audio the file itself (--audio) — uploaded, then confirmed
18
+ • a source where it lives elsewhere (--source-url)
19
+ • a transcript the words (a file, in a format the CLI converts)
20
+
21
+ The API meets ONE transcript shape; vendor formats convert HERE. Formats:
22
+ json the file is already {speakers, turns, …} — passed through
23
+ a a plain transcript, one turn per line as \`Label: text\`; a line with
24
+ no colon continues the turn above it
25
+
26
+ Usage:
27
+ lattris capture recording <transcript-file> --format a --title "…"
28
+ lattris capture recording <transcript.json> --format json
29
+ lattris capture recording --audio talk.m4a --title "…"
30
+ lattris capture recording --source-url https://… --source-name "…"
31
+
32
+ <transcript-file> optional; the transcript to convert (--format picks how)
33
+ --format <name> json | a (default json when the file ends .json)
34
+ --audio <path> the audio file to hold — its size and sha256 are computed
35
+ locally, the type comes from the extension (or --mime-type)
36
+ --mime-type <type> override the audio content type (an unknown extension)
37
+ --source-url <url> where the recording lives, if referenced not held
38
+ --source-name <s> the source's name --title <s> one line, what it is
39
+ --title-at-source its title at the source --creator <s> who made it (text)
40
+ --published-at <t> when the source published it (ISO 8601)
41
+ --captured-at <t> when it was recorded (ISO 8601; the future is refused)
42
+ --duration <n> length in seconds
43
+ --span <uuid> group it into one of your spans
44
+ --share-with public make it public immediately
45
+ --contributor <id> whose expression it is — a contact id
46
+ --allow-banned-words keep a flagged word in the title deliberately
47
+
48
+ saved → prints the id, what it stands on, and the address. Held audio uploads
49
+ the bytes and confirms before the receipt.`;
50
+ const RECORDING_FLAG_NAMES = {
51
+ '--format': 'format',
52
+ '--audio': 'audio',
53
+ '--mime-type': 'mimeType',
54
+ '--source-url': 'sourceUrl',
55
+ '--source-name': 'sourceName',
56
+ '--title': 'title',
57
+ '--title-at-source': 'titleAtSource',
58
+ '--creator': 'creator',
59
+ '--published-at': 'publishedAt',
60
+ '--captured-at': 'capturedAt',
61
+ '--duration': 'duration',
62
+ '--span': 'span',
63
+ '--share-with': 'shareWith',
64
+ '--contributor': 'contributor'
65
+ };
66
+ export const captureRecording = async (cfg, io, args) => {
67
+ if (args.length === 0 || args.some((t) => HELP_TOKENS.has(t))) {
68
+ io.stdout.write(`${CAPTURE_RECORDING_HELP}\n`);
69
+ return;
70
+ }
71
+ const allowBanned = args.includes('--allow-banned-words');
72
+ const yes = args.includes('--yes');
73
+ const { flags, positionals, unknown } = parseArgs(RECORDING_FLAG_NAMES, args.filter((a) => a !== '--allow-banned-words' && a !== '--yes'));
74
+ if (unknown) {
75
+ return refusal(`Unknown flag: ${unknown}\n`);
76
+ }
77
+ if (positionals.length > 1) {
78
+ return refusal(`Unexpected argument: ${positionals[1]}\n`);
79
+ }
80
+ const transcriptFile = positionals[0];
81
+ if (flags.shareWith !== undefined &&
82
+ !SHARE_AUDIENCES.includes(flags.shareWith)) {
83
+ return refusal(`Unknown audience: ${flags.shareWith}. Accepted: ${SHARE_AUDIENCES.join(', ')}\n`);
84
+ }
85
+ if (flags.shareWith !== undefined && !io.stdin.isTTY && !yes) {
86
+ return refusal('This will save the recording AND make it public — anyone, including people ' +
87
+ 'without accounts. There is no prompt here (the content already arrived on ' +
88
+ 'a pipe) — pass --yes once the person has confirmed both facts.\n');
89
+ }
90
+ if (transcriptFile === undefined && flags.audio === undefined && flags.sourceUrl === undefined) {
91
+ return refusal('A recording needs at least one of: a transcript file, --audio, or --source-url.\n');
92
+ }
93
+ let transcript;
94
+ if (transcriptFile !== undefined) {
95
+ const format = flags.format ?? (transcriptFile.toLowerCase().endsWith('.json') ? 'json' : 'a');
96
+ const convert = TRANSCRIPT_CONVERTERS[format];
97
+ if (convert === undefined) {
98
+ return refusal(`Unknown --format: ${format}. Accepted: ${RECORDING_FORMATS.join(', ')}.\n`);
99
+ }
100
+ let raw;
101
+ try {
102
+ raw = io.store.files.readFileSync(transcriptFile, 'utf8');
103
+ }
104
+ catch {
105
+ return refusal(`Cannot read transcript file: ${transcriptFile}\n`);
106
+ }
107
+ try {
108
+ transcript = convert(raw);
109
+ }
110
+ catch (e) {
111
+ return refusal(`Could not parse ${transcriptFile} as --format ${format}: ${e instanceof Error ? e.message : 'invalid'}\n`);
112
+ }
113
+ }
114
+ let upload;
115
+ let audioBytes;
116
+ if (flags.audio !== undefined) {
117
+ const mimeType = mimeForAudio(flags.audio, flags.mimeType);
118
+ if (mimeType === null) {
119
+ return refusal(`Can't tell the audio type of ${flags.audio} from its extension — pass --mime-type <type>.\n`);
120
+ }
121
+ try {
122
+ audioBytes = io.store.files.readFileSync(flags.audio);
123
+ }
124
+ catch {
125
+ return refusal(`Cannot read audio file: ${flags.audio}\n`);
126
+ }
127
+ upload = {
128
+ filename: flags.audio.slice(flags.audio.lastIndexOf('/') + 1),
129
+ mimeType,
130
+ size: audioBytes.length,
131
+ sha256: createHash('sha256').update(audioBytes).digest('hex')
132
+ };
133
+ }
134
+ if (flags.duration !== undefined && !/^\d+$/.test(flags.duration)) {
135
+ return refusal(`--duration must be a whole number of seconds: ${flags.duration}\n`);
136
+ }
137
+ const body = buildRecordingBody({
138
+ transcript,
139
+ upload,
140
+ sourceUrl: flags.sourceUrl,
141
+ title: flags.title,
142
+ capturedAt: flags.capturedAt,
143
+ spanId: flags.span,
144
+ audience: flags.shareWith,
145
+ contributor: flags.contributor,
146
+ sourceName: flags.sourceName,
147
+ titleAtSource: flags.titleAtSource,
148
+ creator: flags.creator,
149
+ publishedAt: flags.publishedAt,
150
+ durationSeconds: flags.duration === undefined ? undefined : Number(flags.duration),
151
+ allowBannedWords: allowBanned
152
+ });
153
+ if (flags.shareWith !== undefined && io.stdin.isTTY) {
154
+ const answer = await confirmAction(io, `Share "${sanitizeForTerminal(flags.title ?? '(untitled)')}" with public — anyone, including people without accounts? [y/N] `);
155
+ if (answer === 'interrupted')
156
+ return refusal('Cancelled.\n', EXIT.GENERIC);
157
+ if (answer === 'no') {
158
+ io.stderr.write('Cancelled.\n');
159
+ return;
160
+ }
161
+ }
162
+ const accessToken = await signedIn(io, cfg);
163
+ if (typeof accessToken !== 'string')
164
+ return accessToken;
165
+ const created = await io.api(accessToken, `${cfg.apiUrl}/memories/recordings`, {
166
+ method: 'POST',
167
+ headers: { 'Content-Type': 'application/json' },
168
+ body: JSON.stringify(body)
169
+ });
170
+ if (!created.ok)
171
+ return refusalOf(created);
172
+ if ('signedUploadUrl' in created.data) {
173
+ const pending = created.data;
174
+ const putOnce = async () => {
175
+ try {
176
+ const res = await putToSignedUploadUrl(io.network, pending.signedUploadUrl, upload.mimeType, new Uint8Array(audioBytes));
177
+ if (res.ok)
178
+ return { ok: true };
179
+ return {
180
+ ok: false,
181
+ why: res.status === 403
182
+ ? 'the signed upload URL was rejected (it may have expired)'
183
+ : `HTTP ${res.status}`
184
+ };
185
+ }
186
+ catch (e) {
187
+ return { ok: false, why: e instanceof Error ? e.message : 'a network error' };
188
+ }
189
+ };
190
+ let put = await putOnce();
191
+ if (!put.ok)
192
+ put = await putOnce();
193
+ if (!put.ok) {
194
+ return refusal(`The recording was reserved (${pending.block.id}) but the audio upload failed — ${put.why}.\n` +
195
+ `That reservation has no confirmed audio and is cleared server-side; running the capture ` +
196
+ `again creates a NEW recording — it does not adopt this one.\n`, EXIT.GENERIC);
197
+ }
198
+ const confirmUrl = `${cfg.apiUrl}/memories/${encodeURIComponent(pending.block.id)}/confirm`;
199
+ const confirmOnce = async () => {
200
+ try {
201
+ const res = await io.api(accessToken, confirmUrl, { method: 'POST' });
202
+ const rawText = res.rawText;
203
+ if (res.ok || res.answer.kind === 'page')
204
+ return { kind: 'ok', data: JSON.parse(rawText), rawText };
205
+ return { kind: 'http', status: res.status, rawText };
206
+ }
207
+ catch (e) {
208
+ return { kind: 'transport', message: e instanceof Error ? e.message : 'a network error' };
209
+ }
210
+ };
211
+ let confirmed = await confirmOnce();
212
+ const firstLost = confirmed.kind === 'transport';
213
+ if (firstLost)
214
+ confirmed = await confirmOnce();
215
+ if (confirmed.kind === 'ok') {
216
+ emit(io, confirmed.data, confirmed.rawText, formatRecording);
217
+ return;
218
+ }
219
+ if (firstLost) {
220
+ try {
221
+ const probe = await io.api(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(pending.block.id)}`);
222
+ if (probe.ok) {
223
+ const rawText = probe.rawText;
224
+ const rec = JSON.parse(rawText);
225
+ if (rec.media != null) {
226
+ emit(io, rec, rawText, formatRecording);
227
+ return;
228
+ }
229
+ }
230
+ }
231
+ catch { }
232
+ }
233
+ if (confirmed.kind === 'http') {
234
+ const { message, next, exit } = classifyApiError(confirmed.status, parseErrorBody(confirmed.rawText));
235
+ return {
236
+ message: `The recording was reserved (${pending.block.id}); the audio uploaded but the confirm did ` +
237
+ `not land. That reservation is incomplete and cleared server-side; running the capture ` +
238
+ `again creates a NEW recording — it does not finish this one.\n`,
239
+ exit,
240
+ answer: { kind: 'door', message, next }
241
+ };
242
+ }
243
+ return refusal(`The audio uploaded but confirming the recording (${pending.block.id}) failed — ${confirmed.message}.\n` +
244
+ `That reservation is incomplete and cleared server-side; running the capture again creates ` +
245
+ `a NEW recording — it does not finish this one.\n`, EXIT.GENERIC);
246
+ }
247
+ emit(io, created.data, created.rawText, formatRecording);
248
+ };
249
+ //# sourceMappingURL=recording.js.map
@@ -0,0 +1,82 @@
1
+ import { HELP_TOKENS } from "../request/flags.js";
2
+ import { formatSaved } from "../memory/capture-common.js";
3
+ import { formatBlockLine, formatMemoryDetail } from "../memory/views.js";
4
+ import { emit } from "../render/output.js";
5
+ import { sanitizeForTerminal } from "../render/text.js";
6
+ import { EXIT, refusal } from "../store/exit.js";
7
+ import { AGENT_LABEL_SHAPE, SESSION_ID_SHAPE, isUuidKey, noteWriteHeaders } from "../store/write-identity.js";
8
+ import { refusalOf } from "../request/api-fetch.js";
9
+ import { signedIn } from "../request/access-token.js";
10
+ import { ambiguousText } from "../render/rows.js";
11
+ export const RESEND_HELP = [
12
+ 'resend — send a kept request again, exactly: the same door, the same session, the same',
13
+ 'key, the same body. What a lost response on `capture memory` or `update memory --overwrite`',
14
+ 'leaves under drafts/ in the machine dir.',
15
+ '',
16
+ 'Usage:',
17
+ ' lattris resend <path-to-request.json>',
18
+ '',
19
+ 'If the first attempt landed, the door answers the same receipt and writes nothing; if it',
20
+ 'did not, the write happens now, once. The file’s own session is used, whatever',
21
+ 'LATTRIS_SESSION_ID says here.'
22
+ ].join('\n');
23
+ const RESEND_DOORS = /^(POST \/memories\/notes|PATCH \/memories\/[^/\s]+)$/;
24
+ export const resend = async (cfg, io, args) => {
25
+ if (args.length === 0 || args.some((t) => HELP_TOKENS.has(t))) {
26
+ io.stdout.write(`${RESEND_HELP}\n`);
27
+ if (args.length > 0 && !args.some((t) => HELP_TOKENS.has(t)))
28
+ return refusal('', EXIT.USAGE);
29
+ return;
30
+ }
31
+ if (args.length !== 1) {
32
+ return refusal('Usage: lattris resend <path-to-request.json>\n');
33
+ }
34
+ let draft;
35
+ try {
36
+ draft = JSON.parse(io.store.files.readFileSync(args[0], 'utf8'));
37
+ }
38
+ catch (e) {
39
+ return refusal(`Could not read a kept request at ${args[0]}: ${sanitizeForTerminal(e instanceof Error ? e.message : String(e))}\n`);
40
+ }
41
+ if (typeof draft !== 'object' ||
42
+ draft === null ||
43
+ typeof draft.door !== 'string' ||
44
+ !RESEND_DOORS.test(draft.door) ||
45
+ typeof draft.idempotencyKey !== 'string' ||
46
+ !isUuidKey(draft.idempotencyKey) ||
47
+ typeof draft.sessionId !== 'string' ||
48
+ !SESSION_ID_SHAPE.test(draft.sessionId) ||
49
+ typeof draft.body !== 'object' ||
50
+ draft.body === null) {
51
+ return refusal(`${args[0]} is not a kept request (door, idempotencyKey, sessionId and body are what one carries).\n`);
52
+ }
53
+ if (draft.agentLabel !== undefined &&
54
+ (typeof draft.agentLabel !== 'string' || !AGENT_LABEL_SHAPE.test(draft.agentLabel))) {
55
+ return refusal(`The kept request at ${args[0]} names the agent label "${sanitizeForTerminal(String(draft.agentLabel))}", ` +
56
+ 'which a request header cannot carry: an agent label is 1 to 128 printable ASCII characters. ' +
57
+ 'Nothing was sent.\n');
58
+ }
59
+ const accessToken = await signedIn(io, cfg);
60
+ if (typeof accessToken !== 'string')
61
+ return accessToken;
62
+ const [method, path] = draft.door.split(' ');
63
+ const identity = { sessionId: draft.sessionId, agentLabel: draft.agentLabel };
64
+ const result = await io.api(accessToken, `${cfg.apiUrl}${path}`, {
65
+ method,
66
+ headers: {
67
+ 'Content-Type': 'application/json',
68
+ ...noteWriteHeaders(identity, draft.idempotencyKey)
69
+ },
70
+ body: JSON.stringify(draft.body)
71
+ });
72
+ if (!result.ok)
73
+ return refusalOf(result, ambiguousText(result.body));
74
+ if (method === 'POST') {
75
+ io.stdout.write(`${formatSaved(result.data)}\n`);
76
+ return;
77
+ }
78
+ emit(io, result.data, result.rawText, (m) => 'visibility' in m
79
+ ? `Resent.\n\n${formatBlockLine(m)}`
80
+ : `Resent.\n\n${formatMemoryDetail(m)}`);
81
+ };
82
+ //# sourceMappingURL=resend.js.map
@@ -0,0 +1,73 @@
1
+ import { parseArgs } from "../request/flags.js";
2
+ import { REVISION_NO_SHAPE } from "../memory/history.js";
3
+ import { FINGERPRINT_SHAPE } from "../memory/update-help.js";
4
+ import { composeAddressFor, formatMemoryDetail } from "../memory/views.js";
5
+ import { emit } from "../render/output.js";
6
+ import { noteWriteHeaders, requireWriteIdentity } from "../store/write-identity.js";
7
+ import { isRefusal, refusal } from "../store/exit.js";
8
+ import { refusalOf } from "../request/api-fetch.js";
9
+ import { signedIn } from "../request/access-token.js";
10
+ import { ambiguousText } from "../render/rows.js";
11
+ export const RESTORE_HELP = [
12
+ 'restore — bring a note back from deleted items, or bring an earlier revision back as the head.',
13
+ '',
14
+ 'Usage:',
15
+ ' lattris restore memory <id-or-prefix> --revision <n> --base-fingerprint <head> [--clear-missing]',
16
+ '',
17
+ ' --revision required; the revision to bring back, from `lattris get history <id>`.',
18
+ ' The note\u2019s own current number un-archives it with its last words.',
19
+ ' --base-fingerprint required; the head fingerprint history printed. A head that moved',
20
+ ' since is refused and nothing changes.',
21
+ ' --clear-missing restore even when the revision names a span or contact that no',
22
+ ' longer exists, clearing those references; without it the door',
23
+ ' refuses and names them.',
24
+ '',
25
+ 'History is never rewritten: restoring earlier words appends them as a new revision',
26
+ 'that says where it came from. A note back from deleted items returns private.'
27
+ ].join('\n');
28
+ const RESTORE_FLAG_NAMES = {
29
+ '--revision': 'revision',
30
+ '--base-fingerprint': 'baseFingerprint'
31
+ };
32
+ export const restoreMemory = async (cfg, io, rest) => {
33
+ const clearMissing = rest.includes('--clear-missing');
34
+ const { flags, positionals, unknown, notAnId } = parseArgs(RESTORE_FLAG_NAMES, rest.filter((a) => a !== '--clear-missing'), [0]);
35
+ if (unknown) {
36
+ return refusal(`Unknown flag: ${unknown}\n`);
37
+ }
38
+ if (notAnId)
39
+ return refusal(notAnId);
40
+ if (positionals.length !== 1 ||
41
+ flags.revision === undefined ||
42
+ flags.baseFingerprint === undefined) {
43
+ return refusal(`Usage: lattris restore memory <id> --revision <n> --base-fingerprint <head>\n`);
44
+ }
45
+ if (!REVISION_NO_SHAPE.test(flags.revision)) {
46
+ return refusal(`--revision must be a positive number from the history: ${flags.revision}\n`);
47
+ }
48
+ if (!FINGERPRINT_SHAPE.test(flags.baseFingerprint)) {
49
+ return refusal(`--base-fingerprint must be the 64 lowercase hex characters history printed: ${flags.baseFingerprint}\n`);
50
+ }
51
+ const identity = requireWriteIdentity(io.store, io.stdin.isTTY);
52
+ if (isRefusal(identity))
53
+ return identity;
54
+ const accessToken = await signedIn(io, cfg);
55
+ if (typeof accessToken !== 'string')
56
+ return accessToken;
57
+ const result = await io.api(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(positionals[0])}/restore`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json', ...noteWriteHeaders(identity) },
60
+ body: JSON.stringify({
61
+ revisionNo: flags.revision,
62
+ baseFingerprint: flags.baseFingerprint,
63
+ ...(clearMissing ? { missingReferences: 'clear' } : {})
64
+ })
65
+ });
66
+ if (!result.ok)
67
+ return refusalOf(result, ambiguousText(result.body));
68
+ let address;
69
+ if (io.stdout.isTTY)
70
+ address = await composeAddressFor(cfg, io, accessToken, result.data);
71
+ emit(io, result.data, result.rawText, (m) => `Restored.\n\n${formatMemoryDetail(m, address)}`);
72
+ };
73
+ //# sourceMappingURL=restore.js.map