Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,31 @@ placing anything. Specifically:

A running log of durable decisions and feedback captured into the project config. Newest first.

- 2026-06-26 — **Build (owner AI intimacy-topic suggester — SPEC 08 §16.5a AI-assist; on `feat/ai-intimacy-topics`,
PR pending).** The last item of the 2026-06-26 batch. The owner-only Intimacy Topics settings control gains a
**"Suggest with AI"** affordance. **All 3 product/UX forks asked first (user-chosen):** lives **in the existing
owner control** (not a separate panel); driven by a **subject box that falls back to a general varied batch when
blank** ("Both"); suggestions are added via a **pick-and-edit checklist** ("tick + edit, then Add selected"). Core
`@selfos/core/intimacy/suggestService.ts` (`suggestIntimacyTopics`) — one metered **`intimacy.suggestTopics`** pass
(budget-gated, meter-BEFORE-parse, tolerant `extractJsonObject` + honest failures, `extendedThinking:false`) that
proposes consensual-adult **activity + fantasy** topics and **dedupes** them case-insensitively against the merged
inventory (built-in + custom; an all-dupes result is an honest EMPTY). **Persists nothing** — the owner reviews +
the existing `addCustomIntimacyTopic` path commits the chosen ones. The consensual-adult boundary lives in the
prompt + the model (never a keyword filter); the Owner is the full-access role so the seam is gated `people.manage`
(owner-only), and **AI-off is a calm state** (no dead button). IPC `questionnaires:suggestIntimacyTopics` through
the full seam + the offline fake-Claude branch (returns a set incl. an existing built-in, so the dedupe is
exercised offline). Renderer: the control gains a subject `Textarea` + "Suggest with AI" → a checklist of
checkbox+editable rows → "Add selected (N)". Additive schema (`IntimacyTopicSuggestResult` + the usage type) — no
migration. Gate green: typecheck (all), lint, **938 core + 854 desktop** unit (4 core suggester [dedup/meter/ground,
no-subject, NO_KEY, EMPTY] + 6 control RTL [suggest/pick/edit/add, EMPTY surface, non-owner hidden] + a coreBridge
test [owner deduped, member denied, AI-off calm]), **E2E** (the Settings flow: Suggest → checklist → a built-in
deduped out → uncheck one → Add selected → persisted across both kinds). Visual QA (real Electron screenshot): the
subject box + the pick-and-edit checklist + "Add selected (3)" read clean + cohesive with Settings. Synced spec 08
§16.5a. **Lesson: a "suggest then add" feature is cheapest as an EPHEMERAL pass (persist nothing) layered on the
existing add path — the suggester only computes deduped candidates, the owner's checklist drives the existing
per-topic add, so there's no new persistence, no migration, and the dedupe is the one real guarantee (the prompt's
avoid-list just nudges the model).**

- 2026-06-26 — **Build (guided-sessions expansion — fuller therapy/coaching + a Family group + catalog search;
on `feat/guided-sessions-expansion`, PR pending).** Additive content + one small UI feature (an extension of
spec 16/48, no new machinery, no schema/IPC). **New `family` group "Family & relationships"** (12
Expand Down
21 changes: 20 additions & 1 deletion apps/desktop/e2e/launch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4469,7 +4469,7 @@ test('authoring (§16.4): AI draft fills the empty title; Save→Send is a two-s
}
});

test('intimacy topics (§16.5a): the owner manages custom topics in Settings + an inline builder add, persisted', async () => {
test('intimacy topics (§16.5a): the owner manages custom topics in Settings + AI suggest + an inline builder add, persisted', async () => {
const { userData, vault } = await seedReadyVault({ 'ai.enabled': true });
await createNodeSecretStore(userData, passthrough).set('anthropic.apiKey', 'sk-ant-e2e');
const fs = createNodeFileSystem(vault);
Expand All @@ -4494,6 +4494,25 @@ test('intimacy topics (§16.5a): the owner manages custom topics in Settings + a
.poll(async () => (await readCustomIntimacyTopics(fs)).activities)
.toContain('Sploshing');

// Suggest with AI: the offline fake proposes a set that INCLUDES an existing built-in ('Sensual
// massage') — which is deduped out of the checklist. Uncheck one fresh suggestion, add the rest.
await w.getByRole('button', { name: 'Suggest with AI' }).click();
await expect(w.getByLabel('Include Mutual edging')).toBeVisible();
await expect(w.getByLabel('Include Temperature contrast play')).toBeVisible();
await expect(w.getByLabel('Include Sensual massage')).toHaveCount(0); // a built-in → deduped
await w.getByLabel('Include Temperature contrast play').uncheck();
await w.getByRole('button', { name: /Add selected/ }).click();
// The picked suggestions persist (across both kinds); the unchecked one does not.
await expect
.poll(async () => (await readCustomIntimacyTopics(fs)).activities)
.toContain('Mutual edging');
await expect
.poll(async () => (await readCustomIntimacyTopics(fs)).fantasies)
.toContain('Rivals-to-lovers roleplay');
expect((await readCustomIntimacyTopics(fs)).activities).not.toContain(
'Temperature contrast play',
);

// The inline builder add (owner) writes to the SAME shared list: author an intimacy/unfiltered
// questionnaire and add a fantasy from the AI panel.
await w.getByRole('link', { name: 'Questionnaires' }).click();
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/main/claude/anthropicClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,19 @@ export function fakeClaudeClient(): ClaudeClient {
});
}

// The owner intimacy-topic suggester (08 §16.5a AI assist) — the brief lists what the owner already
// has. Return a small {activities, fantasies} set; include one EXISTING topic ('Sensual massage', a
// built-in) so the post-parse dedupe is exercised in the offline path (37 §10).
if (userText.includes('Topics the Owner ALREADY has')) {
return Promise.resolve({
text: JSON.stringify({
activities: ['Sensual massage', 'Mutual edging', 'Temperature contrast play'],
fantasies: ['Rivals-to-lovers roleplay', 'Voyeurism'],
}),
usage: { inputTokens: 90, outputTokens: 50, cacheWriteTokens: 0, cacheReadTokens: 0 },
});
}

// The session-analysis turn (09 §5) asks to "summarize this session" as a JSON object. Return a
// valid SessionAnalysisDraft so the offline End & summarize path parses + produces facts/mood.
if (userText.includes('summarize this session')) {
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,10 @@ export function registerIpcHandlers(): void {
handle(IpcChannels.questionnairesIntimacyTopics, bridge.questionnairesIntimacyTopics);
handle(IpcChannels.questionnairesAddIntimacyTopic, bridge.questionnairesAddIntimacyTopic);
handle(IpcChannels.questionnairesRemoveIntimacyTopic, bridge.questionnairesRemoveIntimacyTopic);
handle(
IpcChannels.questionnairesSuggestIntimacyTopics,
bridge.questionnairesSuggestIntimacyTopics,
);
handle(IpcChannels.questionnairesStoreImage, bridge.questionnairesStoreImage);
handle(IpcChannels.questionnairesGetImage, bridge.questionnairesGetImage);
handle(IpcChannels.questionnairesDeleteImage, bridge.questionnairesDeleteImage);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ const bridge: SelfosBridge = {
ipcRenderer.invoke(IpcChannels.questionnairesAddIntimacyTopic, input),
questionnairesRemoveIntimacyTopic: (input) =>
ipcRenderer.invoke(IpcChannels.questionnairesRemoveIntimacyTopic, input),
questionnairesSuggestIntimacyTopics: (input) =>
ipcRenderer.invoke(IpcChannels.questionnairesSuggestIntimacyTopics, input),
questionnairesStoreImage: (input) =>
ipcRenderer.invoke(IpcChannels.questionnairesStoreImage, input),
questionnairesGetImage: (imagePath) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,36 @@
background: var(--color-border);
color: var(--color-text-primary);
}

/* The owner-only "Suggest with AI" block — a calm bordered card distinct from the manual add rows. */
.suggestBox {
padding: var(--space-3);
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}

.suggestions {
padding-top: var(--space-1);
}

/* One reviewable suggestion: a checkbox + an editable label, on a single row. */
.pickRow {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}

.pickRow > :last-child {
flex: 1 1 auto;
min-width: 0;
}

.pickCheck {
flex: none;
width: 16px;
height: 16px;
accent-color: var(--color-accent);
cursor: pointer;
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,5 +96,60 @@ describe('IntimacyTopicsControl (§16.5a)', () => {
expect(screen.getByText('Wax play')).toBeInTheDocument(); // still shown, just not removable
expect(screen.queryByRole('button', { name: 'Remove Wax play' })).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Add activities/i)).not.toBeInTheDocument();
// The owner-only AI suggester is hidden for a non-owner.
expect(screen.queryByRole('button', { name: 'Suggest with AI' })).not.toBeInTheDocument();
});

it('suggests with AI, then adds only the picked + edited suggestions', async () => {
asOwner();
const add = vi.fn((input: { kind: 'activities' | 'fantasies'; name: string }) =>
Promise.resolve(view({ activities: [input.name], fantasies: [] })),
);
const suggest = vi.fn(() =>
Promise.resolve({
ok: true as const,
suggestions: { activities: ['Wax play', 'Edging'], fantasies: ['Roleplay'] },
}),
);
installMockBridge({
questionnairesIntimacyTopics: () => Promise.resolve(view({ activities: [], fantasies: [] })),
questionnairesSuggestIntimacyTopics: suggest,
questionnairesAddIntimacyTopic: add,
});
render(<IntimacyTopicsControl />);

await userEvent.click(await screen.findByRole('button', { name: 'Suggest with AI' }));
// Suggestions render as an editable checklist (all checked by default).
expect(await screen.findByLabelText('Edit suggestion: Wax play')).toBeInTheDocument();
// Uncheck 'Edging'.
await userEvent.click(screen.getByLabelText('Include Edging'));
// Edit 'Roleplay' → 'Pirate roleplay' (the label keys on the original text, so it still resolves).
const roleplay = screen.getByLabelText('Edit suggestion: Roleplay');
await userEvent.clear(roleplay);
await userEvent.type(roleplay, 'Pirate roleplay');

await userEvent.click(screen.getByRole('button', { name: /Add selected \(2\)/ }));
await waitFor(() => {
expect(add).toHaveBeenCalledWith({ kind: 'activities', name: 'Wax play' });
expect(add).toHaveBeenCalledWith({ kind: 'fantasies', name: 'Pirate roleplay' });
});
expect(add).not.toHaveBeenCalledWith({ kind: 'activities', name: 'Edging' }); // unchecked
});

it('surfaces an AI-suggestion failure calmly (nothing fresh)', async () => {
asOwner();
installMockBridge({
questionnairesIntimacyTopics: () => Promise.resolve(view({ activities: [], fantasies: [] })),
questionnairesSuggestIntimacyTopics: () =>
Promise.resolve({
ok: false as const,
reason: 'EMPTY' as const,
message: 'No fresh topics came back — try a different subject.',
}),
});
render(<IntimacyTopicsControl />);

await userEvent.click(await screen.findByRole('button', { name: 'Suggest with AI' }));
expect(await screen.findByText(/No fresh topics came back/)).toBeInTheDocument();
});
});
Loading