diff --git a/contract-canary.lock.json b/contract-canary.lock.json index 31641316..7ba1f1fe 100644 --- a/contract-canary.lock.json +++ b/contract-canary.lock.json @@ -6,7 +6,7 @@ "releaseManifest": { "file": "release-manifest.json", "version": "0.1.0", - "sha256": "b8bfb62236fc8add4a9baad9f00e5401db15074a2d21fe2847a9158104cefb3c" + "sha256": "b248f2d945f77e22d0dee1644e9131aa7d2a20db2f30d06206a974d7a4262dec" }, "artifacts": { "core": { @@ -14,16 +14,16 @@ "version": "0.1.0", "releaseFile": "tarballs/core/opencoven-sdk-core-0.1.0.tgz", "vendorFile": "sdk-core-0.1.0.tgz", - "size": 33284, - "sha256": "9a574e8bd5178ce2aa20db97e8a741c7c9569515546a2d3089406f41a9d040fe" + "size": 33332, + "sha256": "dc86c6d4c88dc8273272b70d2014d2b62c80ae7368c9cf1e8eb78440f5fcc9c4" }, "cave": { "packageName": "@opencoven/cave-client", "version": "0.1.0", "releaseFile": "tarballs/cave/opencoven-cave-client-0.1.0.tgz", "vendorFile": "cave-client-0.1.0.tgz", - "size": 81543, - "sha256": "c44544adf8e712d6be1e8686788e63aa0133eb318274d1fb1926138a7da148c0" + "size": 85426, + "sha256": "de16ce13f2e3be0f6555cfc4413ca3c8f8f1a94f980261a6857c025615e7a14a" }, "coven": { "packageName": "@opencoven/coven-client", diff --git a/docs/developer-toolchains.md b/docs/developer-toolchains.md index 819411f1..c002fff6 100644 --- a/docs/developer-toolchains.md +++ b/docs/developer-toolchains.md @@ -87,7 +87,7 @@ Cave revisions, rejects dirty SDK or Cave checkouts, and verifies the checked-out HEADs before the canary runs. The SDK lock targets canonical `main` commit `acc38488f00860d246c3c553375634d64806eabb` and manifest digest - `b8bfb62236fc8add4a9baad9f00e5401db15074a2d21fe2847a9158104cefb3c`. + `b248f2d945f77e22d0dee1644e9131aa7d2a20db2f30d06206a974d7a4262dec`. The lock also pins the Cave producer's Client v1 contract fixture and `hpke-bound-v1` vector digests; the canary proves packed vector byte identity and the ancestry of the SDK's diff --git a/docs/phase1-conformance.md b/docs/phase1-conformance.md index 85766a65..c1ac278e 100644 --- a/docs/phase1-conformance.md +++ b/docs/phase1-conformance.md @@ -30,7 +30,7 @@ the final bytes. - SDK evidence contract and registry `4736bf2e0d5b16272d79ecf7784c75f376b39b94`; - manifest digest - `b8bfb62236fc8add4a9baad9f00e5401db15074a2d21fe2847a9158104cefb3c`; + `b248f2d945f77e22d0dee1644e9131aa7d2a20db2f30d06206a974d7a4262dec`; - canonical package order, release/vendor paths, sizes, and SHA-256 digests. Chat's Phase 1 source lock now agrees with the frozen Cave and Chat source diff --git a/phase1-conformance.lock.json b/phase1-conformance.lock.json index 81741d9b..33e3f4cc 100644 --- a/phase1-conformance.lock.json +++ b/phase1-conformance.lock.json @@ -274,22 +274,22 @@ "release": { "sdkManifest": { "version": "0.1.0", - "sha256": "b8bfb62236fc8add4a9baad9f00e5401db15074a2d21fe2847a9158104cefb3c" + "sha256": "b248f2d945f77e22d0dee1644e9131aa7d2a20db2f30d06206a974d7a4262dec" }, "sdkArtifacts": [ { "packageName": "@opencoven/sdk-core", "releaseFile": "tarballs/core/opencoven-sdk-core-0.1.0.tgz", "vendorFile": "sdk-core-0.1.0.tgz", - "size": 33284, - "sha256": "9a574e8bd5178ce2aa20db97e8a741c7c9569515546a2d3089406f41a9d040fe" + "size": 33332, + "sha256": "dc86c6d4c88dc8273272b70d2014d2b62c80ae7368c9cf1e8eb78440f5fcc9c4" }, { "packageName": "@opencoven/cave-client", "releaseFile": "tarballs/cave/opencoven-cave-client-0.1.0.tgz", "vendorFile": "cave-client-0.1.0.tgz", - "size": 81543, - "sha256": "c44544adf8e712d6be1e8686788e63aa0133eb318274d1fb1926138a7da148c0" + "size": 85426, + "sha256": "de16ce13f2e3be0f6555cfc4413ca3c8f8f1a94f980261a6857c025615e7a14a" }, { "packageName": "@opencoven/coven-client", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10beb722..010817a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,7 +280,7 @@ packages: resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} '@opencoven/cave-client@file:vendor/opencoven-sdk/cave-client-0.1.0.tgz': - resolution: {integrity: sha512-RcVL85gfejq70t+KOv2SHXd+pD0nMH+0VClncyTYmis/n2TBOwzDx1hXt8iJLfpSYm72we5GTkrBo8A+4PdTpQ==, tarball: file:vendor/opencoven-sdk/cave-client-0.1.0.tgz} + resolution: {integrity: sha512-5USz/yPGgqvj9QQGZYMowU5FVCG74SP5BEyYQuOAzFltpgieBAHie0JDfhmEfykvJIEv2ldpRXX+Eq8OwT2hiQ==, tarball: file:vendor/opencoven-sdk/cave-client-0.1.0.tgz} version: 0.1.0 engines: {node: '>=24.18.0 <25'} @@ -290,7 +290,7 @@ packages: engines: {node: '>=24.18.0 <25'} '@opencoven/sdk-core@file:vendor/opencoven-sdk/sdk-core-0.1.0.tgz': - resolution: {integrity: sha512-ZLfkfFNETnLjwg/Lxa5nIXrYs1L1q+y0fChyzZdorIpB1Rq8NeJt2S2PLQsaQXLls3GyCSkVe9+SCD2qoITkiA==, tarball: file:vendor/opencoven-sdk/sdk-core-0.1.0.tgz} + resolution: {integrity: sha512-vPAANJLfsetoT9dnOvExz2zXpBf2a2j1BcMYNIkmnOpSpEe411EMzNRXafKmxepQ7YP18iXCOTF1jZLKZa1ztg==, tarball: file:vendor/opencoven-sdk/sdk-core-0.1.0.tgz} version: 0.1.0 engines: {node: '>=24.18.0 <25'} diff --git a/src/contract-canary-artifact-root.test.ts b/src/contract-canary-artifact-root.test.ts index 19997b2a..b9240e10 100644 --- a/src/contract-canary-artifact-root.test.ts +++ b/src/contract-canary-artifact-root.test.ts @@ -320,7 +320,7 @@ describe('contract canary temp directory safety', () => { expect(lock.sdk.releaseManifest).toEqual({ file: 'release-manifest.json', version: '0.1.0', - sha256: 'b8bfb62236fc8add4a9baad9f00e5401db15074a2d21fe2847a9158104cefb3c', + sha256: 'b248f2d945f77e22d0dee1644e9131aa7d2a20db2f30d06206a974d7a4262dec', }); expect(Object.keys(lock.sdk.artifacts)).toEqual(['core', 'cave', 'coven', 'sdk']); expect(lock.sdk.artifacts.core).toEqual({ @@ -328,8 +328,8 @@ describe('contract canary temp directory safety', () => { version: '0.1.0', releaseFile: 'tarballs/core/opencoven-sdk-core-0.1.0.tgz', vendorFile: 'sdk-core-0.1.0.tgz', - size: 33284, - sha256: '9a574e8bd5178ce2aa20db97e8a741c7c9569515546a2d3089406f41a9d040fe', + size: 33332, + sha256: 'dc86c6d4c88dc8273272b70d2014d2b62c80ae7368c9cf1e8eb78440f5fcc9c4', }); expect(lock.sdk.artifacts.cave).toEqual({ @@ -337,8 +337,8 @@ describe('contract canary temp directory safety', () => { version: '0.1.0', releaseFile: 'tarballs/cave/opencoven-cave-client-0.1.0.tgz', vendorFile: 'cave-client-0.1.0.tgz', - size: 81543, - sha256: 'c44544adf8e712d6be1e8686788e63aa0133eb318274d1fb1926138a7da148c0', + size: 85426, + sha256: 'de16ce13f2e3be0f6555cfc4413ca3c8f8f1a94f980261a6857c025615e7a14a', }); expect(lock.cave.repository).toBe('OpenCoven/coven-cave'); expect(lock.cave.revision).toBe('6325fc4c1154c7d7398074a9760a2e2dc323b424'); diff --git a/src/familiars/cave-source.test.ts b/src/familiars/cave-source.test.ts new file mode 100644 index 00000000..9fb0dfd4 --- /dev/null +++ b/src/familiars/cave-source.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { QueryAdapter } from '../lib/sdk/query-adapter'; +import { createCaveFamiliarsSource } from './cave-source'; +import type { Capability, QueryResult } from './source'; + +function ok(data: T): QueryResult { + return { status: 'ok', data }; +} + +function makeQueryAdapter(overrides: Partial = {}): QueryAdapter { + return { + listFamiliars: vi.fn().mockResolvedValue(ok({ data: [] })), + listProjects: vi.fn().mockResolvedValue(ok({ data: [] })), + listConversations: vi.fn().mockResolvedValue(ok({ data: [] })), + getConversation: vi.fn().mockResolvedValue({ status: 'not_ready' }), + listMessages: vi.fn().mockResolvedValue(ok({ data: [] })), + familiarContract: vi.fn().mockResolvedValue({ status: 'not_ready' }), + familiarAnalytics: vi.fn().mockResolvedValue({ status: 'not_ready' }), + invalidate: vi.fn(), + dispose: vi.fn(), + ...overrides, + }; +} + +const CAPABILITIES: ReadonlySet = new Set(['familiars', 'familiar-contract']); + +describe('createCaveFamiliarsSource', () => { + it('maps a familiars page through mapFamiliarSummary', async () => { + const queryAdapter = makeQueryAdapter({ + listFamiliars: vi + .fn() + .mockResolvedValue(ok({ data: [{ id: 'astra', displayName: 'Astra', role: 'Guide' }] })), + }); + const source = createCaveFamiliarsSource({ queryAdapter, capabilities: CAPABILITIES }); + + const result = await source.familiars(); + expect(result).toEqual( + ok({ data: [{ id: 'astra', name: 'Astra', role: 'Guide', status: 'offline' }] }), + ); + }); + + it('passes a non-ok familiars result straight through unmapped', async () => { + const queryAdapter = makeQueryAdapter({ + listFamiliars: vi.fn().mockResolvedValue({ status: 'error', code: 'service_unavailable' }), + }); + const source = createCaveFamiliarsSource({ queryAdapter, capabilities: CAPABILITIES }); + + expect(await source.familiars()).toEqual({ status: 'error', code: 'service_unavailable' }); + }); + + it('fetches a familiar contract by id and maps it to a detail', async () => { + const familiarContract = vi.fn().mockResolvedValue( + ok({ + id: 'astra', + present: { soul: true, identity: true, ward: true, memory: true }, + report: { specVersion: '0.1.0', pass: true, properties: [], violations: [], warnings: [] }, + }), + ); + const queryAdapter = makeQueryAdapter({ familiarContract }); + const source = createCaveFamiliarsSource({ queryAdapter, capabilities: CAPABILITIES }); + + const result = await source.familiar('astra'); + expect(familiarContract).toHaveBeenCalledWith('astra'); + expect(result.status).toBe('ok'); + expect(result).toMatchObject({ data: { id: 'astra' } }); + }); + + it('requests the default 7d window when none is given, and maps the response', async () => { + const familiarAnalytics = vi.fn().mockResolvedValue( + ok({ + generatedAt: '2026-08-25T00:00:00.000Z', + windows: { + '7d': { + attempts: 1, + completed: 1, + failed: 0, + cancelled: 0, + successRate: 1, + toolCalls: 4, + toolFailures: 0, + models: [], + harnesses: [], + coverage: {}, + }, + }, + recentAttempts: [], + backfill: { state: 'complete', imported: 1 }, + }), + ); + const queryAdapter = makeQueryAdapter({ familiarAnalytics }); + const source = createCaveFamiliarsSource({ queryAdapter, capabilities: CAPABILITIES }); + + const result = await source.activity('astra'); + expect(familiarAnalytics).toHaveBeenCalledWith('astra', { window: '7d' }); + expect(result).toMatchObject({ status: 'ok', data: { window: '7d', attempts: 1 } }); + }); + + it('requests an explicit window and returns not_found when Cave omits it from the response', async () => { + const familiarAnalytics = vi.fn().mockResolvedValue( + ok({ + generatedAt: '2026-08-25T00:00:00.000Z', + windows: {}, + recentAttempts: [], + backfill: { state: 'not-started', imported: 0 }, + }), + ); + const queryAdapter = makeQueryAdapter({ familiarAnalytics }); + const source = createCaveFamiliarsSource({ queryAdapter, capabilities: CAPABILITIES }); + + const result = await source.activity('astra', '14d'); + expect(familiarAnalytics).toHaveBeenCalledWith('astra', { window: '14d' }); + expect(result).toEqual({ status: 'error', code: 'not_found' }); + }); + + it('passes a non-ok analytics result straight through without mapping', async () => { + const queryAdapter = makeQueryAdapter({ + familiarAnalytics: vi.fn().mockResolvedValue({ status: 'reconcile_required' }), + }); + const source = createCaveFamiliarsSource({ queryAdapter, capabilities: CAPABILITIES }); + + expect(await source.activity('astra')).toEqual({ status: 'reconcile_required' }); + }); + + it('maps a conversations page and a messages page through their mappers', async () => { + const queryAdapter = makeQueryAdapter({ + listConversations: vi + .fn() + .mockResolvedValue( + ok({ data: [{ id: 'c1', familiarId: 'astra', updatedAt: '2026-08-25T00:00:00.000Z' }] }), + ), + listMessages: vi.fn().mockResolvedValue( + ok({ + data: [ + { + id: 'm1', + conversationId: 'c1', + parentId: null, + role: 'user', + text: 'Hi', + createdAt: '2026-08-25T00:00:00.000Z', + attachmentCount: 0, + toolCount: 0, + }, + ], + }), + ), + }); + const source = createCaveFamiliarsSource({ queryAdapter, capabilities: CAPABILITIES }); + + const conversations = await source.conversations(); + expect(conversations).toMatchObject({ + data: { data: [{ id: 'c1', failed: false, pending: false }] }, + }); + + const messages = await source.messages('c1'); + expect(queryAdapter.listMessages).toHaveBeenCalledWith('c1'); + expect(messages).toMatchObject({ + data: { data: [{ id: 'm1', role: 'user', isError: false }] }, + }); + }); + + it('returns the capabilities it was constructed with, unchanged', () => { + const source = createCaveFamiliarsSource({ + queryAdapter: makeQueryAdapter(), + capabilities: CAPABILITIES, + }); + expect(source.capabilities()).toBe(CAPABILITIES); + }); +}); diff --git a/src/familiars/cave-source.ts b/src/familiars/cave-source.ts new file mode 100644 index 00000000..ec629830 --- /dev/null +++ b/src/familiars/cave-source.ts @@ -0,0 +1,93 @@ +import type { Page } from '@opencoven/sdk-core/browser'; + +import type { QueryAdapter } from '../lib/sdk/query-adapter'; +import { + mapConversationSummary, + mapFamiliarActivity, + mapFamiliarDetail, + mapFamiliarSummary, + mapThreadMessage, +} from './mappers'; +import type { + ActivityWindow, + Capability, + ConversationSummary, + FamiliarActivity, + FamiliarDetail, + FamiliarSummary, + FamiliarsSource, + QueryResult, + ThreadMessage, +} from './source'; + +/** + * `FamiliarsSource` over `QueryAdapter` and the managed `CaveClient`. + * + * Every method is a thin fetch-then-map: `QueryAdapter` owns the TTL / LRU / + * abort / epoch semantics the rest of the production shell already uses, + * and `./mappers.ts` owns the SDK-wire-type -> view-type translation. This + * module adds no caching, retry, or formatting logic of its own. + */ + +const DEFAULT_ACTIVITY_WINDOW: ActivityWindow = '7d'; + +export type CaveFamiliarsSourceOptions = Readonly<{ + queryAdapter: QueryAdapter; + /** + * The set of capability names the connected Cave instance advertises + * (`CaveHealth.capabilities`). A snapshot rather than a live subscription: + * the shell re-creates the source when connection state changes. + */ + capabilities: ReadonlySet; +}>; + +function mapResult(result: QueryResult, map: (value: T) => U): QueryResult { + return result.status === 'ok' ? { status: 'ok', data: map(result.data) } : result; +} + +function mapPageResult( + result: QueryResult>, + map: (value: T) => U, +): QueryResult> { + return result.status === 'ok' + ? { status: 'ok', data: { ...result.data, data: result.data.data.map(map) } } + : result; +} + +export function createCaveFamiliarsSource(options: CaveFamiliarsSourceOptions): FamiliarsSource { + const { queryAdapter, capabilities } = options; + + return Object.freeze({ + async familiars(): Promise>> { + return mapPageResult(await queryAdapter.listFamiliars(), mapFamiliarSummary); + }, + async familiar(id: string): Promise> { + return mapResult(await queryAdapter.familiarContract(id), mapFamiliarDetail); + }, + async activity( + id: string, + window: ActivityWindow = DEFAULT_ACTIVITY_WINDOW, + ): Promise> { + const result = await queryAdapter.familiarAnalytics(id, { window }); + if (result.status !== 'ok') { + return result; + } + const activity = mapFamiliarActivity(result.data, window); + // Cave omitted the requested window (e.g. an instance with a shorter + // retained history than `window` implies) rather than the read + // failing outright. + return activity === undefined + ? { status: 'error', code: 'not_found' } + : { status: 'ok', data: activity }; + }, + async conversations(): Promise>> { + return mapPageResult(await queryAdapter.listConversations(), mapConversationSummary); + }, + async messages(conversationId: string): Promise>> { + return mapPageResult(await queryAdapter.listMessages(conversationId), mapThreadMessage); + }, + capabilities(): ReadonlySet { + return capabilities; + }, + }); +} diff --git a/src/familiars/mappers.test.ts b/src/familiars/mappers.test.ts new file mode 100644 index 00000000..d07787db --- /dev/null +++ b/src/familiars/mappers.test.ts @@ -0,0 +1,344 @@ +import type { + CaveExecutionAttempt, + CaveExecutionWindow, + CaveFamiliarAnalytics, + CaveFamiliarContract, +} from '@opencoven/cave-client'; +import type { + CaveCanonicalFamiliar, + CaveConversation, + CaveConversationMessage, +} from '@opencoven/cave-client/managed'; +import { describe, expect, it } from 'vitest'; + +import { + mapConversationSummary, + mapFamiliarActivity, + mapFamiliarDetail, + mapFamiliarSummary, + mapThreadMessage, +} from './mappers'; + +describe('mapFamiliarSummary', () => { + it('renames displayName to name and passes optional fields through', () => { + const familiar: CaveCanonicalFamiliar = { + id: 'astra', + displayName: 'Astra', + role: 'Research and synthesis', + description: 'Reads widely.', + pronouns: 'she/her', + status: 'working', + }; + + expect(mapFamiliarSummary(familiar)).toEqual({ + id: 'astra', + name: 'Astra', + role: 'Research and synthesis', + description: 'Reads widely.', + pronouns: 'she/her', + status: 'working', + }); + }); + + it('defaults an unrecognized or missing status to offline, and omits absent optional fields', () => { + const bare: CaveCanonicalFamiliar = { id: 'astra', displayName: 'Astra', role: 'Guide' }; + + expect(mapFamiliarSummary(bare)).toEqual({ + id: 'astra', + name: 'Astra', + role: 'Guide', + status: 'offline', + }); + expect(mapFamiliarSummary({ ...bare, status: 'unknown-future-value' })).toMatchObject({ + status: 'offline', + }); + }); +}); + +describe('mapFamiliarDetail', () => { + const contract: CaveFamiliarContract = { + id: 'astra', + workspace: '/workspace/astra', + present: { soul: true, identity: true, ward: true, memory: true }, + identity: { name: 'Astra', creature: 'Cartographer', person: 'Val Alexander' }, + ward: { + version: '0.3.1', + protectedFiles: ['SOUL.md', 'IDENTITY.md', 'MEMORY.md', 'ward.toml'], + invariants: ['familiar.name', 'familiar.person'], + editablePaths: ['TOOLS.md', 'notes/'], + approvalTiers: { auto: ['read files'], humanReview: ['publish a finding'] }, + }, + report: { + specVersion: '0.1.0', + pass: true, + properties: [{ property: 'Named Identity', pass: true }], + violations: [], + warnings: [{ file: 'MEMORY.md', field: 'entries', message: 'No memory yet.' }], + }, + }; + + it('maps identity, ward, and the contract report field-for-field', () => { + expect(mapFamiliarDetail(contract)).toEqual({ + id: 'astra', + workspace: '/workspace/astra', + present: { soul: true, identity: true, ward: true, memory: true }, + identity: { name: 'Astra', creature: 'Cartographer', person: 'Val Alexander' }, + ward: { + version: '0.3.1', + protectedFiles: ['SOUL.md', 'IDENTITY.md', 'MEMORY.md', 'ward.toml'], + invariants: ['familiar.name', 'familiar.person'], + editablePaths: ['TOOLS.md', 'notes/'], + approvalTiers: { auto: ['read files'], humanReview: ['publish a finding'] }, + }, + report: contract.report, + }); + }); + + it('omits identity and ward when Cave withholds or lacks them', () => { + const minimal: CaveFamiliarContract = { + id: 'echo', + present: { soul: true, identity: true, ward: false, memory: false }, + report: { specVersion: '0.1.0', pass: false, properties: [], violations: [], warnings: [] }, + }; + + const detail = mapFamiliarDetail(minimal); + expect(detail.identity).toBeUndefined(); + expect(detail.ward).toBeUndefined(); + expect(detail.workspace).toBeUndefined(); + expect(detail.present).toEqual({ soul: true, identity: true, ward: false, memory: false }); + }); +}); + +function executionAttempt(overrides: Partial = {}): CaveExecutionAttempt { + return { + id: 'attempt-1', + executionKind: 'run', + occurredAt: '2026-08-25T00:00:00.000Z', + harnessId: 'claude-code', + status: 'completed', + toolCalls: 14, + toolFailures: 0, + ...overrides, + }; +} + +function executionWindow(overrides: Partial = {}): CaveExecutionWindow { + return { + attempts: 12, + completed: 12, + failed: 0, + cancelled: 0, + successRate: 1, + medianDurationMs: 96_000, + p95DurationMs: 168_000, + toolCalls: 148, + toolFailures: 2, + models: [], + harnesses: [ + { + key: 'files.read', + attempts: 58, + completed: 58, + failed: 0, + cancelled: 0, + successRate: 1, + toolCalls: 58, + toolFailures: 0, + }, + { + key: 'web.fetch', + label: 'web.fetch', + attempts: 22, + completed: 20, + failed: 2, + cancelled: 0, + successRate: 0.9, + toolCalls: 22, + toolFailures: 2, + }, + ], + coverage: {}, + days: [{ date: '2026-08-24', completed: 3, failed: 1, cancelled: 0 }], + ...overrides, + }; +} + +describe('mapFamiliarActivity', () => { + it('renames the window fields the plan specifies and maps harnesses to tool usage', () => { + const analytics: CaveFamiliarAnalytics = { + generatedAt: '2026-08-25T00:00:00.000Z', + windows: { '7d': executionWindow() }, + recentAttempts: [ + executionAttempt(), + executionAttempt({ id: 'attempt-2', status: 'failed', toolFailures: 1 }), + ], + backfill: { state: 'complete', imported: 12 }, + }; + + expect(mapFamiliarActivity(analytics, '7d')).toEqual({ + window: '7d', + generatedAt: '2026-08-25T00:00:00.000Z', + attempts: 12, + completed: 12, + failed: 0, + cancelled: 0, + completion: 1, + medianDurationMs: 96_000, + p95DurationMs: 168_000, + calls: 148, + callFailures: 2, + tools: [ + { name: 'files.read', calls: 58, failed: 0 }, + { name: 'web.fetch', calls: 22, failed: 2 }, + ], + days: [{ date: '2026-08-24', completed: 3, failed: 1, cancelled: 0 }], + recent: [ + { + id: 'attempt-1', + occurredAt: '2026-08-25T00:00:00.000Z', + harnessId: 'claude-code', + status: 'completed', + toolCalls: 14, + toolFailures: 0, + }, + { + id: 'attempt-2', + occurredAt: '2026-08-25T00:00:00.000Z', + harnessId: 'claude-code', + status: 'failed', + toolCalls: 14, + toolFailures: 1, + }, + ], + backfillState: 'complete', + }); + }); + + it('omits days on a window that does not carry the runs-per-day series', () => { + const { days: _days, ...windowWithoutDays } = executionWindow(); + const analytics: CaveFamiliarAnalytics = { + generatedAt: '2026-08-25T00:00:00.000Z', + windows: { all: windowWithoutDays }, + recentAttempts: [], + backfill: { state: 'partial', imported: 40, remaining: 8 }, + }; + + expect(mapFamiliarActivity(analytics, 'all')?.days).toBeUndefined(); + }); + + it('returns undefined when Cave did not serve the requested window', () => { + const analytics: CaveFamiliarAnalytics = { + generatedAt: '2026-08-25T00:00:00.000Z', + windows: { '7d': executionWindow() }, + recentAttempts: [], + backfill: { state: 'not-started', imported: 0 }, + }; + + expect(mapFamiliarActivity(analytics, '14d')).toBeUndefined(); + }); + + it('carries a null completion rate through rather than coercing it to zero', () => { + const analytics: CaveFamiliarAnalytics = { + generatedAt: '2026-08-25T00:00:00.000Z', + windows: { + '7d': executionWindow({ attempts: 0, completed: 0, failed: 0, successRate: null }), + }, + recentAttempts: [], + backfill: { state: 'complete', imported: 0 }, + }; + + expect(mapFamiliarActivity(analytics, '7d')?.completion).toBeNull(); + }); +}); + +describe('mapConversationSummary', () => { + it('derives failed from status and treats a nonzero exit code as failed even with no status', () => { + const base: CaveConversation = { + id: 'c1', + familiarId: 'astra', + updatedAt: '2026-08-25T00:00:00.000Z', + }; + + expect(mapConversationSummary(base).failed).toBe(false); + expect(mapConversationSummary({ ...base, status: 'failed' }).failed).toBe(true); + expect(mapConversationSummary({ ...base, status: 'error' }).failed).toBe(true); + expect(mapConversationSummary({ ...base, exitCode: 1 }).failed).toBe(true); + expect(mapConversationSummary({ ...base, exitCode: 0 }).failed).toBe(false); + expect(mapConversationSummary({ ...base, exitCode: null }).failed).toBe(false); + }); + + it('maps pending straight through and omits title when Cave omits it', () => { + const conversation: CaveConversation = { + id: 'c1', + familiarId: 'astra', + title: 'Q3 pricing evidence map', + updatedAt: '2026-08-25T00:00:00.000Z', + pending: true, + }; + + expect(mapConversationSummary(conversation)).toEqual({ + id: 'c1', + familiarId: 'astra', + title: 'Q3 pricing evidence map', + updatedAt: '2026-08-25T00:00:00.000Z', + failed: false, + pending: true, + }); + expect( + mapConversationSummary({ + id: 'c1', + familiarId: 'astra', + updatedAt: '2026-08-25T00:00:00.000Z', + }).title, + ).toBeUndefined(); + }); +}); + +describe('mapThreadMessage', () => { + it('maps every field, defaulting isError and cancelled to false', () => { + const message: CaveConversationMessage = { + id: 'm1', + conversationId: 'c1', + parentId: null, + role: 'assistant', + text: 'On it.', + createdAt: '2026-08-25T00:00:00.000Z', + attachmentCount: 0, + toolCount: 3, + }; + + expect(mapThreadMessage(message)).toEqual({ + id: 'm1', + conversationId: 'c1', + parentId: null, + role: 'assistant', + text: 'On it.', + createdAt: '2026-08-25T00:00:00.000Z', + attachmentCount: 0, + toolCount: 3, + isError: false, + cancelled: false, + }); + }); + + it('carries isError and cancelled through when Cave sets them', () => { + const message: CaveConversationMessage = { + id: 'm2', + conversationId: 'c1', + parentId: 'm1', + role: 'assistant', + text: '', + createdAt: '2026-08-25T00:00:00.000Z', + attachmentCount: 0, + toolCount: 0, + isError: true, + cancelled: true, + }; + + expect(mapThreadMessage(message)).toMatchObject({ + isError: true, + cancelled: true, + parentId: 'm1', + }); + }); +}); diff --git a/src/familiars/mappers.ts b/src/familiars/mappers.ts new file mode 100644 index 00000000..96a5e5a9 --- /dev/null +++ b/src/familiars/mappers.ts @@ -0,0 +1,189 @@ +import type { + CaveExecutionAttempt, + CaveExecutionDay, + CaveExecutionSlice, + CaveFamiliarAnalytics, + CaveFamiliarContract, + CaveFamiliarIdentity, + CaveFamiliarWard, +} from '@opencoven/cave-client'; +import type { + CaveCanonicalFamiliar, + CaveConversation, + CaveConversationMessage, +} from '@opencoven/cave-client/managed'; + +import type { + ActivityAttempt, + ActivityDay, + ActivityToolUsage, + ActivityWindow, + ConversationSummary, + FamiliarActivity, + FamiliarDetail, + FamiliarIdentity, + FamiliarStatus, + FamiliarSummary, + FamiliarWard, + ThreadMessage, +} from './source'; + +/** + * SDK wire types -> the Familiars surface's view types. + * + * This is the only module that knows the shape of a `CaveCanonicalFamiliar`, + * a `CaveFamiliarContract`, or a `CaveExecutionWindow`. Both + * `CaveFamiliarsSource` and its tests depend on this file rather than + * reaching into `@opencoven/cave-client` themselves, so a wire change is a + * type error here and nowhere else. + */ + +function normalizeFamiliarStatus(status: string | undefined): FamiliarStatus { + // Cave's `status` is a bare string today (no reviewed literal union). An + // unrecognized or missing value defaults to the least-permissive reading + // rather than a guess at "available". + return status === 'available' || status === 'working' ? status : 'offline'; +} + +export function mapFamiliarSummary(familiar: CaveCanonicalFamiliar): FamiliarSummary { + return { + id: familiar.id, + name: familiar.displayName, + role: familiar.role, + ...(familiar.description === undefined ? {} : { description: familiar.description }), + ...(familiar.pronouns === undefined ? {} : { pronouns: familiar.pronouns }), + status: normalizeFamiliarStatus(familiar.status), + }; +} + +function mapFamiliarIdentity(identity: CaveFamiliarIdentity): FamiliarIdentity { + return { + ...(identity.name === undefined ? {} : { name: identity.name }), + ...(identity.creature === undefined ? {} : { creature: identity.creature }), + ...(identity.person === undefined ? {} : { person: identity.person }), + }; +} + +function mapFamiliarWard(ward: CaveFamiliarWard): FamiliarWard { + return { + ...(ward.version === undefined ? {} : { version: ward.version }), + protectedFiles: ward.protectedFiles, + invariants: ward.invariants, + editablePaths: ward.editablePaths, + approvalTiers: { + auto: ward.approvalTiers.auto, + humanReview: ward.approvalTiers.humanReview, + }, + }; +} + +export function mapFamiliarDetail(contract: CaveFamiliarContract): FamiliarDetail { + return { + id: contract.id, + ...(contract.workspace === undefined ? {} : { workspace: contract.workspace }), + present: contract.present, + ...(contract.identity === undefined + ? {} + : { identity: mapFamiliarIdentity(contract.identity) }), + ...(contract.ward === undefined ? {} : { ward: mapFamiliarWard(contract.ward) }), + report: { + specVersion: contract.report.specVersion, + pass: contract.report.pass, + properties: contract.report.properties, + violations: contract.report.violations, + warnings: contract.report.warnings, + }, + }; +} + +function mapActivityToolUsage(slice: CaveExecutionSlice): ActivityToolUsage { + // Cave's Stage 1 analytics contract does not break usage down by literal + // tool name; a harness slice is the closest available concept. + return { + name: slice.label ?? slice.key, + calls: slice.toolCalls, + failed: slice.toolFailures, + }; +} + +function mapActivityDay(day: CaveExecutionDay): ActivityDay { + return { date: day.date, completed: day.completed, failed: day.failed, cancelled: day.cancelled }; +} + +function mapActivityAttempt(attempt: CaveExecutionAttempt): ActivityAttempt { + return { + id: attempt.id, + occurredAt: attempt.occurredAt, + harnessId: attempt.harnessId, + status: attempt.status, + ...(attempt.durationMs === undefined ? {} : { durationMs: attempt.durationMs }), + toolCalls: attempt.toolCalls, + toolFailures: attempt.toolFailures, + }; +} + +/** + * Maps one requested window out of `analytics.windows`. Returns `undefined` + * when Cave did not serve that window (an instance with a shorter history, + * or a window key the analytics response simply omits). + */ +export function mapFamiliarActivity( + analytics: CaveFamiliarAnalytics, + window: ActivityWindow, +): FamiliarActivity | undefined { + const source = analytics.windows[window]; + if (source === undefined) { + return undefined; + } + + return { + window, + generatedAt: analytics.generatedAt, + attempts: source.attempts, + completed: source.completed, + failed: source.failed, + cancelled: source.cancelled, + completion: source.successRate, + ...(source.medianDurationMs === undefined ? {} : { medianDurationMs: source.medianDurationMs }), + ...(source.p95DurationMs === undefined ? {} : { p95DurationMs: source.p95DurationMs }), + calls: source.toolCalls, + callFailures: source.toolFailures, + tools: source.harnesses.map(mapActivityToolUsage), + ...(source.days === undefined ? {} : { days: source.days.map(mapActivityDay) }), + recent: analytics.recentAttempts.map(mapActivityAttempt), + backfillState: analytics.backfill.state, + }; +} + +function isConversationFailed(conversation: CaveConversation): boolean { + if (conversation.status === 'failed' || conversation.status === 'error') { + return true; + } + return typeof conversation.exitCode === 'number' && conversation.exitCode !== 0; +} + +export function mapConversationSummary(conversation: CaveConversation): ConversationSummary { + return { + id: conversation.id, + familiarId: conversation.familiarId, + ...(conversation.title === undefined ? {} : { title: conversation.title }), + updatedAt: conversation.updatedAt, + failed: isConversationFailed(conversation), + pending: conversation.pending === true, + }; +} + +export function mapThreadMessage(message: CaveConversationMessage): ThreadMessage { + return { + id: message.id, + conversationId: message.conversationId, + parentId: message.parentId, + role: message.role, + text: message.text, + createdAt: message.createdAt, + attachmentCount: message.attachmentCount, + toolCount: message.toolCount, + isError: message.isError === true, + cancelled: message.cancelled === true, + }; +} diff --git a/src/familiars/source.ts b/src/familiars/source.ts index f230d897..4af33ec2 100644 --- a/src/familiars/source.ts +++ b/src/familiars/source.ts @@ -8,12 +8,12 @@ export type { Page, PageCursor, QueryResult }; * The Familiars surface's data-source seam. * * `FamiliarsShell` reads through this interface rather than module - * constants. One implementation exists today: `MockFamiliarsSource` - * (`./mock-source.ts`), wrapping `src/demo/familiars-data.ts` for the demo - * build and tests. The Cave-backed implementation, and the single mapping - * from SDK wire types to these view types, land with the SDK bump that - * exports `CaveFamiliarIdentity`, `CaveFamiliarWard`, and - * `CaveExecutionDay`; the seam is shaped for it so that arrival is additive. + * constants. Two implementations exist: `MockFamiliarsSource` + * (`./mock-source.ts`), wrapping today's `src/demo/familiars-data.ts` for + * the demo build and tests, and `CaveFamiliarsSource` (`./cave-source.ts`), + * built on `QueryAdapter` and the managed `CaveClient`. The mapping from SDK + * wire types to these view types lives in one place, `./mappers.ts`, so a + * wire change is a type error there and nowhere else. * * Scoped to Stage 1 (reads only) of * `docs/superpowers/plans/2026-09-02-familiars-integration.md`. Send, diff --git a/src/lib/sdk/native-boundary.ts b/src/lib/sdk/native-boundary.ts index 38d0c100..1c10beb9 100644 --- a/src/lib/sdk/native-boundary.ts +++ b/src/lib/sdk/native-boundary.ts @@ -1,7 +1,16 @@ import type { - CaveManagedCredentialTransport, - CaveManagedDiscoverySource, - CavePairingRequest, + CaveAnalyticsWindowKey, + CaveFamiliarAnalyticsResponse, + CaveFamiliarAnalyticsTransportOptions, + CaveFamiliarContractResponse, +} from '@opencoven/cave-client'; +import { + CAVE_ANALYTICS_WINDOWS, + type CaveManagedCredentialTransport, + type CaveManagedDiscoverySource, + type CavePairingRequest, + canonicalFamiliarAnalyticsData, + canonicalFamiliarContractData, } from '@opencoven/cave-client/managed'; import { type OperationContext, @@ -417,6 +426,38 @@ function canonicalConversationId(value: unknown): string { return value; } +function canonicalFamiliarId(value: unknown): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 64 || + !/^[A-Za-z0-9][A-Za-z0-9_-]*$/u.test(value) + ) { + return invalidNativeInput(); + } + return value; +} + +function canonicalAnalyticsWindow(value: CaveAnalyticsWindowKey | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + if (!(CAVE_ANALYTICS_WINDOWS as readonly string[]).includes(value)) { + return invalidNativeInput(); + } + return value; +} + +function canonicalRecentLimit(value: number | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + if (!Number.isInteger(value) || value < 0 || value > 100) { + return invalidNativeInput(); + } + return value; +} + function containsControlCharacter(value: string): boolean { for (const character of value) { const codePoint = character.codePointAt(0); @@ -606,5 +647,34 @@ export function createCaveManagedCredentialTransport( context, ); }, + async familiarContract( + familiarId: string, + context?: OperationContext, + ): Promise { + const result = await invokeBoundOperation( + 'cave_familiar_contract', + { + familiarId: canonicalFamiliarId(familiarId), + }, + context, + ); + return canonicalFamiliarContractData(result) as unknown as CaveFamiliarContractResponse; + }, + async familiarAnalytics( + familiarId: string, + options?: CaveFamiliarAnalyticsTransportOptions, + context?: OperationContext, + ): Promise { + const result = await invokeBoundOperation( + 'cave_familiar_analytics', + { + familiarId: canonicalFamiliarId(familiarId), + window: canonicalAnalyticsWindow(options?.window), + recentLimit: canonicalRecentLimit(options?.recentLimit), + }, + context, + ); + return canonicalFamiliarAnalyticsData(result) as unknown as CaveFamiliarAnalyticsResponse; + }, }); } diff --git a/src/phase1-conformance-lock.test.ts b/src/phase1-conformance-lock.test.ts index 1b0a9725..b2cc112f 100644 --- a/src/phase1-conformance-lock.test.ts +++ b/src/phase1-conformance-lock.test.ts @@ -203,22 +203,22 @@ const expectedEntries = { release: { sdkManifest: { version: '0.1.0', - sha256: 'b8bfb62236fc8add4a9baad9f00e5401db15074a2d21fe2847a9158104cefb3c', + sha256: 'b248f2d945f77e22d0dee1644e9131aa7d2a20db2f30d06206a974d7a4262dec', }, sdkArtifacts: [ { packageName: '@opencoven/sdk-core', releaseFile: 'tarballs/core/opencoven-sdk-core-0.1.0.tgz', vendorFile: 'sdk-core-0.1.0.tgz', - size: 33284, - sha256: '9a574e8bd5178ce2aa20db97e8a741c7c9569515546a2d3089406f41a9d040fe', + size: 33332, + sha256: 'dc86c6d4c88dc8273272b70d2014d2b62c80ae7368c9cf1e8eb78440f5fcc9c4', }, { packageName: '@opencoven/cave-client', releaseFile: 'tarballs/cave/opencoven-cave-client-0.1.0.tgz', vendorFile: 'cave-client-0.1.0.tgz', - size: 81543, - sha256: 'c44544adf8e712d6be1e8686788e63aa0133eb318274d1fb1926138a7da148c0', + size: 85426, + sha256: 'de16ce13f2e3be0f6555cfc4413ca3c8f8f1a94f980261a6857c025615e7a14a', }, { packageName: '@opencoven/coven-client', diff --git a/src/specification-guards.test.ts b/src/specification-guards.test.ts index 14b34c81..50699e1c 100644 --- a/src/specification-guards.test.ts +++ b/src/specification-guards.test.ts @@ -152,7 +152,7 @@ describe('Phase 1 specification guards', () => { expect(lock.sdk.releaseManifest).toEqual({ file: 'release-manifest.json', version: '0.1.0', - sha256: 'b8bfb62236fc8add4a9baad9f00e5401db15074a2d21fe2847a9158104cefb3c', + sha256: 'b248f2d945f77e22d0dee1644e9131aa7d2a20db2f30d06206a974d7a4262dec', }); expect(lock.sdk.artifacts).toEqual({ core: { @@ -160,16 +160,16 @@ describe('Phase 1 specification guards', () => { version: '0.1.0', releaseFile: 'tarballs/core/opencoven-sdk-core-0.1.0.tgz', vendorFile: 'sdk-core-0.1.0.tgz', - size: 33284, - sha256: '9a574e8bd5178ce2aa20db97e8a741c7c9569515546a2d3089406f41a9d040fe', + size: 33332, + sha256: 'dc86c6d4c88dc8273272b70d2014d2b62c80ae7368c9cf1e8eb78440f5fcc9c4', }, cave: { packageName: '@opencoven/cave-client', version: '0.1.0', releaseFile: 'tarballs/cave/opencoven-cave-client-0.1.0.tgz', vendorFile: 'cave-client-0.1.0.tgz', - size: 81543, - sha256: 'c44544adf8e712d6be1e8686788e63aa0133eb318274d1fb1926138a7da148c0', + size: 85426, + sha256: 'de16ce13f2e3be0f6555cfc4413ca3c8f8f1a94f980261a6857c025615e7a14a', }, coven: { packageName: '@opencoven/coven-client', diff --git a/vendor/opencoven-sdk/cave-client-0.1.0.tgz b/vendor/opencoven-sdk/cave-client-0.1.0.tgz index ca3888ad..8df95547 100644 Binary files a/vendor/opencoven-sdk/cave-client-0.1.0.tgz and b/vendor/opencoven-sdk/cave-client-0.1.0.tgz differ diff --git a/vendor/opencoven-sdk/sdk-core-0.1.0.tgz b/vendor/opencoven-sdk/sdk-core-0.1.0.tgz index 18a3e458..84a81bcc 100644 Binary files a/vendor/opencoven-sdk/sdk-core-0.1.0.tgz and b/vendor/opencoven-sdk/sdk-core-0.1.0.tgz differ