@pellux/goodvibes-tui 1.24.2 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/bin/goodvibes-daemon +24 -0
- package/docs/foundation-artifacts/operator-contract.json +18234 -12253
- package/package.json +8 -7
- package/src/cli/help.ts +10 -0
- package/src/cli/redaction.ts +68 -3
- package/src/config/goodvibes-home.ts +149 -0
- package/src/config/secret-config.ts +64 -8
- package/src/core/turn-event-wiring.ts +7 -16
- package/src/core/workstream-notification.ts +69 -0
- package/src/daemon/cli.ts +62 -7
- package/src/daemon/handlers/credentials.ts +15 -5
- package/src/daemon/handlers/index.ts +7 -6
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +332 -0
- package/src/daemon/send/composition.ts +99 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/input/commands/calendar-runtime.ts +245 -0
- package/src/input/commands/config.ts +68 -2
- package/src/input/commands/connection-status.ts +258 -0
- package/src/input/commands/local-provider-runtime.ts +9 -1
- package/src/input/commands/mail-runtime.ts +267 -0
- package/src/input/commands/operator-runtime.ts +6 -1
- package/src/input/commands/payment-card-intake.ts +200 -0
- package/src/input/commands/profile-render.ts +250 -0
- package/src/input/commands/profile-runtime.ts +362 -0
- package/src/input/commands/profile-types.ts +381 -0
- package/src/input/commands/review-runtime.ts +13 -1
- package/src/input/commands.ts +11 -0
- package/src/input/composer-editor.ts +4 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +13 -1
- package/src/input/payments-config.ts +167 -0
- package/src/input/provider-key-intake.ts +10 -2
- package/src/input/settings-modal-commit.ts +146 -0
- package/src/input/settings-modal-connections.ts +102 -0
- package/src/input/settings-modal-data.ts +30 -0
- package/src/input/settings-modal-reset.ts +6 -2
- package/src/input/settings-modal-secrets.ts +9 -2
- package/src/input/settings-modal-types.ts +19 -3
- package/src/input/settings-modal.ts +54 -107
- package/src/main.ts +7 -2
- package/src/panels/modals/marketplace-modal.ts +5 -0
- package/src/panels/modals/skills-modal.ts +5 -0
- package/src/renderer/settings-modal-connections.ts +63 -0
- package/src/renderer/settings-modal-helpers.ts +6 -0
- package/src/renderer/settings-modal.ts +21 -1
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/bootstrap.ts +2 -2
- package/src/runtime/context.ts +9 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +0 -4
- package/src/runtime/install-self-check-startup.ts +23 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/onboarding/types.ts +6 -1
- package/src/runtime/path-shadow-startup.ts +243 -0
- package/src/runtime/path-shadow.ts +418 -0
- package/src/runtime/reachability-notice.ts +100 -0
- package/src/runtime/runtime-services-types.ts +243 -0
- package/src/runtime/services.ts +30 -224
- package/src/shell/ui-openers.ts +7 -0
- package/src/verification/verification-ledger.ts +158 -0
- package/src/version.ts +1 -1
- package/src/daemon/handlers/calendar/caldav-client.ts +0 -662
- package/src/daemon/handlers/calendar/ics.ts +0 -556
- package/src/daemon/handlers/calendar/index.ts +0 -316
- package/src/daemon/handlers/email/config.ts +0 -164
- package/src/daemon/handlers/email/imap-connector.ts +0 -441
- package/src/daemon/handlers/email/imap-parsing.ts +0 -499
- package/src/daemon/handlers/email/index.ts +0 -43
- package/src/daemon/handlers/email/read-handlers.ts +0 -80
- package/src/daemon/handlers/email/runtime.ts +0 -140
- package/src/daemon/handlers/email/smtp-connector.ts +0 -557
- package/src/daemon/handlers/email/validation.ts +0 -147
- package/src/daemon/handlers/email/write-handlers.ts +0 -133
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile-render.ts
|
|
3
|
+
*
|
|
4
|
+
* How `/profile` puts the owner profile on screen. Every function here is pure:
|
|
5
|
+
* it takes a checked response (profile-types.ts) and returns a string. Nothing
|
|
6
|
+
* in this file reads a file, opens a connection, or writes to a log — which is
|
|
7
|
+
* what makes the containment rule in docs/owner-profile.md §10/§11.3 testable
|
|
8
|
+
* rather than a claim: a profile value can only leave through the string these
|
|
9
|
+
* functions return, and the only thing that consumes that string is `ctx.print`.
|
|
10
|
+
*
|
|
11
|
+
* Three shapes matter, one per question §8.3 says every surface must answer:
|
|
12
|
+
*
|
|
13
|
+
* "what do you know about me?" → renderProfileDocument
|
|
14
|
+
* "where did you get that?" → renderProfileProvenance
|
|
15
|
+
* "forget that" → renderProfileWriteResult
|
|
16
|
+
*
|
|
17
|
+
* Two rules the renderers hold to:
|
|
18
|
+
*
|
|
19
|
+
* - **A write never echoes the value.** `renderProfileWriteResult` prints the
|
|
20
|
+
* daemon's own one-line disclosure ("Noted — saved your office address to
|
|
21
|
+
* your profile.") and the field names that changed. The value the owner just
|
|
22
|
+
* set is deliberately absent: repeating it puts a closed-tier string into the
|
|
23
|
+
* transcript for no benefit (§8.2).
|
|
24
|
+
* - **A refusal is never dressed up as a success.** `ok: false` prints the
|
|
25
|
+
* daemon's reason verbatim, including "your profile has no shipping address
|
|
26
|
+
* recorded, so there was nothing to forget" (§9.2). There is no path here
|
|
27
|
+
* that turns an empty change list into a confirmation.
|
|
28
|
+
*/
|
|
29
|
+
import type {
|
|
30
|
+
ProfileDocumentView,
|
|
31
|
+
ProfileFieldView,
|
|
32
|
+
ProfileLineView,
|
|
33
|
+
ProfileProvenanceReportView,
|
|
34
|
+
ProfileProvenanceView,
|
|
35
|
+
ProfileSectionView,
|
|
36
|
+
ProfileStateView,
|
|
37
|
+
ProfileWriteResultView,
|
|
38
|
+
} from './profile-types.ts';
|
|
39
|
+
|
|
40
|
+
/** The prefix every line of this command's output carries. */
|
|
41
|
+
export const PROFILE_TAG = '[profile]';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The load state as a sentence, or `null` when the profile is loaded and has
|
|
45
|
+
* content to show. `unavailable` and `disabled` are stated conditions, never an
|
|
46
|
+
* empty profile that would read as "I know nothing about you" (§4.4).
|
|
47
|
+
*/
|
|
48
|
+
export function describeProfileState(state: ProfileStateView): string | null {
|
|
49
|
+
if (state.kind === 'disabled') {
|
|
50
|
+
return `The owner profile is turned off (profile.enabled = false). Turn it on in /settings under "Owner Profile", then run /profile again.\n file: ${state.path}`;
|
|
51
|
+
}
|
|
52
|
+
if (state.kind === 'unavailable') {
|
|
53
|
+
return `Your profile could not be read: ${state.reason ?? 'no reason given'}\n file: ${state.path}`;
|
|
54
|
+
}
|
|
55
|
+
if (state.exists === false) {
|
|
56
|
+
return `Your profile is empty — nothing has been recorded yet.\n file: ${state.path}`;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function padTo(text: string, width: number): string {
|
|
62
|
+
return text.length >= width ? text : text + ' '.repeat(width - text.length);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One field row: the label as written in the file, the value, and the field id
|
|
67
|
+
* so `/profile where <id>` and `/profile forget <id>` are copy-pasteable.
|
|
68
|
+
*
|
|
69
|
+
* An invalid value is shown verbatim with its reason rather than hidden — the
|
|
70
|
+
* owner typed it, and a parser that disliked it is not grounds for pretending it
|
|
71
|
+
* is not there (§4.3).
|
|
72
|
+
*/
|
|
73
|
+
function renderField(field: ProfileFieldView, labelWidth: number): string {
|
|
74
|
+
const validity = field.valid ? '' : ` (not valid: ${field.invalidReason ?? 'no reason given'})`;
|
|
75
|
+
return ` ${padTo(field.label, labelWidth)} ${field.value}${validity} [${field.fieldId}]`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function renderProse(line: ProfileLineView): string {
|
|
79
|
+
return ` ${line.text}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderSection(section: ProfileSectionView): string {
|
|
83
|
+
const labelWidth = section.fields.reduce((width, field) => Math.max(width, field.label.length), 0);
|
|
84
|
+
const lines = [section.heading];
|
|
85
|
+
for (const field of section.fields) lines.push(renderField(field, labelWidth));
|
|
86
|
+
for (const line of section.prose) lines.push(renderProse(line));
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* "What do you know about me?" — the whole document, by section, in the order
|
|
92
|
+
* the file has it. Sections the owner added himself render exactly like the
|
|
93
|
+
* canonical ones, because his headings are as real as the built-in ones (§4.5).
|
|
94
|
+
*/
|
|
95
|
+
export function renderProfileDocument(document: ProfileDocumentView): string {
|
|
96
|
+
const stateLine = describeProfileState(document.state);
|
|
97
|
+
if (stateLine !== null) return `${PROFILE_TAG} ${stateLine}`;
|
|
98
|
+
|
|
99
|
+
const populated = document.sections.filter(
|
|
100
|
+
(section) => section.fields.length > 0 || section.prose.length > 0,
|
|
101
|
+
);
|
|
102
|
+
if (populated.length === 0) {
|
|
103
|
+
return `${PROFILE_TAG} Your profile has no recorded content yet.\n file: ${document.state.path}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const header = [
|
|
107
|
+
`${PROFILE_TAG} What I know about you`,
|
|
108
|
+
` file: ${document.state.path}`,
|
|
109
|
+
];
|
|
110
|
+
return [...header, '', ...populated.map(renderSection).flatMap((block) => [block, ''])]
|
|
111
|
+
.join('\n')
|
|
112
|
+
.trimEnd();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderProvenanceDetail(provenance: ProfileProvenanceView, indent: string): string[] {
|
|
116
|
+
return [
|
|
117
|
+
`${indent}recorded by ${provenance.surface} on ${provenance.date}`,
|
|
118
|
+
`${indent}you said: "${provenance.said}"`,
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* "Where did you get that?" — the surface, the date and the owner's verbatim
|
|
124
|
+
* words, plus every superseded predecessor still retained as a history comment.
|
|
125
|
+
*
|
|
126
|
+
* A field with no suffix reports that he wrote it by hand. That is the honest
|
|
127
|
+
* answer and it is deliberately not softened into "source unknown", because a
|
|
128
|
+
* hand-edited line has a known source: him (§4.2).
|
|
129
|
+
*/
|
|
130
|
+
export function renderProfileProvenance(report: ProfileProvenanceReportView): string {
|
|
131
|
+
const lines = [`${PROFILE_TAG} Where ${report.fieldId} came from`];
|
|
132
|
+
|
|
133
|
+
if (!report.present && report.superseded.length === 0) {
|
|
134
|
+
return `${PROFILE_TAG} Your profile has no ${report.fieldId} recorded, so there is nothing to trace.`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!report.present) {
|
|
138
|
+
lines.push(' not currently recorded — only superseded history remains:');
|
|
139
|
+
} else if (report.handEdited) {
|
|
140
|
+
lines.push(' no provenance recorded — you wrote or edited this line by hand.');
|
|
141
|
+
} else if (report.provenance) {
|
|
142
|
+
lines.push(...renderProvenanceDetail(report.provenance, ' '));
|
|
143
|
+
} else {
|
|
144
|
+
lines.push(' no provenance recorded.');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (report.superseded.length > 0) {
|
|
148
|
+
lines.push('', ` superseded before this (${report.superseded.length}, oldest first):`);
|
|
149
|
+
for (const previous of report.superseded) {
|
|
150
|
+
lines.push(` ${previous.value} (replaced ${previous.supersededOn})`);
|
|
151
|
+
if (previous.provenance) lines.push(...renderProvenanceDetail(previous.provenance, ' '));
|
|
152
|
+
else lines.push(' no provenance recorded — hand-written.');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return lines.join('\n');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Field names only. A receipt names what changed; it never repeats the value. */
|
|
159
|
+
function renderChangeNames(result: ProfileWriteResultView): string[] {
|
|
160
|
+
return result.changes.map((change) => {
|
|
161
|
+
const what = change.fieldId ?? `a note under ${change.section}`;
|
|
162
|
+
const superseded = change.superseded ? ' (previous value kept as history)' : '';
|
|
163
|
+
return ` ${change.kind}: ${what}${superseded}`;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* What a write actually did.
|
|
169
|
+
*
|
|
170
|
+
* `ok: false` prints the daemon's reason and nothing else — no "done", no
|
|
171
|
+
* change list, no disclosure. That covers the case §9.2 names explicitly:
|
|
172
|
+
* forgetting something that was not there reports that it was not there.
|
|
173
|
+
*
|
|
174
|
+
* `ok: true` with an empty change list is also not a success line. The daemon
|
|
175
|
+
* returns an empty disclosure when nothing changed, and inventing "Noted —"
|
|
176
|
+
* over the top of it would be the false receipt the design forbids.
|
|
177
|
+
*/
|
|
178
|
+
export function renderProfileWriteResult(result: ProfileWriteResultView): string {
|
|
179
|
+
if (!result.ok) {
|
|
180
|
+
return `${PROFILE_TAG} ${result.reason ?? 'the write was refused, and the daemon gave no reason.'}`;
|
|
181
|
+
}
|
|
182
|
+
if (result.changes.length === 0) {
|
|
183
|
+
return `${PROFILE_TAG} nothing changed${result.reason ? ` — ${result.reason}` : '.'}`;
|
|
184
|
+
}
|
|
185
|
+
const disclosure = result.disclosure.trim();
|
|
186
|
+
const lines = [disclosure.length > 0 ? `${PROFILE_TAG} ${disclosure}` : `${PROFILE_TAG} done.`];
|
|
187
|
+
lines.push(...renderChangeNames(result));
|
|
188
|
+
return lines.join('\n');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The diagnostic view: load state, path, section names, counts, and every
|
|
193
|
+
* mechanical value that did not validate WITH its reason.
|
|
194
|
+
*
|
|
195
|
+
* No values appear here, and none can: `ProfileStateView` has no property that
|
|
196
|
+
* carries one. That is what makes this output safe to paste into a bug report
|
|
197
|
+
* (§11.3), and profile-runtime.test.ts asserts it against a populated profile.
|
|
198
|
+
*/
|
|
199
|
+
export function renderProfileStatus(state: ProfileStateView): string {
|
|
200
|
+
const lines = [`${PROFILE_TAG} Profile status: ${state.kind}`, ` file: ${state.path}`];
|
|
201
|
+
if (state.kind === 'unavailable') {
|
|
202
|
+
lines.push(` reason: ${state.reason ?? 'no reason given'}`);
|
|
203
|
+
return lines.join('\n');
|
|
204
|
+
}
|
|
205
|
+
if (state.kind === 'disabled') {
|
|
206
|
+
lines.push(' reason: profile.enabled is false, so the file is never opened.');
|
|
207
|
+
return lines.join('\n');
|
|
208
|
+
}
|
|
209
|
+
lines.push(` file exists: ${state.exists === true ? 'yes' : 'no'}`);
|
|
210
|
+
lines.push(` lines: ${state.lineCount ?? 0} · fields: ${state.fieldCount ?? 0} · notes: ${state.proseLineCount ?? 0}`);
|
|
211
|
+
const sections = state.sections ?? [];
|
|
212
|
+
lines.push(` sections (${sections.length}): ${sections.length > 0 ? sections.join(', ') : '(none)'}`);
|
|
213
|
+
const invalid = state.invalidFields ?? [];
|
|
214
|
+
if (invalid.length === 0) {
|
|
215
|
+
lines.push(' fields that did not validate: none');
|
|
216
|
+
} else {
|
|
217
|
+
lines.push(` fields that did not validate (${invalid.length}) — the value is kept as you typed it:`);
|
|
218
|
+
for (const entry of invalid) lines.push(` ${entry.fieldId}: ${entry.reason}`);
|
|
219
|
+
}
|
|
220
|
+
return lines.join('\n');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Every field id present in the document, paired with the label written on its
|
|
225
|
+
* line. Used to resolve `/profile where shipping address` to
|
|
226
|
+
* `commerce.shippingAddress` without keeping a copy of the SDK's field registry
|
|
227
|
+
* here — the mapping comes from the live response, so it cannot drift.
|
|
228
|
+
*/
|
|
229
|
+
export function collectFieldLabels(document: ProfileDocumentView): ReadonlyMap<string, string[]> {
|
|
230
|
+
const byLabel = new Map<string, string[]>();
|
|
231
|
+
for (const section of document.sections) {
|
|
232
|
+
for (const field of section.fields) {
|
|
233
|
+
for (const key of [field.label, field.fieldId]) {
|
|
234
|
+
const normalized = normalizeFieldToken(key);
|
|
235
|
+
const existing = byLabel.get(normalized);
|
|
236
|
+
if (existing) {
|
|
237
|
+
if (!existing.includes(field.fieldId)) existing.push(field.fieldId);
|
|
238
|
+
} else {
|
|
239
|
+
byLabel.set(normalized, [field.fieldId]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return byLabel;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Field names match case-insensitively with whitespace collapsed (§4.3). */
|
|
248
|
+
export function normalizeFieldToken(value: string): string {
|
|
249
|
+
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
250
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile-runtime.ts
|
|
3
|
+
*
|
|
4
|
+
* `/profile` — the owner profile in the TUI: what the platform knows about the
|
|
5
|
+
* person who owns it, kept as one hand-editable Markdown file at daemon scope
|
|
6
|
+
* (docs/owner-profile.md).
|
|
7
|
+
*
|
|
8
|
+
* The subcommands exist to make §8.3's three questions answerable here, not just
|
|
9
|
+
* in the agent:
|
|
10
|
+
*
|
|
11
|
+
* /profile → profile.read "what do you know about me?"
|
|
12
|
+
* /profile where → profile.provenance "where did you get that?"
|
|
13
|
+
* /profile forget → profile.forget "forget that"
|
|
14
|
+
*
|
|
15
|
+
* plus `set`, `note`, `undo` and `status` so a correction, a note, a recovery
|
|
16
|
+
* and a diagnosis do not require leaving the terminal or hand-editing the file
|
|
17
|
+
* (which remains allowed, and authoritative — §4.5).
|
|
18
|
+
*
|
|
19
|
+
* ## Why it goes over the operator wire
|
|
20
|
+
*
|
|
21
|
+
* Same reason `/principals`, `/ci` and `/checkin` do (see operator-rpc.ts): the
|
|
22
|
+
* `profile.*` family ships in the operator contract and has not been promoted to
|
|
23
|
+
* the in-process `OperatorClient` facade. Surfaces never open the profile file —
|
|
24
|
+
* the daemon is the single writer, which is what makes its rename-based atomic
|
|
25
|
+
* writes sufficient with no lock (§3, §5.4). This command therefore has no file
|
|
26
|
+
* path, no parser and no writer of its own, and could not corrupt the document
|
|
27
|
+
* if it tried.
|
|
28
|
+
*
|
|
29
|
+
* ## Containment
|
|
30
|
+
*
|
|
31
|
+
* Values reach exactly one place: the string handed to `ctx.print`. This module
|
|
32
|
+
* imports no logger, builds no diagnostic payload, and never puts a value in an
|
|
33
|
+
* error message — a failed call renders `describeOperatorRpcError`, which
|
|
34
|
+
* describes the transport, not the content. A write prints the daemon's
|
|
35
|
+
* one-line disclosure and the field names that changed, never the value that was
|
|
36
|
+
* just recorded (§8.2). The `People` section is third-party personal data and is
|
|
37
|
+
* shown only in `/profile show`, i.e. only when the owner asked this surface,
|
|
38
|
+
* this turn, what it knows about him.
|
|
39
|
+
*/
|
|
40
|
+
import type { CommandContext, CommandRegistry } from '../command-registry.ts';
|
|
41
|
+
import { describeOperatorRpcError, getOperatorRpc, type OperatorRpc } from './operator-rpc.ts';
|
|
42
|
+
import {
|
|
43
|
+
MALFORMED,
|
|
44
|
+
toProfileDocument,
|
|
45
|
+
toProfileProvenanceReport,
|
|
46
|
+
toProfileState,
|
|
47
|
+
toProfileWriteResult,
|
|
48
|
+
type ExactProfileInput,
|
|
49
|
+
type ProfileInput,
|
|
50
|
+
type ProfileVerb,
|
|
51
|
+
type ProfileWriteVerb,
|
|
52
|
+
} from './profile-types.ts';
|
|
53
|
+
import {
|
|
54
|
+
PROFILE_TAG,
|
|
55
|
+
collectFieldLabels,
|
|
56
|
+
normalizeFieldToken,
|
|
57
|
+
renderProfileDocument,
|
|
58
|
+
renderProfileProvenance,
|
|
59
|
+
renderProfileStatus,
|
|
60
|
+
renderProfileWriteResult,
|
|
61
|
+
} from './profile-render.ts';
|
|
62
|
+
|
|
63
|
+
/** Which surface is recording a line. Named in the provenance suffix. */
|
|
64
|
+
const TUI_SURFACE = 'tui';
|
|
65
|
+
|
|
66
|
+
const SUBCOMMANDS = ['show', 'where', 'set', 'note', 'forget', 'undo', 'status'] as const;
|
|
67
|
+
|
|
68
|
+
const USAGE = [
|
|
69
|
+
'Usage: /profile <subcommand>',
|
|
70
|
+
' /profile [show] — what the platform knows about you, by section',
|
|
71
|
+
' /profile where <field> — where that came from: surface, date, your exact words',
|
|
72
|
+
' /profile set <field> <value> — record or correct one field',
|
|
73
|
+
' /profile note [--section <name>] <text> — add a note (Notes unless you name a section)',
|
|
74
|
+
' /profile forget <field> — delete a field and every retained predecessor',
|
|
75
|
+
' /profile forget --section <name> <text> — delete a note, named by its text',
|
|
76
|
+
' /profile undo <field> — put a field\'s most recent superseded value back',
|
|
77
|
+
' /profile status — whether it loaded, from where, what did not validate',
|
|
78
|
+
'',
|
|
79
|
+
' <field> is a field id such as commerce.shippingAddress, or the label as written',
|
|
80
|
+
' in the file (e.g. "shipping address") once that field is recorded. /profile show',
|
|
81
|
+
' prints each field id beside its value.',
|
|
82
|
+
'',
|
|
83
|
+
' Notes in People, Places, Work and Notes have no field id — forget those with',
|
|
84
|
+
' --section and the line as /profile show prints it. The leading "-" is optional;',
|
|
85
|
+
' the rest must match, and if two notes read the same nothing is removed.',
|
|
86
|
+
].join('\n');
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Invoke a `profile.*` verb.
|
|
90
|
+
*
|
|
91
|
+
* The verb id and its input ARE checked against the generated contract, so a
|
|
92
|
+
* misspelled field name or a missing required argument is a compile error here
|
|
93
|
+
* rather than a 400 discovered at runtime.
|
|
94
|
+
*
|
|
95
|
+
* The RESULT is deliberately `unknown`. Widening the method id to `string` at
|
|
96
|
+
* the call site selects the client's generic
|
|
97
|
+
* `invoke<T = unknown>(methodId: string, …)` overload rather than the typed
|
|
98
|
+
* one, because the typed overload's return type is a claim about the wire that
|
|
99
|
+
* nothing enforces: only 5 of the contract's 452 method ids carry a Zod
|
|
100
|
+
* response schema and no `profile.*` verb is one of them (see
|
|
101
|
+
* profile-types.ts). Every response goes through a checker before a renderer
|
|
102
|
+
* reads a property off it.
|
|
103
|
+
*/
|
|
104
|
+
type ProfileInvoke = <TVerb extends ProfileVerb, TBody extends ProfileInput<TVerb>>(
|
|
105
|
+
methodId: TVerb,
|
|
106
|
+
input: ExactProfileInput<TVerb, TBody>,
|
|
107
|
+
) => Promise<unknown>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The rpc resolver, injectable so tests can drive the full command against a
|
|
111
|
+
* scripted daemon instead of a live one. Production always uses
|
|
112
|
+
* {@link getOperatorRpc}.
|
|
113
|
+
*/
|
|
114
|
+
export interface ProfileCommandDeps {
|
|
115
|
+
readonly resolveRpc: (context: CommandContext) => OperatorRpc;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const DEFAULT_DEPS: ProfileCommandDeps = { resolveRpc: getOperatorRpc };
|
|
119
|
+
|
|
120
|
+
function unrecognizedResponse(verb: string): string {
|
|
121
|
+
return `${PROFILE_TAG} the daemon answered ${verb} with a response this build does not recognise — it is probably running a different platform version. Nothing was read or written.`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve what the owner typed to a field id.
|
|
126
|
+
*
|
|
127
|
+
* A dotted token is already an id and is passed through untouched. A bare label
|
|
128
|
+
* ("shipping address") is matched against the labels in the live `profile.read`
|
|
129
|
+
* response — deliberately NOT against a copy of the SDK's field registry, which
|
|
130
|
+
* would be a second list to keep in step with the first. A label that matches
|
|
131
|
+
* nothing is passed through unchanged so the daemon answers with its own message
|
|
132
|
+
* naming what is wrong, rather than this command guessing.
|
|
133
|
+
*/
|
|
134
|
+
async function resolveFieldToken(
|
|
135
|
+
invoke: ProfileInvoke,
|
|
136
|
+
token: string,
|
|
137
|
+
print: (text: string) => void,
|
|
138
|
+
): Promise<string | null> {
|
|
139
|
+
if (token.includes('.')) return token;
|
|
140
|
+
let raw: unknown;
|
|
141
|
+
try {
|
|
142
|
+
raw = await invoke('profile.read', {});
|
|
143
|
+
} catch {
|
|
144
|
+
return token;
|
|
145
|
+
}
|
|
146
|
+
const document = toProfileDocument(raw);
|
|
147
|
+
if (document === MALFORMED) return token;
|
|
148
|
+
const matches = collectFieldLabels(document).get(normalizeFieldToken(token));
|
|
149
|
+
if (!matches || matches.length === 0) return token;
|
|
150
|
+
if (matches.length === 1) return matches[0] ?? token;
|
|
151
|
+
print(`${PROFILE_TAG} "${token}" matches more than one field: ${matches.join(', ')}. Use the full field id.`);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function runShow(invoke: ProfileInvoke, print: (text: string) => void): Promise<void> {
|
|
156
|
+
try {
|
|
157
|
+
const document = toProfileDocument(await invoke('profile.read', {}));
|
|
158
|
+
print(document === MALFORMED ? unrecognizedResponse('profile.read') : renderProfileDocument(document));
|
|
159
|
+
} catch (error) {
|
|
160
|
+
print(`${PROFILE_TAG} ${describeOperatorRpcError(error)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function runStatus(invoke: ProfileInvoke, print: (text: string) => void): Promise<void> {
|
|
165
|
+
try {
|
|
166
|
+
const state = toProfileState(await invoke('profile.status', {}));
|
|
167
|
+
print(state === MALFORMED ? unrecognizedResponse('profile.status') : renderProfileStatus(state));
|
|
168
|
+
} catch (error) {
|
|
169
|
+
print(`${PROFILE_TAG} ${describeOperatorRpcError(error)}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function runWhere(invoke: ProfileInvoke, token: string, print: (text: string) => void): Promise<void> {
|
|
174
|
+
const fieldId = await resolveFieldToken(invoke, token, print);
|
|
175
|
+
if (fieldId === null) return;
|
|
176
|
+
try {
|
|
177
|
+
const report = toProfileProvenanceReport(await invoke('profile.provenance', { fieldId }));
|
|
178
|
+
print(report === MALFORMED ? unrecognizedResponse('profile.provenance') : renderProfileProvenance(report));
|
|
179
|
+
} catch (error) {
|
|
180
|
+
print(`${PROFILE_TAG} ${describeOperatorRpcError(error)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Run one write verb and render what it actually did.
|
|
186
|
+
*
|
|
187
|
+
* The response is the only source of the outcome line: a refusal prints the
|
|
188
|
+
* daemon's reason and a no-op prints "nothing changed", so a `/profile forget`
|
|
189
|
+
* for something that was not recorded can never come back as a success (§9.2).
|
|
190
|
+
*/
|
|
191
|
+
async function runWrite<TVerb extends ProfileWriteVerb, TBody extends ProfileInput<TVerb>>(
|
|
192
|
+
invoke: ProfileInvoke,
|
|
193
|
+
verb: TVerb,
|
|
194
|
+
input: ExactProfileInput<TVerb, TBody>,
|
|
195
|
+
print: (text: string) => void,
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
try {
|
|
198
|
+
const result = toProfileWriteResult(await invoke(verb, input));
|
|
199
|
+
print(result === MALFORMED ? unrecognizedResponse(verb) : renderProfileWriteResult(result));
|
|
200
|
+
} catch (error) {
|
|
201
|
+
print(`${PROFILE_TAG} ${describeOperatorRpcError(error)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* `--section <name>` followed by free text, shared by `/profile note` and
|
|
207
|
+
* `/profile forget`. Section defaults to Notes, which is only meaningful for
|
|
208
|
+
* `note` — `forget` calls this solely when `--section` was given.
|
|
209
|
+
*/
|
|
210
|
+
function parseSectionArgs(args: readonly string[]): { section: string; text: string } {
|
|
211
|
+
if (args[1] === '--section' && typeof args[2] === 'string' && args[2].length > 0) {
|
|
212
|
+
return { section: args[2], text: args.slice(3).join(' ').trim() };
|
|
213
|
+
}
|
|
214
|
+
return { section: 'Notes', text: args.slice(1).join(' ').trim() };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function registerProfileRuntimeCommands(
|
|
218
|
+
registry: CommandRegistry,
|
|
219
|
+
deps: ProfileCommandDeps = DEFAULT_DEPS,
|
|
220
|
+
): void {
|
|
221
|
+
registry.register({
|
|
222
|
+
name: 'profile',
|
|
223
|
+
description: 'What the platform knows about you: read it, correct it, trace where a fact came from, or forget one',
|
|
224
|
+
usage: '[show|where <field>|set <field> <value>|note [--section <name>] <text>|forget <field>|forget --section <name> <text>|undo <field>|status]',
|
|
225
|
+
argsHint: '[show|where|set|note|forget|undo|status]',
|
|
226
|
+
async handler(args, ctx) {
|
|
227
|
+
const sub = args[0] ?? 'show';
|
|
228
|
+
if (!(SUBCOMMANDS as readonly string[]).includes(sub)) {
|
|
229
|
+
ctx.print(USAGE);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Validate each subcommand's arguments before touching the connection, so
|
|
234
|
+
// a usage mistake never depends on daemon reachability to be reported.
|
|
235
|
+
//
|
|
236
|
+
// where/forget/undo take the whole remainder as the field, so a label
|
|
237
|
+
// written with a space ("shipping address") works unquoted. `set` cannot
|
|
238
|
+
// do that — everything after the first token is the value — so a label
|
|
239
|
+
// there must be one word, or the field id.
|
|
240
|
+
const fieldToken = sub === 'set' ? args[1] : args.slice(1).join(' ').trim();
|
|
241
|
+
|
|
242
|
+
// `/profile forget --section <name> <text>` removes a prose bullet, which
|
|
243
|
+
// is the only way to forget a line in People, Places, Work or Notes —
|
|
244
|
+
// those sections have no mechanical fields at all (§4.3), so without this
|
|
245
|
+
// "forget that" would answer for a shipping address but not for a note
|
|
246
|
+
// about a person. The line is named by its exact text, never by position:
|
|
247
|
+
// the owner edits this file himself, so an index taken from an earlier
|
|
248
|
+
// read may address a different line by the time the delete lands (§9.2).
|
|
249
|
+
const forgetProse = sub === 'forget' && args[1] === '--section' ? parseSectionArgs(args) : null;
|
|
250
|
+
if (forgetProse && forgetProse.text.length === 0) {
|
|
251
|
+
ctx.print('Usage: /profile forget --section <name> <the exact text of the line>');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if ((sub === 'where' || sub === 'undo' || (sub === 'forget' && !forgetProse)) && !fieldToken) {
|
|
255
|
+
ctx.print(`Usage: /profile ${sub} <field>`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const setValue = sub === 'set' ? args.slice(2).join(' ').trim() : '';
|
|
259
|
+
if (sub === 'set' && (!fieldToken || setValue.length === 0)) {
|
|
260
|
+
ctx.print('Usage: /profile set <field> <value>');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const note = sub === 'note' ? parseSectionArgs(args) : null;
|
|
264
|
+
if (note && note.text.length === 0) {
|
|
265
|
+
ctx.print('Usage: /profile note [--section <name>] <text>');
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const rpc = deps.resolveRpc(ctx);
|
|
270
|
+
if (!rpc.available) {
|
|
271
|
+
ctx.print(`${PROFILE_TAG} ${rpc.reason}`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// `methodId as string` is what picks the generic overload, so the result
|
|
275
|
+
// stays `unknown` and has to go through a checker — see ProfileInvoke.
|
|
276
|
+
const invoke: ProfileInvoke = (methodId, input) =>
|
|
277
|
+
rpc.sdk.operator.invoke(methodId as string, input as unknown as Record<string, unknown>);
|
|
278
|
+
const print = (text: string): void => { ctx.print(text); };
|
|
279
|
+
|
|
280
|
+
if (sub === 'show') {
|
|
281
|
+
await runShow(invoke, print);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (sub === 'status') {
|
|
285
|
+
await runStatus(invoke, print);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (sub === 'where') {
|
|
289
|
+
await runWhere(invoke, fieldToken!, print);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (sub === 'set') {
|
|
294
|
+
const fieldId = await resolveFieldToken(invoke, fieldToken!, print);
|
|
295
|
+
if (fieldId === null) return;
|
|
296
|
+
// `said` is the command as typed. It is a verbatim owner utterance,
|
|
297
|
+
// which is what layer 3 of the trust gate requires (§7) and what makes
|
|
298
|
+
// "where did you get that" answer with something he recognises.
|
|
299
|
+
//
|
|
300
|
+
// `authority: 'owner-direct'` is claimed here, not defaulted by the
|
|
301
|
+
// daemon: every call in this handler runs because the owner typed a
|
|
302
|
+
// `/profile` command into this terminal on his own machine, which is
|
|
303
|
+
// exactly what the owner-direct tier means (docs/owner-profile.md
|
|
304
|
+
// §7). That is the whole justification for the claim, so it is
|
|
305
|
+
// written out once here rather than copied silently to the other
|
|
306
|
+
// three write sites below.
|
|
307
|
+
//
|
|
308
|
+
// The daemon requires it: `readAuthority` refuses an absent value
|
|
309
|
+
// outright. An earlier build defaulted it to owner-direct instead, and
|
|
310
|
+
// stating the claim rather than leaning on that default is precisely
|
|
311
|
+
// why this command kept working when it was tightened. It is the only
|
|
312
|
+
// gate `forget` and `undo` have — layers 2 and 3 do not apply to a
|
|
313
|
+
// removal — so it is not a field to leave to anyone else's default.
|
|
314
|
+
//
|
|
315
|
+
// This surface can hardcode it; the agent must not. The TUI's only
|
|
316
|
+
// input is the owner at his own keyboard, whereas the agent can be
|
|
317
|
+
// handed a purported fact by a page, an email or a channel message and
|
|
318
|
+
// has to state the real source per write.
|
|
319
|
+
await runWrite(invoke, 'profile.set', {
|
|
320
|
+
fieldId,
|
|
321
|
+
value: setValue,
|
|
322
|
+
surface: TUI_SURFACE,
|
|
323
|
+
said: `/profile set ${fieldToken} ${setValue}`,
|
|
324
|
+
authority: 'owner-direct',
|
|
325
|
+
}, print);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (sub === 'note' && note) {
|
|
330
|
+
await runWrite(invoke, 'profile.append', {
|
|
331
|
+
section: note.section,
|
|
332
|
+
text: note.text,
|
|
333
|
+
surface: TUI_SURFACE,
|
|
334
|
+
said: `/profile note ${note.text}`,
|
|
335
|
+
authority: 'owner-direct',
|
|
336
|
+
}, print);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (sub === 'forget') {
|
|
341
|
+
if (forgetProse) {
|
|
342
|
+
await runWrite(invoke, 'profile.forget', {
|
|
343
|
+
section: forgetProse.section,
|
|
344
|
+
text: forgetProse.text,
|
|
345
|
+
authority: 'owner-direct',
|
|
346
|
+
}, print);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const fieldId = await resolveFieldToken(invoke, fieldToken!, print);
|
|
350
|
+
if (fieldId === null) return;
|
|
351
|
+
await runWrite(invoke, 'profile.forget', { fieldId, authority: 'owner-direct' }, print);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (sub === 'undo') {
|
|
356
|
+
const fieldId = await resolveFieldToken(invoke, fieldToken!, print);
|
|
357
|
+
if (fieldId === null) return;
|
|
358
|
+
await runWrite(invoke, 'profile.undo', { fieldId, authority: 'owner-direct' }, print);
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|