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
16 changes: 13 additions & 3 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,11 @@ jobs:
timeout-minutes: 20
run: npm run smoke:packaged-update-handoff -- --resources dist/win-unpacked/resources

- name: Bind PR installer to its tested source
run: |
$version = node -p "require('./package.json').version"
node scripts/pr-gui-candidate-source.cjs --directory dist --version $version

- name: Upload Windows PR package
uses: actions/upload-artifact@v4
with:
Expand All @@ -277,6 +282,7 @@ jobs:
dist/Kun-*-win-x64.exe
dist/Kun-*-win-x64.exe.blockmap
dist/latest.yml
dist/pr-candidate-source.json

gui-upgrade-windows:
name: GUI upgrade from released 0.3.7 (Windows)
Expand All @@ -294,21 +300,25 @@ jobs:
name: pr-windows-nsis-package
path: dist
- name: Verify GUI upgrade from the published 0.3.7 installer
env:
GUI_UPGRADE_SOURCE: pull-request
timeout-minutes: 60
run: |
$version = node -p "require('./package.json').version"
node scripts/smoke-gui-upgrade.cjs --directory dist --version $version --report gui-upgrade-win32.json

- name: Collect GUI upgrade diagnostics
if: always()
run: node scripts/gui-upgrade-diagnostics.cjs

- name: Upload GUI upgrade evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: pr-gui-upgrade-windows
path: |
gui-upgrade-win32.json*
${{ env.GUI_UPGRADE_EVIDENCE }}/kun-gui-upgrade-*/**
!${{ env.GUI_UPGRADE_EVIDENCE }}/kun-gui-upgrade-*/installed/**
!${{ env.GUI_UPGRADE_EVIDENCE }}/kun-gui-upgrade-*/**/*.exe
gui-upgrade-evidence/**
if-no-files-found: warn

pr-gate:
Expand Down
1 change: 1 addition & 0 deletions kun/scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function resolveElectronExecutable() {

const vitest = join(dirname(fileURLToPath(import.meta.url)), '..', 'node_modules', 'vitest', 'vitest.mjs')
const doctorFiles = [
'src/adapters/hybrid/hybrid-memory-distillation.test.ts',
'src/adapters/hybrid/hybrid-memory-store.test.ts',
'src/adapters/hybrid/hybrid-thread-store.test.ts',
'src/adapters/hybrid/hybrid-thread-index-write-failure.test.ts',
Expand Down
76 changes: 76 additions & 0 deletions kun/src/adapters/hybrid/hybrid-memory-distillation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { PendingMemoryCandidate } from '../../contracts/memory-distillation-runtime.js'
import { MemoryDistillationPendingStore } from '../../memory/memory-distillation-pending-store.js'
import { buildMemoryDistillationApplyReceipt } from '../../memory/memory-distillation-apply.js'
import { canonicalMemoryHash } from '../../memory/memory-record-normalizer.js'
import { HybridMemoryStore } from './hybrid-memory-store.js'

const cleanup: Array<() => Promise<void>> = []
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) await close()
})

async function setup(action: 'update' | 'supersede') {
const dataDir = await mkdtemp(join(tmpdir(), 'kun-hybrid-distillation-'))
cleanup.push(() => rm(dataDir, { recursive: true, force: true }))
let time = '2026-09-03T01:00:00.000Z'
const store = new HybridMemoryStore({ dataDir, nowIso: () => time,
config: { enabled: true, scopes: ['workspace'], maxInjectedRecords: 8,
distillation: { enabled: true } } })
cleanup.push(() => store.shutdown())
await store.ready()
const target = await store.createWithId('target', {
content: 'The user prefers long release notes.', scope: 'workspace', workspace: dataDir
})
const pending = new MemoryDistillationPendingStore({ dataDir, nowIso: () => time })
await pending.beginRun('thread', 'turn')
const [entry] = await pending.completeRun('thread', 'turn', [{
threadId: 'thread', turnId: 'turn', target: { scope: 'workspace', workspace: dataDir },
candidate: {
content: 'The user prefers concise release notes.', type: 'preference', tags: [],
confidence: 0.9, importance: 0.7, observedAt: time,
sources: [{ id: 'source', kind: 'user', trust: 'explicit-user', excerpt: 'Concise release notes.' }]
},
proposedAction: { action, memoryId: target.id, targetUpdatedAt: target.updatedAt,
targetFingerprint: canonicalMemoryHash(target) }
}])
const applying = await pending.transition(entry!.id, ['pending'], 'applying', {
applyReceipt: buildMemoryDistillationApplyReceipt(entry!)
})
return { store, dataDir, applying, advance: () => { time = '2026-09-03T02:00:00.000Z' } }
}

describe('atomic distillation with the SQLite Memory projection', () => {
it.each(['update', 'supersede'] as const)('projects and replays %s exactly once', async (action) => {
const h = await setup(action)
const record = await h.store.commitDistillation(h.applying)
await expect(h.store.commitDistillation(h.applying)).resolves.toEqual(record)
const diagnostics = await h.store.diagnostics()
expect(diagnostics.indexedCount).toBe(action === 'update' ? 1 : 2)
expect(diagnostics.indexedCount).toBe(diagnostics.canonicalCount)
const retrieved = await h.store.retrieve({ query: 'concise release notes', workspace: h.dataDir, limit: 8 })
expect(retrieved.map((entry) => entry.id)).toEqual([record.id])
})

it.each(['update', 'supersede'] as const)('preserves indexed manual edits before %s', async (action) => {
const h = await setup(action)
// Deliberately keep the same clock value: the fingerprint must detect this edit.
await h.store.update('target', { content: 'The user prefers exhaustive release notes.' }, { workspace: h.dataDir })
await expect(h.store.commitDistillation(h.applying)).rejects.toThrow(/changed after extraction/)
expect((await h.store.list({ workspace: h.dataDir }))[0]?.content)
.toBe('The user prefers exhaustive release notes.')
expect(await h.store.diagnostics()).toMatchObject({ canonicalCount: 1, indexedCount: 1 })
})

it('fails closed for old unapplied target proposals missing their fingerprint', async () => {
const h = await setup('update')
const old = structuredClone(h.applying)
delete (old.proposedAction as Extract<PendingMemoryCandidate['proposedAction'], { action: 'update' }>).targetFingerprint
await expect(h.store.commitDistillation(old)).rejects.toThrow(/changed after extraction/)
expect((await h.store.list({ workspace: h.dataDir }))[0]?.content)
.toBe('The user prefers long release notes.')
})
})
3 changes: 2 additions & 1 deletion kun/src/adapters/hybrid/hybrid-memory-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ const roots: string[] = []
const policy: MemoryCapabilityConfig = {
enabled: true,
scopes: ['user', 'workspace', 'project'],
maxInjectedRecords: 8
maxInjectedRecords: 8,
distillation: { enabled: false }
}

afterEach(async () => {
Expand Down
13 changes: 13 additions & 0 deletions kun/src/adapters/hybrid/hybrid-memory-store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { PendingMemoryCandidate } from '../../contracts/memory-distillation-runtime.js'
import { mkdir } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import type { Database as BetterSqliteDatabase } from 'better-sqlite3'
Expand Down Expand Up @@ -99,6 +100,18 @@ export class HybridMemoryStore implements MemoryStore {
try { this.db?.close() } finally { this.db = null; this.index = null }
}

async commitDistillation(candidate: PendingMemoryCandidate): Promise<MemoryRecord> {
return this.enqueueMutation(async () => {
this.mutationGeneration += 1
await this.ready()
const record = await this.canonical.commitDistillation(candidate)
const superseded = candidate.proposedAction.action === 'supersede'
? [candidate.proposedAction.memoryId] : []
await this.projectCanonicalIds([record.id, ...superseded])
return record
})
}

async create(input: MemoryCreateRequest): Promise<MemoryRecord> {
return this.enqueueMutation(async () => {
this.mutationGeneration += 1
Expand Down
3 changes: 2 additions & 1 deletion kun/src/adapters/tool/memory-tool-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const roots: string[] = []
const policy: MemoryCapabilityConfig = {
enabled: true,
scopes: ['user', 'workspace', 'project'],
maxInjectedRecords: 8
maxInjectedRecords: 8,
distillation: { enabled: false }
}

afterEach(async () => {
Expand Down
5 changes: 4 additions & 1 deletion kun/src/contracts/capabilities-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ export type AttachmentsCapabilityConfig = z.infer<typeof AttachmentsCapabilityCo

export const MemoryCapabilityConfig = CapabilityToggleConfig.extend({
scopes: z.array(z.enum(['user', 'workspace', 'project'])).default(['user', 'workspace', 'project']),
maxInjectedRecords: z.number().int().positive().default(8)
maxInjectedRecords: z.number().int().positive().default(8),
distillation: z.object({
enabled: z.boolean().default(false)
}).strict().default(() => ({ enabled: false }))
}).strict()
export type MemoryCapabilityConfig = z.infer<typeof MemoryCapabilityConfig>

Expand Down
2 changes: 1 addition & 1 deletion kun/src/contracts/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ export const UsageEvent = RuntimeEventBase.extend({
model: z.string().optional(),
providerId: z.string().min(1).optional(),
accountId: z.string().min(1).optional(),
attribution: z.enum(['agent-turn', 'approval-review']).optional(),
attribution: z.enum(['agent-turn', 'approval-review', 'memory-distillation']).optional(),
usage: UsageSnapshotSchema
})
export type UsageEvent = z.infer<typeof UsageEvent>
Expand Down
1 change: 1 addition & 0 deletions kun/src/contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export * from './runtime-config.js'
export * from './tool-output-limits.js'
export * from './memory.js'
export * from './memory-distillation.js'
export * from './memory-distillation-runtime.js'
export * from './model-endpoint-format.js'
export * from './extension-providers.js'
export * from './migrations.js'
Expand Down
132 changes: 132 additions & 0 deletions kun/src/contracts/memory-distillation-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { z } from 'zod'
import {
MemoryCandidate,
MemoryCandidateDraft,
MemoryCandidateDurability,
MemoryCandidateRelation
} from './memory-distillation.js'

export const MEMORY_DISTILLATION_STORE_VERSION = 1 as const
export const MEMORY_DISTILLATION_MAX_CANDIDATES = 8
export const MEMORY_DISTILLATION_PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1_000

export const MemoryDistillationExtractionCandidate = MemoryCandidateDraft.extend({
durability: MemoryCandidateDurability,
comparisons: z.array(z.object({
memoryId: z.string().min(1).max(256),
relation: MemoryCandidateRelation
}).strict()).max(8).default([])
}).strict()
export type MemoryDistillationExtractionCandidate = z.infer<
typeof MemoryDistillationExtractionCandidate
>

export const MemoryDistillationExtractionResponse = z.object({
candidates: z.array(MemoryDistillationExtractionCandidate)
.max(MEMORY_DISTILLATION_MAX_CANDIDATES)
}).strict()
export type MemoryDistillationExtractionResponse = z.infer<
typeof MemoryDistillationExtractionResponse
>

export const MemoryDistillationTarget = z.object({
scope: z.literal('workspace'),
workspace: z.string().min(1)
}).strict()
export type MemoryDistillationTarget = z.infer<typeof MemoryDistillationTarget>

export const MemoryDistillationProposedAction = z.discriminatedUnion('action', [
z.object({ action: z.literal('create') }).strict(),
z.object({
action: z.literal('update'),
memoryId: z.string().min(1),
targetUpdatedAt: z.string().min(1).max(128),
targetFingerprint: z.string().regex(/^[a-f0-9]{64}$/).optional()
}).strict(),
z.object({
action: z.literal('supersede'),
memoryId: z.string().min(1),
targetUpdatedAt: z.string().min(1).max(128),
targetFingerprint: z.string().regex(/^[a-f0-9]{64}$/).optional()
}).strict()
])
export type MemoryDistillationProposedAction = z.infer<
typeof MemoryDistillationProposedAction
>

export const MemoryDistillationCandidateStatus = z.enum([
'pending',
'applying',
'allowed',
'denied',
'timed-out',
'expired',
'withdrawn',
'conflicted',
'failed'
])
export type MemoryDistillationCandidateStatus = z.infer<
typeof MemoryDistillationCandidateStatus
>

export const MemoryDistillationHistoryEntry = z.object({
status: MemoryDistillationCandidateStatus,
at: z.string().datetime(),
reason: z.string().min(1).max(512).optional()
}).strict()

export const MemoryDistillationApplyReceipt = z.object({
operationId: z.string().min(1).max(128),
expectedMemoryId: z.string().min(1).max(256),
targetUpdatedAt: z.string().min(1).max(128).optional()
}).strict()
export type MemoryDistillationApplyReceipt = z.infer<
typeof MemoryDistillationApplyReceipt
>

export const PendingMemoryCandidate = z.object({
schemaVersion: z.literal(MEMORY_DISTILLATION_STORE_VERSION),
id: z.string().min(1),
fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
threadId: z.string().min(1),
turnId: z.string().min(1),
target: MemoryDistillationTarget,
candidate: MemoryCandidate,
proposedAction: MemoryDistillationProposedAction,
status: MemoryDistillationCandidateStatus,
createdAt: z.string().datetime(),
expiresAt: z.string().datetime(),
history: z.array(MemoryDistillationHistoryEntry).min(1),
applyReceipt: MemoryDistillationApplyReceipt.optional(),
memoryId: z.string().min(1).optional()
}).strict()
export type PendingMemoryCandidate = z.infer<typeof PendingMemoryCandidate>

export const MemoryDistillationRunStatus = z.enum(['processing', 'completed', 'failed'])
export const MemoryDistillationRun = z.object({
schemaVersion: z.literal(MEMORY_DISTILLATION_STORE_VERSION),
id: z.string().min(1),
threadId: z.string().min(1),
turnId: z.string().min(1),
status: MemoryDistillationRunStatus,
createdAt: z.string().datetime(),
completedAt: z.string().datetime().optional(),
candidateCount: z.number().int().nonnegative().max(MEMORY_DISTILLATION_MAX_CANDIDATES)
.optional(),
diagnostic: z.string().min(1).max(512).optional()
}).strict()
export type MemoryDistillationRun = z.infer<typeof MemoryDistillationRun>

export const MemoryDistillationStoreState = z.object({
schemaVersion: z.literal(MEMORY_DISTILLATION_STORE_VERSION),
runs: z.array(MemoryDistillationRun).default([]),
candidates: z.array(PendingMemoryCandidate).default([])
}).strict()
export type MemoryDistillationStoreState = z.infer<typeof MemoryDistillationStoreState>

export const MemoryDistillationDecisionRequest = z.object({
decision: z.enum(['allow', 'deny', 'withdraw'])
}).strict()
export type MemoryDistillationDecisionRequest = z.infer<
typeof MemoryDistillationDecisionRequest
>
39 changes: 39 additions & 0 deletions kun/src/contracts/memory-distillation-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { z } from 'zod'
import { MemoryCandidate } from './memory-distillation.js'
import { MemoryRecord } from './memory.js'
import {
MemoryDistillationApplyReceipt,
MemoryDistillationCandidateStatus,
MemoryDistillationProposedAction,
MemoryDistillationTarget
} from './memory-distillation-runtime.js'

const id = z.string().min(1).max(256)
export const MemoryDistillationCandidateInsert = z.object({
threadId: id, turnId: id, target: MemoryDistillationTarget,
candidate: MemoryCandidate, proposedAction: MemoryDistillationProposedAction
}).strict()
const turn = { threadId: id, turnId: id }
export const MemoryDistillationPendingRequest = z.discriminatedUnion('operation', [
z.object({ operation: z.literal('ready') }).strict(),
z.object({ operation: z.literal('beginRun'), ...turn }).strict(),
z.object({ operation: z.literal('completeRun'), ...turn,
inserts: z.array(MemoryDistillationCandidateInsert).max(8) }).strict(),
z.object({ operation: z.literal('failRun'), ...turn,
diagnostic: z.string().max(512) }).strict(),
z.object({ operation: z.literal('list'), workspace: z.string().min(1).optional(),
status: MemoryDistillationCandidateStatus.optional() }).strict(),
z.object({ operation: z.literal('get'), id }).strict(),
z.object({ operation: z.literal('expireDue') }).strict(),
z.object({ operation: z.literal('transition'), id,
from: z.array(MemoryDistillationCandidateStatus).min(1).max(10),
to: MemoryDistillationCandidateStatus,
options: z.object({ reason: z.string().max(512).optional(), memoryId: id.optional(),
applyReceipt: MemoryDistillationApplyReceipt.optional() }).strict()
}).strict()
])

export const MemoryDistillationCommitResult = z.discriminatedUnion('ok', [
z.object({ ok: z.literal(true), record: MemoryRecord }).strict(),
z.object({ ok: z.literal(false), conflict: z.string().min(1).max(512) }).strict()
])
Loading
Loading