lattris 0.2.0 → 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/dist/build.json +1 -4
- package/dist/dispatch-form.js +27 -6
- package/dist/dispatch-words.js +19 -23
- package/dist/memory/access.js +88 -0
- package/dist/memory/capture-common.js +109 -5
- package/dist/memory/capture-help.js +18 -9
- package/dist/memory/capture-note.js +26 -16
- package/dist/memory/capture-session.js +18 -16
- package/dist/memory/get-help.js +10 -1
- package/dist/memory/get.js +38 -5
- package/dist/memory/recording.js +36 -27
- package/dist/memory/transcript.js +3 -0
- package/dist/memory/views.js +10 -2
- package/dist/nouns/contact.js +83 -22
- package/dist/nouns/index.js +15 -1
- package/dist/nouns/share.js +189 -28
- package/dist/nouns/words.js +9 -4
- package/dist/registry/memory.js +8 -2
- package/dist/render/rows.js +34 -1
- package/package.json +2 -2
package/dist/build.json
CHANGED
package/dist/dispatch-form.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { audiencesOf } from "./nouns/index.js";
|
|
2
2
|
import { emit } from "./render/output.js";
|
|
3
3
|
import { confirmAction } from "./render/prompt.js";
|
|
4
|
-
import { ambiguousText, formatContact } from "./render/rows.js";
|
|
4
|
+
import { ambiguousText, formatContact, groupsNotBuilt, isGroupWord } from "./render/rows.js";
|
|
5
5
|
import { renderRunHint, sanitizeForTerminal } from "./render/text.js";
|
|
6
6
|
import { signedIn } from "./request/access-token.js";
|
|
7
7
|
import { refusalOf } from "./request/api-fetch.js";
|
|
8
8
|
import { EXIT, isRefusal } from "./store/exit.js";
|
|
9
|
-
import { buildBody, describeAudience, fillIn, readWords, refuse, resolveContact, urlOf } from "./dispatch-words.js";
|
|
9
|
+
import { buildBody, describeAudience, fillIn, fillPath, readWords, refuse, resolveContact, urlOf } from "./dispatch-words.js";
|
|
10
10
|
export const runForm = async (cfg, io, form, rest) => {
|
|
11
11
|
const typed = readWords(form, rest);
|
|
12
12
|
if (isRefusal(typed))
|
|
@@ -34,6 +34,8 @@ export const runForm = async (cfg, io, form, rest) => {
|
|
|
34
34
|
if (steps.requireFlag && typed.flags[steps.requireFlag.key] === undefined) {
|
|
35
35
|
return refuse(steps.requireFlag.message);
|
|
36
36
|
}
|
|
37
|
+
if (selector !== undefined && isGroupWord(selector))
|
|
38
|
+
return refuse(groupsNotBuilt(selector));
|
|
37
39
|
if (steps.pipedNeedsYes && !io.stdin.isTTY && !typed.on.has('--yes')) {
|
|
38
40
|
return refuse(steps.pipedNeedsYes.message);
|
|
39
41
|
}
|
|
@@ -53,8 +55,17 @@ export const runForm = async (cfg, io, form, rest) => {
|
|
|
53
55
|
if (typeof accessToken !== 'string')
|
|
54
56
|
return accessToken;
|
|
55
57
|
let missingName;
|
|
58
|
+
const reserved = [
|
|
59
|
+
...(steps.contact?.publicWord === undefined ? [] : [steps.contact.publicWord]),
|
|
60
|
+
...(steps.contact?.reserved ?? [])
|
|
61
|
+
];
|
|
62
|
+
const refusedWord = selector !== undefined ? steps.contact?.refusedWords?.[selector] : undefined;
|
|
63
|
+
if (refusedWord !== undefined)
|
|
64
|
+
return refuse(refusedWord);
|
|
56
65
|
const wantsPublic = steps.contact?.publicWord !== undefined && selector === steps.contact.publicWord;
|
|
57
|
-
if (
|
|
66
|
+
if (selector !== undefined && reserved.includes(selector))
|
|
67
|
+
context.reserved = selector;
|
|
68
|
+
if (steps.contact && selector !== undefined && context.reserved === undefined) {
|
|
58
69
|
const contacts = await io.api(accessToken, `${cfg.apiUrl}/contacts`);
|
|
59
70
|
if (!contacts.ok)
|
|
60
71
|
return refusalOf(contacts);
|
|
@@ -108,6 +119,7 @@ export const runForm = async (cfg, io, form, rest) => {
|
|
|
108
119
|
name,
|
|
109
120
|
title: sanitizeForTerminal(context.memory?.title ?? '(untitled)'),
|
|
110
121
|
'memory.id': context.memory?.id ?? '',
|
|
122
|
+
'contact.id': context.contact?.id ?? '',
|
|
111
123
|
audience: context.contact?.displayName ?? describeAudience(selector ?? '')
|
|
112
124
|
});
|
|
113
125
|
const answer = await confirmAction(io, question);
|
|
@@ -134,19 +146,28 @@ export const runForm = async (cfg, io, form, rest) => {
|
|
|
134
146
|
context.createdContact = true;
|
|
135
147
|
body = buildBody(form, typed, stdin, context);
|
|
136
148
|
}
|
|
137
|
-
else if (context.contact !== undefined || wantsPublic) {
|
|
149
|
+
else if (context.contact !== undefined || wantsPublic || context.reserved !== undefined) {
|
|
138
150
|
body = buildBody(form, typed, stdin, context);
|
|
139
151
|
}
|
|
140
152
|
const url = urlOf(cfg.apiUrl, form, typed, stdin, context);
|
|
141
|
-
const
|
|
153
|
+
const send = (at) => io.api(accessToken, at, body === undefined
|
|
142
154
|
? { method: form.request.method }
|
|
143
155
|
: {
|
|
144
156
|
method: form.request.method,
|
|
145
157
|
headers: { 'Content-Type': 'application/json' },
|
|
146
158
|
body: JSON.stringify(body)
|
|
147
159
|
});
|
|
160
|
+
let result = await send(url);
|
|
161
|
+
const fallback = form.request.fallback;
|
|
162
|
+
if (!result.ok &&
|
|
163
|
+
fallback !== undefined &&
|
|
164
|
+
result.status === fallback.status &&
|
|
165
|
+
context.contact?.claimedPersonId) {
|
|
166
|
+
result = await send(new URL(`${cfg.apiUrl}${fillPath(fallback.path, typed, context)}`));
|
|
167
|
+
}
|
|
148
168
|
if (!result.ok) {
|
|
149
|
-
return refusalOf(result, form.ambiguousRefusal ? ambiguousText(result.body) : '')
|
|
169
|
+
return refusalOf(result, (form.ambiguousRefusal ? ambiguousText(result.body) : '') +
|
|
170
|
+
(form.refusalWords?.(result.body, context) ?? ''));
|
|
150
171
|
}
|
|
151
172
|
const typedView = context;
|
|
152
173
|
if (form.pickFromList) {
|
package/dist/dispatch-words.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveContact } from "./render/rows.js";
|
|
1
2
|
import { renderRunHint } from "./render/text.js";
|
|
2
3
|
import { parseArgs } from "./request/flags.js";
|
|
3
4
|
import { EXIT, refusal } from "./store/exit.js";
|
|
@@ -84,6 +85,9 @@ export const valueOf = (source, typed, stdin, context) => {
|
|
|
84
85
|
return typed.on.has(source.switch) ? source.value : source.otherwise;
|
|
85
86
|
};
|
|
86
87
|
export const buildBody = (form, typed, stdin, context) => {
|
|
88
|
+
if (context.reserved !== undefined && form.request.bodyFor?.[context.reserved]) {
|
|
89
|
+
return form.request.bodyFor[context.reserved];
|
|
90
|
+
}
|
|
87
91
|
if (form.request.bodyWithPublic && context.contact === undefined) {
|
|
88
92
|
return form.request.bodyWithPublic;
|
|
89
93
|
}
|
|
@@ -99,16 +103,16 @@ export const buildBody = (form, typed, stdin, context) => {
|
|
|
99
103
|
}
|
|
100
104
|
return body;
|
|
101
105
|
};
|
|
102
|
-
export const pathOf = (form, typed, context) =>
|
|
103
|
-
|
|
106
|
+
export const pathOf = (form, typed, context) => fillPath((context.reserved !== undefined && form.request.pathFor?.[context.reserved]) ||
|
|
107
|
+
(form.request.pathWithPublic && context.contact === undefined
|
|
104
108
|
? form.request.pathWithPublic
|
|
105
|
-
: form.request.path;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
};
|
|
109
|
+
: form.request.path), typed, context);
|
|
110
|
+
export const fillPath = (template, typed, context) => template
|
|
111
|
+
.replace(/\{(--[a-z-]+):([^}]*)\}/g, (_m, name, text) => typed.on.has(name) ? text : '')
|
|
112
|
+
.replace(/\{p(\d+)\}/g, (_m, i) => encodeURIComponent(typed.positionals[Number(i)] ?? ''))
|
|
113
|
+
.replace('{memory.id}', encodeURIComponent(context.memory?.id ?? typed.positionals[0] ?? ''))
|
|
114
|
+
.replace('{contact.claimedPersonId}', () => encodeURIComponent(String(context.contact?.claimedPersonId ?? '')))
|
|
115
|
+
.replace('{contact.id}', () => encodeURIComponent(String(context.contact?.id ?? '')));
|
|
112
116
|
export const urlOf = (apiUrl, form, typed, stdin, context) => {
|
|
113
117
|
const url = new URL(`${apiUrl}${pathOf(form, typed, context)}`);
|
|
114
118
|
for (const [key, sources] of Object.entries(form.request.query ?? {})) {
|
|
@@ -120,18 +124,10 @@ export const urlOf = (apiUrl, form, typed, stdin, context) => {
|
|
|
120
124
|
}
|
|
121
125
|
return url;
|
|
122
126
|
};
|
|
123
|
-
export
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const matches = contacts.filter((c) => c.displayName.toLowerCase() === folded);
|
|
130
|
-
if (matches.length === 1)
|
|
131
|
-
return { kind: 'found', contact: matches[0] };
|
|
132
|
-
if (matches.length > 1)
|
|
133
|
-
return { kind: 'ambiguous', matches };
|
|
134
|
-
return { kind: 'missing-name', displayName: selector };
|
|
135
|
-
};
|
|
136
|
-
export const describeAudience = (audience) => audience === 'public' ? 'public — anyone, including people without accounts' : audience;
|
|
127
|
+
export { resolveContact };
|
|
128
|
+
export const describeAudience = (audience) => audience === 'public'
|
|
129
|
+
? 'public — anyone, including people without accounts'
|
|
130
|
+
: audience === 'all-contacts'
|
|
131
|
+
? 'all your contacts'
|
|
132
|
+
: audience;
|
|
137
133
|
//# sourceMappingURL=dispatch-words.js.map
|
|
@@ -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
|
|
@@ -1,5 +1,110 @@
|
|
|
1
|
+
import { formatContact, groupsNotBuilt, isGroupWord, resolveContact, waitsForNameLines } from "../render/rows.js";
|
|
1
2
|
import { renderRunHint, sanitizeForTerminal } from "../render/text.js";
|
|
2
|
-
|
|
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) => {
|
|
3
108
|
const body = { content };
|
|
4
109
|
if (flags.intent !== undefined)
|
|
5
110
|
body.intent = flags.intent;
|
|
@@ -7,8 +112,7 @@ export const buildCreateNoteBody = (content, flags, allowBannedWords = false) =>
|
|
|
7
112
|
body.title = flags.title;
|
|
8
113
|
if (flags.capturedAt !== undefined)
|
|
9
114
|
body.capturedAt = flags.capturedAt;
|
|
10
|
-
|
|
11
|
-
body.audience = flags.shareWith;
|
|
115
|
+
withShares(body, shares);
|
|
12
116
|
if (flags.contributor !== undefined)
|
|
13
117
|
body.contributor = flags.contributor;
|
|
14
118
|
if (flags.aiSummary !== undefined)
|
|
@@ -32,6 +136,8 @@ export const formatSaved = (b, address) => {
|
|
|
32
136
|
lines.push(` revisionNo ${b.revisionNo}`);
|
|
33
137
|
if (address)
|
|
34
138
|
lines.push(` address ${address}`);
|
|
139
|
+
for (const sentence of waitsForNameLines(b.access))
|
|
140
|
+
lines.push(` website ${sentence}`);
|
|
35
141
|
return lines.join('\n');
|
|
36
142
|
};
|
|
37
143
|
export const CAPTURE_MEMORY_FLAG_NAMES = {
|
|
@@ -40,7 +146,6 @@ export const CAPTURE_MEMORY_FLAG_NAMES = {
|
|
|
40
146
|
'--intent': 'intent',
|
|
41
147
|
'--title': 'title',
|
|
42
148
|
'--captured-at': 'capturedAt',
|
|
43
|
-
'--share-with': 'shareWith',
|
|
44
149
|
'--source': 'source',
|
|
45
150
|
'--conversation-id': 'conversationId',
|
|
46
151
|
'--extends': 'extends',
|
|
@@ -63,5 +168,4 @@ export const SESSION_ONLY_FLAGS = [
|
|
|
63
168
|
['--forked-from', 'forkedFrom']
|
|
64
169
|
];
|
|
65
170
|
export const retryWords = (path) => renderRunHint(['resend', path]);
|
|
66
|
-
export const SHARE_AUDIENCES = ['public'];
|
|
67
171
|
//# sourceMappingURL=capture-common.js.map
|
|
@@ -70,12 +70,18 @@ to save text arrives on stdin — no quoting; up to 5MB.
|
|
|
70
70
|
--intent <name> optional; default none
|
|
71
71
|
--title <text> optional; one line, what this is about
|
|
72
72
|
--captured-at <time> optional; when it happened, if not now
|
|
73
|
-
--share-with
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
79
85
|
|
|
80
86
|
--ai-summary <text> optional; an AI-written abstract beside the
|
|
81
87
|
title — a finding aid, never the record;
|
|
@@ -173,9 +179,12 @@ is the conversation, not the machinery.
|
|
|
173
179
|
--contributor <uuid> optional; whose expression — a contact id, attributed
|
|
174
180
|
to that person's voice. Applied to EVERY epoch in a
|
|
175
181
|
transcript chain (re-running heals older epochs too)
|
|
176
|
-
--share-with
|
|
177
|
-
as a note
|
|
178
|
-
|
|
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
|
|
179
188
|
|
|
180
189
|
A divergence refusal (the thread differs from the stored session) means a fork:
|
|
181
190
|
nothing was written; re-run with --as-new --forked-from <that block> once the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { ambiguousText, formatEdgeReceipt } from "../render/rows.js";
|
|
3
3
|
import { parseArgs } from "../request/flags.js";
|
|
4
|
-
import { CAPTURE_MEMORY_FLAG_NAMES, SESSION_ONLY_FLAGS,
|
|
4
|
+
import { CAPTURE_MEMORY_FLAG_NAMES, SESSION_ONLY_FLAGS, allowWords, buildCreateNoteBody, checkShareWords, formatSaved, isRefusalValue, needsYesWords, resolveBornShares, retryWords, takeShareFlags } from "../memory/capture-common.js";
|
|
5
5
|
import { CAPTURE_NOTE_HELP, CAPTURE_SESSION_HELP } from "../memory/capture-help.js";
|
|
6
6
|
import { captureSession, captureSessionTranscript } from "../memory/capture-session.js";
|
|
7
7
|
import { composeAddressFor } from "../memory/views.js";
|
|
@@ -34,7 +34,9 @@ export const captureMemory = async (cfg, io, args) => {
|
|
|
34
34
|
const yes = args.includes('--yes');
|
|
35
35
|
const asNew = args.includes('--as-new');
|
|
36
36
|
const allowBanned = args.includes('--allow-banned-words');
|
|
37
|
-
const
|
|
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;
|
|
38
40
|
const piped = !io.stdin.isTTY && positionals.length === 0;
|
|
39
41
|
const wantsNote = !asksForSessionForm(args) && (flags.type ?? 'note') === 'note';
|
|
40
42
|
const early = piped && wantsNote ? requireWriteIdentity(io.store, io.stdin.isTTY) : undefined;
|
|
@@ -45,9 +47,12 @@ export const captureMemory = async (cfg, io, args) => {
|
|
|
45
47
|
io.stdout.write(`${asksForSessionForm(args) ? CAPTURE_SESSION_HELP : CAPTURE_NOTE_HELP}\n`);
|
|
46
48
|
return;
|
|
47
49
|
}
|
|
48
|
-
if (unknown) {
|
|
49
|
-
return refusal(`Unknown flag: ${unknown}\n`);
|
|
50
|
+
if (unknown ?? taken.unknown) {
|
|
51
|
+
return refusal(`Unknown flag: ${unknown ?? taken.unknown}\n`);
|
|
50
52
|
}
|
|
53
|
+
const badShareWords = checkShareWords(taken.shareWith, taken.deny);
|
|
54
|
+
if (badShareWords)
|
|
55
|
+
return badShareWords;
|
|
51
56
|
if (flags.contributor !== undefined) {
|
|
52
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;
|
|
53
58
|
if (!UUID_SHAPE.test(flags.contributor)) {
|
|
@@ -76,9 +81,14 @@ export const captureMemory = async (cfg, io, args) => {
|
|
|
76
81
|
return refusal(`Unexpected argument: ${positionals[1]}\n`);
|
|
77
82
|
}
|
|
78
83
|
if (positionals.length === 1) {
|
|
79
|
-
return captureSessionTranscript(cfg, io, positionals[0], flags, asNew);
|
|
84
|
+
return captureSessionTranscript(cfg, io, positionals[0], flags, asNew, sharesNamed);
|
|
80
85
|
}
|
|
81
|
-
return captureSession(cfg, io, content, flags, {
|
|
86
|
+
return captureSession(cfg, io, content, flags, {
|
|
87
|
+
yes,
|
|
88
|
+
asNew,
|
|
89
|
+
shareWith: taken.shareWith,
|
|
90
|
+
deny: taken.deny
|
|
91
|
+
});
|
|
82
92
|
}
|
|
83
93
|
if (positionals.length > 0) {
|
|
84
94
|
return refusal(`Unexpected argument: ${positionals[0]}\n`);
|
|
@@ -110,15 +120,8 @@ export const captureMemory = async (cfg, io, args) => {
|
|
|
110
120
|
if (flags.linksTo !== undefined && !isUuidKey(flags.linksTo)) {
|
|
111
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`);
|
|
112
122
|
}
|
|
113
|
-
if (
|
|
114
|
-
|
|
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
|
-
}
|
|
123
|
+
if (taken.shareWith.length > 0 && !yes) {
|
|
124
|
+
return refusal(needsYesWords('memory', allowWords(taken.shareWith)));
|
|
122
125
|
}
|
|
123
126
|
const identity = early ?? requireWriteIdentity(io.store, io.stdin.isTTY);
|
|
124
127
|
if (isRefusal(identity))
|
|
@@ -130,7 +133,14 @@ export const captureMemory = async (cfg, io, args) => {
|
|
|
130
133
|
const accessToken = await signedIn(io, cfg);
|
|
131
134
|
if (typeof accessToken !== 'string')
|
|
132
135
|
return accessToken;
|
|
133
|
-
|
|
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);
|
|
134
144
|
const requestDraft = {
|
|
135
145
|
door: 'POST /memories/notes',
|
|
136
146
|
idempotencyKey,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { extractChain } from "./extract-claude-jsonl.js";
|
|
2
2
|
import { ChainSaveError, driveChain } from "./save-session-chain.js";
|
|
3
3
|
import { loadReceipts, saveReceipts } from "../store/chains.js";
|
|
4
|
-
import {
|
|
4
|
+
import { allowWords, isRefusalValue, needsYesWords, resolveBornShares, withShares } from "../memory/capture-common.js";
|
|
5
5
|
import { emit } from "../render/output.js";
|
|
6
6
|
import { sanitizeForTerminal } from "../render/text.js";
|
|
7
7
|
import { classifyApiError } from "../request/door.js";
|
|
@@ -17,7 +17,7 @@ export const formatSavedSession = (r) => {
|
|
|
17
17
|
lines.push(` capturedAt ${r.block.capturedAt}`);
|
|
18
18
|
return lines.join('\n');
|
|
19
19
|
};
|
|
20
|
-
export const buildCaptureSessionBody = (turns, flags, asNew) => {
|
|
20
|
+
export const buildCaptureSessionBody = (turns, flags, asNew, shares) => {
|
|
21
21
|
const body = { turns };
|
|
22
22
|
if (flags.title !== undefined)
|
|
23
23
|
body.title = flags.title;
|
|
@@ -35,8 +35,7 @@ export const buildCaptureSessionBody = (turns, flags, asNew) => {
|
|
|
35
35
|
body.from = Number(flags.from);
|
|
36
36
|
if (asNew)
|
|
37
37
|
body.asNewBlock = true;
|
|
38
|
-
|
|
39
|
-
body.audience = flags.shareWith;
|
|
38
|
+
withShares(body, shares);
|
|
40
39
|
if (flags.continuesFrom !== undefined)
|
|
41
40
|
body.continuesFrom = flags.continuesFrom;
|
|
42
41
|
if (flags.forkedFrom !== undefined)
|
|
@@ -62,23 +61,23 @@ export const captureSession = async (cfg, io, content, flags, opts) => {
|
|
|
62
61
|
if (flags.from !== undefined && !/^\d+$/.test(flags.from)) {
|
|
63
62
|
return refusal(`--from must be a non-negative integer, got: ${flags.from}\n`);
|
|
64
63
|
}
|
|
65
|
-
if (
|
|
66
|
-
|
|
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
|
-
}
|
|
64
|
+
if (opts.shareWith.length > 0 && !opts.yes) {
|
|
65
|
+
return refusal(needsYesWords('session', allowWords(opts.shareWith)));
|
|
74
66
|
}
|
|
75
67
|
const accessToken = await signedIn(io, cfg);
|
|
76
68
|
if (typeof accessToken !== 'string')
|
|
77
69
|
return accessToken;
|
|
70
|
+
let shares;
|
|
71
|
+
if (opts.shareWith.length > 0 || opts.deny.length > 0) {
|
|
72
|
+
const resolved = await resolveBornShares(cfg, io, accessToken, opts.shareWith, opts.deny);
|
|
73
|
+
if (isRefusalValue(resolved))
|
|
74
|
+
return resolved;
|
|
75
|
+
shares = resolved;
|
|
76
|
+
}
|
|
78
77
|
const result = await io.api(accessToken, `${cfg.apiUrl}/memories/sessions`, {
|
|
79
78
|
method: 'POST',
|
|
80
79
|
headers: { 'Content-Type': 'application/json' },
|
|
81
|
-
body: JSON.stringify(buildCaptureSessionBody(turns, flags, opts.asNew))
|
|
80
|
+
body: JSON.stringify(buildCaptureSessionBody(turns, flags, opts.asNew, shares))
|
|
82
81
|
});
|
|
83
82
|
if (!result.ok)
|
|
84
83
|
return refusalOf(result);
|
|
@@ -87,7 +86,6 @@ export const captureSession = async (cfg, io, content, flags, opts) => {
|
|
|
87
86
|
const TRANSCRIPT_INAPPLICABLE_FLAGS = [
|
|
88
87
|
['--intent', 'intent'],
|
|
89
88
|
['--captured-at', 'capturedAt'],
|
|
90
|
-
['--share-with', 'shareWith'],
|
|
91
89
|
['--source', 'source'],
|
|
92
90
|
['--conversation-id', 'conversationId'],
|
|
93
91
|
['--extends', 'extends'],
|
|
@@ -96,13 +94,17 @@ const TRANSCRIPT_INAPPLICABLE_FLAGS = [
|
|
|
96
94
|
['--forked-from', 'forkedFrom']
|
|
97
95
|
];
|
|
98
96
|
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) => {
|
|
97
|
+
export const captureSessionTranscript = async (cfg, io, file, flags, asNew, sharesNamed = false) => {
|
|
100
98
|
for (const [name, key] of TRANSCRIPT_INAPPLICABLE_FLAGS) {
|
|
101
99
|
if (flags[key] !== undefined) {
|
|
102
100
|
return refusal(`${name} does not apply when capturing from a transcript — the chain drive ` +
|
|
103
101
|
`derives it. Only --title and --ai-summary ride this form.\n`);
|
|
104
102
|
}
|
|
105
103
|
}
|
|
104
|
+
if (sharesNamed) {
|
|
105
|
+
return refusal('--share-with and --deny do not apply when capturing from a transcript — the chain ' +
|
|
106
|
+
'drive derives it. Only --title and --ai-summary ride this form.\n');
|
|
107
|
+
}
|
|
106
108
|
if (asNew) {
|
|
107
109
|
return refusal('--as-new does not apply when capturing from a transcript — a fork is detected ' +
|
|
108
110
|
'and saved by the drive itself.\n');
|
package/dist/memory/get-help.js
CHANGED
|
@@ -7,15 +7,17 @@ export const GET_HELP = [
|
|
|
7
7
|
' history one note\u2019s history: every change, newest first, and the head fingerprint',
|
|
8
8
|
' deleted your deleted items, with the fingerprint restore and erase need',
|
|
9
9
|
' questions the questions left on your tree, open first',
|
|
10
|
+
' access may one contact, or one number, see a memory, and because of what',
|
|
10
11
|
'',
|
|
11
12
|
'Usage:',
|
|
12
|
-
' lattris get memories [--shared-with-me] [--q <words>] [--type <name>] [--intent <name>] [--with <audience>] [--limit <n>] [--cursor <token>] [--content 0|1]',
|
|
13
|
+
' lattris get memories [--shared-with-me] [--q <words>] [--type <name>] [--intent <name>] [--with <audience>] [--for <contact> | --for-phone <number>] [--limit <n>] [--cursor <token>] [--content 0|1]',
|
|
13
14
|
' lattris get memories --by <owner-handle> [--limit <n>] [--cursor <token>] [--content 0|1]',
|
|
14
15
|
' lattris get memory <website-address|id> [--by <owner-handle>] [--links 0|1] [--depth <n>] [--content 0|1]',
|
|
15
16
|
' lattris get memory <id-or-prefix> --revision <n> one revision, as history kept it',
|
|
16
17
|
' lattris get history <id-or-prefix> [--limit <n>] [--before <operation-id>]',
|
|
17
18
|
' lattris get deleted [--limit <n>] [--cursor <token>]',
|
|
18
19
|
' lattris get questions [--mine]',
|
|
20
|
+
' lattris get access <memory-id-or-prefix> --for <contact | number>',
|
|
19
21
|
'',
|
|
20
22
|
' --q <words> only memories whose title or content contains these words,',
|
|
21
23
|
' matched as one literal phrase, case-insensitive. Needs 3+',
|
|
@@ -32,6 +34,12 @@ export const GET_HELP = [
|
|
|
32
34
|
' session needs it, while a full address carries it',
|
|
33
35
|
' --shared-with-me list memories other people granted to you; each row',
|
|
34
36
|
' names its owner and whether it is on your graph',
|
|
37
|
+
' --for <contact> what one of your contacts may see of your memories: an',
|
|
38
|
+
' exact name or id from `lattris contact list`. The rows',
|
|
39
|
+
' carry what they would see; the first line names them',
|
|
40
|
+
' --for-phone <number> the same for a phone number in full international',
|
|
41
|
+
' form; a number no contact holds is a stranger, who',
|
|
42
|
+
' sees public memories only',
|
|
35
43
|
'',
|
|
36
44
|
' --links 0|1 edges on the one memory (default 1): direction, verb,',
|
|
37
45
|
' reason, and each neighbour\u2019s finding aid. --links 0',
|
|
@@ -53,6 +61,7 @@ export const GET_HELP = [
|
|
|
53
61
|
'Every read of a note prints its fingerprint (the head of its history) and',
|
|
54
62
|
'revisionNo; every read prints structureHash, a hash of the block\u2019s links and',
|
|
55
63
|
'grants as this reader sees them. An edit sends the fingerprint back as --base-fingerprint.',
|
|
64
|
+
'Your own read of a memory also prints `access`: every allow and deny on it.',
|
|
56
65
|
'',
|
|
57
66
|
"A memory's web address is <site>/by/<owner-handle>/memories/<slug>-<memory-handle>.",
|
|
58
67
|
'A /by read asks for JSON with Accept: application/json; it sends a bearer',
|