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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<p align="center">
<strong>One sourced answer from the context your team already has.</strong><br />
ContextCake resolves company policy, team practice, and personal judgment into an effective knowledge graph for people and AI agents.
ContextCake links company policy, team practice, and personal judgment through shared OKF identities, then resolves them into an effective knowledge graph for people and AI agents.
</p>

<p align="center">
Expand Down Expand Up @@ -43,13 +43,13 @@ The app is Apple silicon (arm64) only. On an Intel Mac or Linux, run the engine

Teams do not have one source of truth. They have an org policy, a service runbook, a team decision, and the local note that explains the exception. Flattening those into another wiki loses both the useful detail and the disagreement.

ContextCake keeps each scope separate, then resolves them at read time. The result is an answer an agent can use **with its source, date, and contradictions intact**.
ContextCake keeps each scope separate, then resolves them at read time. The result is an answer an agent can use **with its source, date, and structural discrepancies intact**.

| What you need | What ContextCake does |
| --- | --- |
| Local nuance without copying every policy | Higher-priority layers override only the sections they address. Everything else inherits. |
| An AI agent that can explain its answer | Returns provenance for frontmatter and every resolved section. |
| A safe way through disagreement | Clears formatting-only conflicts automatically; asks a direct question when meaning changes; keeps the original answers and every decision in append-only local history. |
| A safe way through disagreement | Surfaces structural discrepancies with complete evidence; applies explicit decisions transactionally; suggests governed rules only after repeated, consistent choices. |
| Knowledge from more than one system | Layers any folder of Markdown, any GitHub repository, local [OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundles, and trusted foreign MCP graphs in one cascade. |

## The layer cake
Expand Down
4 changes: 3 additions & 1 deletion apps/console/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ describe('Mac-first application shell', () => {
await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: '5', metaKey: true, bubbles: true })))
expect(container.querySelector('[data-destination="review"]')?.getAttribute('aria-current')).toBe('page')
expect(button('Queue 3')).toBeTruthy()
expect(button('Conflicts 3')).toBeTruthy()
expect(button('Discrepancies 3')).toBeTruthy()
expect(container.textContent).toContain('Simulation—no files will change.')
expect(container.textContent).toContain('Automatic rules never run.')
expect(container.querySelector('[aria-live="polite"]')?.textContent).toBe('')
})

Expand Down
8 changes: 7 additions & 1 deletion apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export function App() {
{ id: 'files', label: 'Go to Knowledge: Files', keywords: 'markdown documents', shortcut: '⇧⌘F', run: () => setView('files') },
{ id: 'sources', label: 'Go to Sources', shortcut: '⌘4', run: () => setView('sources') },
{ id: 'queue', label: 'Go to Review: Queue', keywords: 'triage', run: () => setView('triage') },
{ id: 'conflicts', label: 'Go to Review: Conflicts', keywords: 'resolve', run: () => setView('conflicts') },
{ id: 'conflicts', label: 'Go to Review: Discrepancies', keywords: 'resolve align', run: () => setView('conflicts') },
// One per source: the palette is the keyboard route into the navigator,
// matching the Sources panel's "Browse files" button — including in the
// demo, where that button is offered too. Browsing is a read.
Expand Down Expand Up @@ -378,6 +378,12 @@ export function App() {
onAddSource={mode === 'live' ? reopenWizard : undefined}
onConnectAgent={isDesktop && !needsSetup ? openConnect : undefined}
/>
{mode === 'demo' && (
<div className="cc-global-simulation" role="status">
<strong>Simulation—no files will change.</strong>
<span>Actions and history reset on reload. Automatic rules never run.</span>
</div>
)}
<div className="sr-only" aria-live="polite">
{backgroundAnnouncement}
</div>
Expand Down
132 changes: 130 additions & 2 deletions apps/console/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

import demoBundleRaw from './generated/demo-cascade.json'
import type {
ConflictResolutionRecord, DemoBundle, GraphSummary, GraphSource, ResolveConflictRequest,
ConflictResolutionRecord, DemoBundle, DiscrepanciesResponse, DiscrepancyDecisionRequest, DiscrepancyRecord,
DiscrepancyRule, DiscrepancyRuleSuggestion, GraphSummary, GraphSource, ResolveConflictRequest,
ResolvedConcept, ResolvedSection, SourceStatus, StatusSummary,
} from './types'
import type { Concept, ConceptSection, Conflict, Dissent, Source } from './data'
Expand Down Expand Up @@ -60,6 +61,13 @@ export interface DataSource {
status(): Promise<StatusSummary | null>
conflictResolutions(): Promise<ConflictResolutionRecord[]>
resolveConflict(request: ResolveConflictRequest): Promise<ConflictResolutionRecord>
discrepancies(): Promise<DiscrepanciesResponse | null>
decideDiscrepancy(request: DiscrepancyDecisionRequest): Promise<ConflictResolutionRecord>
discrepancyRules(): Promise<{ rules: DiscrepancyRule[]; suggestions: DiscrepancyRuleSuggestion[] }>
createDiscrepancyRule(suggestionId: string): Promise<DiscrepancyRule>
patchDiscrepancyRule(id: string, changes: { mode?: 'recommend' | 'automatic'; enabled?: boolean }): Promise<DiscrepancyRule>
promoteDiscrepancyRule(id: string, confirm: boolean): Promise<Record<string, unknown>>
setDiscrepancyPriority(id: string, priority: string): Promise<void>
}

// ---- Transport --------------------------------------------------------------
Expand Down Expand Up @@ -188,6 +196,42 @@ class DemoSource implements DataSource {
}
}
async conflictResolutions(): Promise<ConflictResolutionRecord[]> { return this.resolutions }
async discrepancies(): Promise<DiscrepanciesResponse> {
const conflicts = adaptConflicts(this.bundle.concepts, this.resolutions)
return {
discrepancies: conflicts.map((conflict) => legacyConflictRecord(conflict)),
coverageComplete: true, indexing: false, indexingSources: [], errors: [], generation: 1,
}
}
async decideDiscrepancy(request: DiscrepancyDecisionRequest): Promise<ConflictResolutionRecord> {
if (request.action !== 'choose_contribution' || !request.selectedSource) {
const current = (await this.discrepancies()).discrepancies.find((item) => item.id === request.discrepancyId)
if (!current) throw new LiveDataError('bad-status', 'This discrepancy is no longer open.', 409)
const chosen = request.action === 'compose'
? { layer: current.effectiveSource ?? current.contributions[0].source, content: request.content ?? '', updated: new Date().toISOString() }
: null
const record: ConflictResolutionRecord = {
schemaVersion: 2, id: `demo-${Date.now()}-${this.resolutions.length + 1}`,
conflictId: current.legacyId ?? current.id, discrepancyId: current.id,
conceptId: current.conceptId, title: current.conceptTitle, sectionKey: current.key,
sectionHeading: current.label,
contributions: current.contributions.map((item) => ({ layer: item.source, level: item.level, content: String(item.value), updated: item.updated })),
chosen, method: 'manual', actor: 'local-user', decidedAt: new Date().toISOString(),
action: request.action, reason: request.action === 'acknowledge' ? 'You kept this scoped difference.' : 'You wrote a reconciled answer.',
reasonCode: request.reasonCode, note: request.note,
transactionState: request.action === 'acknowledge' ? 'not_required' : 'committed', writtenTargets: [],
}
this.resolutions.push(record)
return record
}
const [, conceptId, sectionKey] = request.discrepancyId.split('::')
return this.resolveConflict({ conceptId, sectionKey, selectedLayer: request.selectedSource, method: 'manual' })
}
async discrepancyRules() { return { rules: [], suggestions: [] } }
async createDiscrepancyRule(): Promise<DiscrepancyRule> { throw new LiveDataError('bad-status', 'Simulation rules reset on reload.', 405) }
async patchDiscrepancyRule(): Promise<DiscrepancyRule> { throw new LiveDataError('bad-status', 'Automatic rules never run in simulation.', 405) }
async promoteDiscrepancyRule(): Promise<Record<string, unknown>> { throw new LiveDataError('bad-status', 'Simulation cannot promote team rules.', 405) }
async setDiscrepancyPriority(): Promise<void> { /* simulation-only local state is owned by the store */ }
async resolveConflict(request: ResolveConflictRequest): Promise<ConflictResolutionRecord> {
const prior = request.resolutionId
? this.resolutions.find((item) => item.id === request.resolutionId)
Expand Down Expand Up @@ -301,6 +345,41 @@ class LiveSource implements DataSource {
})
return response.resolution
}
async discrepancies(): Promise<DiscrepanciesResponse | null> {
try { return await this.get<DiscrepanciesResponse>('/api/discrepancies') }
catch (error) {
if (error instanceof LiveDataError && error.kind === 'bad-status' && error.status === 404) return null
throw error
}
}
async decideDiscrepancy(request: DiscrepancyDecisionRequest): Promise<ConflictResolutionRecord> {
return (await this.request<{ decision: ConflictResolutionRecord }>('/api/discrepancy-decisions', {
method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify(request),
})).decision
}
async discrepancyRules(): Promise<{ rules: DiscrepancyRule[]; suggestions: DiscrepancyRuleSuggestion[] }> {
return this.get('/api/discrepancy-rules')
}
async createDiscrepancyRule(suggestionId: string): Promise<DiscrepancyRule> {
return (await this.request<{ rule: DiscrepancyRule }>('/api/discrepancy-rules', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ suggestionId }),
})).rule
}
async patchDiscrepancyRule(id: string, changes: { mode?: 'recommend' | 'automatic'; enabled?: boolean }): Promise<DiscrepancyRule> {
return (await this.request<{ rule: DiscrepancyRule }>(`/api/discrepancy-rules?id=${encodeURIComponent(id)}`, {
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(changes),
})).rule
}
async promoteDiscrepancyRule(id: string, confirm: boolean): Promise<Record<string, unknown>> {
return this.request('/api/discrepancy-rules/promote', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, confirm }),
})
}
async setDiscrepancyPriority(id: string, priority: string): Promise<void> {
await this.request(`/api/discrepancies?id=${encodeURIComponent(id)}`, {
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ priority }),
})
}
private async get<T>(path: string): Promise<T> {
return this.request<T>(path, { headers: { accept: 'application/json' } })
}
Expand Down Expand Up @@ -697,11 +776,60 @@ export function adaptConflicts(concepts: ResolvedConcept[], resolutions: Conflic
section: headingText(latest.sectionHeading),
title: `${headingText(latest.sectionHeading)} — ${latest.title}`,
status: 'resolved',
winner: layerOf(latest.chosen.layer, latest.chosen.level ?? (latest.chosen.layer === 'personal' ? 3 : latest.chosen.layer === 'team' ? 2 : 0)),
winner: layerOf(latest.chosen?.layer ?? latest.contributions[0]?.layer ?? '', latest.chosen?.level ?? latest.contributions[0]?.level ?? 0),
contributions,
safe: false,
history,
})
}
return out
}

function legacyConflictRecord(conflict: Conflict): DiscrepancyRecord {
return {
id: `section_content::${conflict.concept}::${conflict.sectionKey}`,
legacyId: conflict.id,
kind: 'section_content', originalKind: 'section_content',
conceptId: conflict.concept, conceptTitle: conflict.title, conceptType: 'concept',
key: conflict.sectionKey, label: conflict.section,
revision: `${conflict.id}:${conflict.history.length}`,
status: conflict.status === 'resolved' ? 'resolved' : 'needs_review',
contributions: conflict.contributions.map((item, index) => ({
source: item.sourceLayer, level: item.layer === 'personal' ? 3 : item.layer === 'team' ? 2 : 0,
updated: item.updated || null, value: item.value, fingerprint: `${conflict.id}:${index}`, effective: index === 0,
})),
effectiveSource: conflict.contributions[0]?.sourceLayer ?? null,
effectiveValue: conflict.contributions[0]?.value ?? '',
winnerReason: `${conflict.contributions[0]?.sourceLayer ?? 'The selected source'} wins by configured layer precedence.`,
owner: 'Unassigned', priority: 'unassigned', fresherDissent: conflict.contributions.some((item) => item.fresherDissent),
freshness: { effectiveUpdated: conflict.contributions[0]?.updated ?? null, newestUpdated: conflict.contributions[0]?.updated ?? null, hasNewerDissent: conflict.contributions.some((item) => item.fresherDissent) },
affectedLinks: [],
sourceHealth: conflict.contributions.map((item) => ({ source: item.sourceLayer, status: 'ok', error: null })),
history: conflict.history, matchingRules: [],
}
}

/** Raw professional discrepancy records → the existing navigator view model. */
export function adaptDiscrepancies(records: DiscrepancyRecord[], coverageComplete = true): Conflict[] {
return records.map((record) => {
const contributions = record.contributions.map((item) => ({
layer: layerOf(item.source, item.level), sourceLayer: item.source,
value: typeof item.value === 'string' ? item.value : JSON.stringify(item.value, null, 2),
updated: item.updated ?? '',
...(record.fresherDissent && !item.effective ? { fresherDissent: true } : {}),
}))
const effective = record.contributions.find((item) => item.effective) ?? record.contributions[0]
return {
id: record.id, concept: record.conceptId, sectionKey: record.key,
section: record.label, title: `${record.label} — ${record.conceptTitle}`,
status: record.status === 'resolved' ? 'resolved' : 'open',
winner: layerOf(effective?.source ?? '', effective?.level ?? 0),
contributions, safe: false, history: record.history,
kind: record.kind, discrepancyStatus: record.status, revision: record.revision,
owner: record.owner, priority: record.priority, winnerReason: record.winnerReason,
effectiveSource: record.effectiveSource, coverageComplete, sourceHealth: record.sourceHealth,
matchingRules: record.matchingRules, ruleConflict: record.ruleConflict, target: record.target,
affectedLinks: record.affectedLinks,
}
})
}
8 changes: 4 additions & 4 deletions apps/console/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function HeaderInner({
const destination = destinationForView(view)
const searchable = SEARCHABLE_VIEWS.has(view)
const queueCount = signals.filter((signal) => signal.route === 'review_required').length
const conflictCount = conflicts.filter((conflict) => conflict.status === 'open').length
const conflictCount = conflicts.filter((conflict) => ['needs_review', 'reopened', 'recommended', 'auto_ready', 'blocked'].includes(conflict.discrepancyStatus ?? (conflict.status === 'open' ? 'needs_review' : 'resolved'))).length

useEffect(() => {
const focus = () => search.current?.focus()
Expand All @@ -44,7 +44,7 @@ function HeaderInner({
{ value: 'concepts', label: 'Concepts' }, { value: 'files', label: 'Files' },
]} />}
{destination === 'review' && <SegmentedControl label="Review view" value={view as 'triage' | 'conflicts'} onChange={setView} options={[
{ value: 'triage', label: `Queue ${queueCount}` }, { value: 'conflicts', label: `Conflicts ${conflictCount}` },
{ value: 'triage', label: `Queue ${queueCount}` }, { value: 'conflicts', label: `Discrepancies ${conflictCount}` },
]} />}
</div>
<div className="cc-toolbar-actions">
Expand All @@ -57,8 +57,8 @@ function HeaderInner({
setQuery('')
}
}}
label={`Search ${view === 'triage' ? 'queue' : view}`}
placeholder={`Search ${view === 'triage' ? 'queue' : view}`}
label={`Search ${view === 'triage' ? 'queue' : view === 'conflicts' ? 'discrepancies' : view}`}
placeholder={`Search ${view === 'triage' ? 'queue' : view === 'conflicts' ? 'discrepancies' : view}`}
/>}
{/* Background work and its health, from every destination — a count
with no progress and no detail was the badge this replaces. */}
Expand Down
2 changes: 1 addition & 1 deletion apps/console/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function SidebarInner({ onOpenSettings, onNavigate }: { onOpenSettings?: () => v
if (view === 'triage' || view === 'conflicts') reviewView.current = view

const reviewCount = signals.filter((signal) => signal.route === 'review_required').length
+ conflicts.filter((conflict) => conflict.status === 'open').length
+ conflicts.filter((conflict) => ['needs_review', 'reopened', 'recommended', 'auto_ready', 'blocked'].includes(conflict.discrepancyStatus ?? (conflict.status === 'open' ? 'needs_review' : 'resolved'))).length
const sourceErrors = sources.filter((source) => source.status === 'degraded' || source.status === 'error').length

const go = (destination: ShellDestination) => {
Expand Down
15 changes: 14 additions & 1 deletion apps/console/src/data.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ConflictResolutionRecord } from './types'
import type { ConflictResolutionRecord, DiscrepancyKind, DiscrepancyStatus, DiscrepancyRule } from './types'
import type { LayerId, RouteId } from './theme'

export interface Layer {
Expand Down Expand Up @@ -66,6 +66,19 @@ export interface Conflict {
status: 'open' | 'resolved'; contributions: Contribution[]; winner: LayerId
safe: boolean
history: ConflictResolutionRecord[]
kind?: DiscrepancyKind
discrepancyStatus?: DiscrepancyStatus
revision?: string
owner?: string
priority?: string
winnerReason?: string
effectiveSource?: string | null
coverageComplete?: boolean
sourceHealth?: ({ source: string; status: string; error: string | null } | null)[]
matchingRules?: Pick<DiscrepancyRule, 'id' | 'scope' | 'mode' | 'action' | 'evidenceDecisionIds'>[]
ruleConflict?: boolean
target?: string
affectedLinks?: string[]
}

/** `sourceLayer` is the source's real name; `layer` is the lane it renders in. */
Expand Down
Loading