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 CHANGED
@@ -1,4 +1 @@
1
- {
2
- "commit": "8b66c9fde9fb6cd5cf716c08fa0e1ed1234c0651",
3
- "builtAt": "2026-09-27T23:55:25.807Z"
4
- }
1
+ {"commit":"6045dca8f744658deeee2c8dfd61fe1aed6a619b","builtAt":"2026-10-03T04:07:32Z"}
@@ -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 (steps.contact && selector !== undefined && !wantsPublic) {
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 result = await io.api(accessToken, url, body === undefined
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) {
@@ -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
- const template = form.request.pathWithPublic && context.contact === undefined
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
- return template
107
- .replace(/\{(--[a-z-]+):([^}]*)\}/g, (_m, name, text) => typed.on.has(name) ? text : '')
108
- .replace(/\{p(\d+)\}/g, (_m, i) => encodeURIComponent(typed.positionals[Number(i)] ?? ''))
109
- .replace('{memory.id}', encodeURIComponent(context.memory?.id ?? typed.positionals[0] ?? ''))
110
- .replace('{contact.claimedPersonId}', () => encodeURIComponent(String(context.contact?.claimedPersonId ?? '')));
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 const resolveContact = (contacts, selector) => {
124
- if (isUuidKey(selector)) {
125
- const contact = contacts.find((c) => c.id.toLowerCase() === selector.toLowerCase());
126
- return contact ? { kind: 'found', contact } : { kind: 'missing-id' };
127
- }
128
- const folded = selector.toLowerCase();
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
- export const buildCreateNoteBody = (content, flags, allowBannedWords = false) => {
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
- if (flags.shareWith !== undefined)
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 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.
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 public optional; public from birth — requires --yes, same law
177
- as a note
178
- --yes confirms save AND publish together
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, SHARE_AUDIENCES, buildCreateNoteBody, formatSaved, retryWords } from "../memory/capture-common.js";
4
+ import { CAPTURE_MEMORY_FLAG_NAMES, SESSION_ONLY_FLAGS, allowWords, buildCreateNoteBody, checkShareWords, formatSaved, isRefusalValue, needsYesWords, resolveBornShares, retryWords, takeShareFlags } from "../memory/capture-common.js";
5
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 { flags, positionals, unknown } = parseArgs(CAPTURE_MEMORY_FLAG_NAMES, args.filter((a) => a !== '--yes' && a !== '--as-new' && a !== '--allow-banned-words'));
37
+ const taken = takeShareFlags(args.filter((a) => a !== '--yes' && a !== '--as-new' && a !== '--allow-banned-words'));
38
+ const { flags, positionals, unknown } = parseArgs(CAPTURE_MEMORY_FLAG_NAMES, taken.rest);
39
+ const sharesNamed = taken.shareWith.length > 0 || taken.deny.length > 0;
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, { yes, asNew });
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 (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
- }
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
- const createBody = buildCreateNoteBody(content, flags, allowBanned);
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 { SHARE_AUDIENCES } from "../memory/capture-common.js";
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
- if (flags.shareWith !== undefined)
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 (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
- }
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');
@@ -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',