Skip to content
Open
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ TestResults.xml
.kun-canvas/
.kunsdd/
openspec/
.codegraph/
.claude/settings.json
.cursor/mcp.json
.mcp.json

### Internal docs
docs/R2_RELEASE.md
3 changes: 2 additions & 1 deletion kun/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@
"memory": {
"enabled": false,
"scopes": ["user", "workspace", "project"],
"maxInjectedRecords": 8
"maxInjectedRecords": 8,
"minConfidence": 0.2
},
"computerUse": {
"enabled": false,
Expand Down
4 changes: 2 additions & 2 deletions kun/src/adapters/tool/memory-tool-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function buildMemoryToolProviders(store: MemoryStore | undefined): Capabi
...(args.scope === 'project' ? { project: context.workspace } : {}),
sourceThreadId: context.threadId,
sourceTurnId: context.turnId,
provenance: { kind: 'user', turnId: context.turnId, origin: 'memory_create' },
provenance: { kind: 'tool', turnId: context.turnId, origin: 'memory_create' },
...(typeof args.ttlDays === 'number' && Number.isFinite(args.ttlDays) && args.ttlDays > 0
? { ttlMs: Math.round(args.ttlDays * 24 * 60 * 60 * 1_000) }
: {}),
Expand Down Expand Up @@ -78,7 +78,7 @@ export function buildMemoryToolProviders(store: MemoryStore | undefined): Capabi
memory: await store.update(args.id, {
...(typeof args.content === 'string' ? { content: args.content } : {}),
...(typeof args.disabled === 'boolean' ? { disabled: args.disabled } : {})
}, { workspace: context.workspace })
}, { workspace: context.workspace, source: 'agent' })
}
}
}
Expand Down
15 changes: 11 additions & 4 deletions kun/src/contracts/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,9 +414,14 @@ export const AttachmentsCapabilityConfig = CapabilityToggleConfig.extend({
}).strict()
export type AttachmentsCapabilityConfig = z.infer<typeof AttachmentsCapabilityConfig>

export const DEFAULT_MEMORY_MAX_INJECTED_RECORDS = 8
export const DEFAULT_SCOPES = ['user', 'workspace', 'project'] as const
export const DEFAULT_MEMORY_MIN_CONFIDENCE = 0.2

export const MemoryCapabilityConfig = CapabilityToggleConfig.extend({
scopes: z.array(z.enum(['user', 'workspace', 'project'])).default(['user', 'workspace', 'project']),
maxInjectedRecords: z.number().int().positive().default(8)
scopes: z.array(z.enum(['user', 'workspace', 'project'])).default([...DEFAULT_SCOPES]),
maxInjectedRecords: z.number().int().positive().default(DEFAULT_MEMORY_MAX_INJECTED_RECORDS),
minConfidence: z.number().min(0).max(1).default(DEFAULT_MEMORY_MIN_CONFIDENCE)
}).strict()
export type MemoryCapabilityConfig = z.infer<typeof MemoryCapabilityConfig>

Expand Down Expand Up @@ -625,7 +630,8 @@ export const RuntimeCapabilityManifest = z
}).strict(),
memory: RuntimeCapabilityState.extend({
scopes: z.array(z.enum(['user', 'workspace', 'project'])),
maxInjectedRecords: z.number().int().positive()
maxInjectedRecords: z.number().int().positive(),
minConfidence: z.number().min(0).max(1)
}).strict(),
imageGen: RuntimeCapabilityState.extend({
model: z.string().optional()
Expand Down Expand Up @@ -827,7 +833,8 @@ export function buildRuntimeCapabilityManifest(input: {
input.memory?.reason ?? 'memory store is unavailable'
),
scopes: config.memory.scopes,
maxInjectedRecords: config.memory.maxInjectedRecords
maxInjectedRecords: config.memory.maxInjectedRecords,
minConfidence: config.memory.minConfidence
},
imageGen: {
...providerCapabilityState(
Expand Down
1 change: 1 addition & 0 deletions kun/src/contracts/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const MemoryRecord = z.object({
provenance: MemoryProvenance.optional(),
tags: z.array(z.string()).default([]),
confidence: z.number().min(0).max(1).default(1),
anchored: z.boolean().default(false),
createdAt: z.string(),
updatedAt: z.string(),
expiresAt: z.string().datetime().optional(),
Expand Down
1 change: 1 addition & 0 deletions kun/src/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './usage.js'
export * from './session.js'
export * from './model-history-repair.js'
export * from './runtime-event-reducer.js'
export * from './memory-scoring.js'
19 changes: 19 additions & 0 deletions kun/src/domain/memory-scoring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { ngrams } from './memory-scoring.js'

describe('ngrams', () => {
it('tokenizes ASCII words into trigrams', () => {
expect(Array.from(ngrams('fix error')).sort()).toEqual(['err', 'fix', 'ror', 'rro'])
})

it('lowercases and drops words shorter than 3 chars', () => {
// "TS" (2 chars) is below the 3-char word boundary and dropped entirely.
expect(Array.from(ngrams('Fix TS ERROR')).sort()).toEqual(['err', 'fix', 'ror', 'rro'])
})

it('splits a short CJK continuation into a single bigram', () => {
const grams = ngrams('继续')
expect(Array.from(grams).sort()).toEqual(['继续'])
expect(grams.size).toBe(1)
})
})
25 changes: 25 additions & 0 deletions kun/src/domain/memory-scoring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Produce a fingerprint of overlapping n-grams for a string. ASCII/Latin
* segments are tokenized on word boundaries and down to trigrams, while CJK
* runs are split into bigrams. Lower-cased, de-spaced. This keeps matching
* language-agnostic without pulling in a tokenizer dependency.
*/
export function ngrams(input: string): Set<string> {
const grams = new Set<string>()
const normalized = input.toLowerCase()
// Pull out ASCII words (letters/digits/underscore) and CJK runs separately.
const asciiWords = normalized.match(/[a-z0-9_]{3,}/g) ?? []
for (const word of asciiWords) {
for (let i = 0; i + 3 <= word.length; i += 1) {
grams.add(word.slice(i, i + 3))
}
}
const cjkRuns = normalized.match(/[一-鿿぀-ヿ가-힯]+/g) ?? []
for (const run of cjkRuns) {
for (let i = 0; i + 2 <= run.length; i += 1) {
grams.add(run.slice(i, i + 2))
}
if (run.length < 2) grams.add(run)
}
return grams
}
93 changes: 90 additions & 3 deletions kun/src/loop/turn-context-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import type { TurnItem } from '../contracts/items.js'
import type { ThreadRecord } from '../contracts/threads.js'
import type { Turn } from '../contracts/turns.js'
import type { MemoryRecord } from '../contracts/memory.js'
Expand Down Expand Up @@ -74,11 +75,11 @@ describe('TurnContextResolver', () => {
name: 'create_plan', description: 'Create plan', inputSchema: {}, providerId: 'gui'
}]
})
const retrieve = vi.fn(async (): Promise<MemoryRecord[]> => {
const retrieve = vi.fn<MemoryStore['retrieve']>(async (_input) => {
resolutionOrder.push('memories')
return [{
id: 'memory_1', content: 'Prefer tests', scope: 'workspace',
tags: [], confidence: 1,
tags: [], confidence: 1, anchored: false,
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z'
}]
})
Expand Down Expand Up @@ -150,6 +151,11 @@ describe('TurnContextResolver', () => {
tools: [expect.objectContaining({ name: 'create_plan' })]
})
expect(setLastInjected).toHaveBeenCalledWith(['memory_1'])
expect(retrieve).toHaveBeenCalledWith(expect.objectContaining({
query: 'Implement the requested plan',
workspace: '/workspace'
}))
expect(retrieve.mock.calls[0]?.[0]).not.toHaveProperty('limit')
expect(resolutionOrder).toEqual(['attachments', 'skills', 'instructions', 'memories', 'tools'])
expect(listTools).toHaveBeenCalledWith(expect.objectContaining({
guiPlan: expect.objectContaining({ planId: 'plan_1' }),
Expand Down Expand Up @@ -275,7 +281,8 @@ describe('TurnContextResolver', () => {
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
tags: [],
confidence: 1
confidence: 1,
anchored: false
}]),
setLastInjected: vi.fn()
}
Expand All @@ -284,4 +291,84 @@ describe('TurnContextResolver', () => {
})
expect(currentMemoryStore.setLastInjected).toHaveBeenCalledWith(['memory_live'])
})

it('composes the retrieval query from the active goal and recent history', async () => {
const retrieve = vi.fn<MemoryStore['retrieve']>(async () => [])
const resolver = new TurnContextResolver({
toolHost: { listTools: async () => [] },
resolveAttachments: async () => ({ imageAttachments: [], textFallbacks: [], documents: [] }),
memoryStore: { retrieve, setLastInjected: vi.fn() },
interactiveToolBridge: { awaitUserInput: async () => ({ status: 'cancelled' }) }
})
const goalThread = thread({
goal: {
threadId: 'thread_1',
objective: 'Refactor the build pipeline to use pnpm',
status: 'active',
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
})
const history: TurnItem[] = [
{ id: 'item_1', turnId: 'turn_1', threadId: 'thread_1', role: 'user', status: 'completed', createdAt: '2026-01-01T00:00:00.000Z', kind: 'user_message', text: 'Switch the frontend to Vite 5' },
{ id: 'item_2', turnId: 'turn_1', threadId: 'thread_1', role: 'assistant', status: 'completed', createdAt: '2026-01-01T00:00:01.000Z', kind: 'assistant_text', text: 'I checked the package manager config' },
{ id: 'item_3', turnId: 'turn_1', threadId: 'thread_1', role: 'tool', status: 'completed', createdAt: '2026-01-01T00:00:02.000Z', kind: 'tool_result', toolName: 'read', callId: 'call_1', toolKind: 'tool_call', output: 'package.json uses pnpm', isError: false }
]
await resolver.resolve({
threadId: 'thread_1',
turnId: 'turn_1',
thread: goalThread,
turn: turn({ attachmentIds: [] }),
history,
model: 'model_1',
modelCapabilities: capabilities(['text']),
signal: new AbortController().signal,
mode: resolveTurnModeContext({ turn: turn(), workspace: '/workspace', threadMode: 'agent' }),
goalNoToolRecoverySteps: 0
})
const called = retrieve.mock.calls[0]?.[0]
expect(called?.query).toContain('Implement the requested plan')
expect(called?.query).toContain('Refactor the build pipeline')
expect(called?.query).toContain('Vite 5')
expect(called?.query).toContain('package.json uses pnpm')
expect(called?.allowRecencyFallback).toBe(false)
})

it('flags degenerate prompts for the store recency fallback', async () => {
const retrieve = vi.fn<MemoryStore['retrieve']>(async () => [])
const resolver = new TurnContextResolver({
toolHost: { listTools: async () => [] },
resolveAttachments: async () => ({ imageAttachments: [], textFallbacks: [], documents: [] }),
memoryStore: { retrieve, setLastInjected: vi.fn() },
interactiveToolBridge: { awaitUserInput: async () => ({ status: 'cancelled' }) }
})
const goalThread = thread({
goal: {
threadId: 'thread_1',
objective: 'Refactor the build pipeline to use pnpm',
status: 'active',
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
})
await resolver.resolve({
threadId: 'thread_1',
turnId: 'turn_1',
thread: goalThread,
turn: turn({ prompt: '继续', attachmentIds: [] }),
history: [],
model: 'model_1',
modelCapabilities: capabilities(['text']),
signal: new AbortController().signal,
mode: resolveTurnModeContext({ turn: turn(), workspace: '/workspace', threadMode: 'agent' }),
goalNoToolRecoverySteps: 0
})
const called = retrieve.mock.calls[0]?.[0]
expect(called?.query).toMatch(/^继续 /)
expect(called?.allowRecencyFallback).toBe(true)
})
})
61 changes: 56 additions & 5 deletions kun/src/loop/turn-context-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ModelCapabilityMetadata } from '../contracts/capabilities.js'
import type { TurnItem } from '../contracts/items.js'
import type { MemoryRecord } from '../contracts/memory.js'
import type { ThreadRecord } from '../contracts/threads.js'
import type { ThreadGoal, ThreadRecord } from '../contracts/threads.js'
import type { Turn } from '../contracts/turns.js'
import type { ActingTurnModelRoute } from '../contracts/turns.js'
import type { TurnClientSurface } from '../contracts/turns.js'
Expand All @@ -11,6 +12,7 @@ import {
DEFAULT_SANDBOX_MODE
} from '../contracts/policy.js'
import type { InstructionRuntime, InstructionTurnResolution } from '../instructions/instruction-runtime.js'
import { ngrams } from '../domain/memory-scoring.js'
import type { MemoryStore } from '../memory/memory-store.js'
import type { GuiPlanContext, ToolHost, ToolHostContext } from '../ports/tool-host.js'
import type { SkillRuntime, SkillTurnResolution } from '../skills/skill-runtime.js'
Expand All @@ -24,6 +26,7 @@ import {
todoContinuationInstruction
} from './continuation-instructions.js'
import { isStalePlanContext } from './plan-mode.js'
import { toolResultTextWithoutImages } from './tool-result-image.js'
import { createToolDiscoveryContext } from './tool-discovery-context-factory.js'
import type {
PreparedTurnContext,
Expand All @@ -43,6 +46,10 @@ const EMPTY_INSTRUCTION_RESOLUTION: InstructionTurnResolution = {
injectedBytes: 0
}

const MEMORY_QUERY_TOTAL_BUDGET = 1200
const MEMORY_QUERY_FRAGMENT_BUDGET = 200
const MEMORY_QUERY_TOOL_BUDGET = 300

/** Stable, policy-relevant identity of a turn before resolving its schemas. */
export type TurnModeContext = Readonly<{
dedicatedSvgTurn: boolean
Expand Down Expand Up @@ -132,7 +139,9 @@ export class TurnContextResolver {
Promise.resolve(EMPTY_INSTRUCTION_RESOLUTION),
retrieveMemories(memoryStore, {
prompt: input.turn.prompt,
workspace
workspace,
threadGoal: input.thread.goal,
history: input.history
})
])
const planTurnActive = !input.mode.dedicatedSvgTurn && !input.mode.planContextStale && (
Expand Down Expand Up @@ -289,18 +298,60 @@ export function resolveTurnModeContext(input: {

async function retrieveMemories(
memoryStore: TurnContextResolverDeps['memoryStore'],
input: { prompt: string; workspace: string }
input: { prompt: string; workspace: string; threadGoal?: ThreadGoal; history?: readonly TurnItem[] }
): Promise<MemoryRecord[]> {
if (!memoryStore) return []
const memories = await memoryStore.retrieve({
query: input.prompt,
query: buildMemoryQuery(input.prompt, input.threadGoal, input.history),
workspace: input.workspace,
limit: 8
// A degenerate prompt like "继续" produces a single n-gram and scores the
// workspace/project pool empty; the store then falls back to recently
// updated records. Longer prompts keep scored retrieval.
allowRecencyFallback: ngrams(input.prompt).size < 2
})
memoryStore.setLastInjected(memories.map((memory) => memory.id))
return memories
}

/**
* Compose the retrieval query from the turn prompt plus recent thread context.
* The query is consumed only by the store's n-gram scoring and never rendered
* into the model request, so widening it is free in prompt tokens. Continuation
* turns ("继续") that share zero n-grams with stored content otherwise score
* the workspace/project pool empty.
*/
function buildMemoryQuery(
prompt: string,
goal: ThreadGoal | undefined,
history: readonly TurnItem[] | undefined
): string {
const parts: string[] = [prompt]
if (goal?.status === 'active') parts.push(goal.objective)
if (history) {
const lastUser = lastItemOfKind(history, 'user_message')
if (lastUser?.text) parts.push(lastUser.text.slice(0, MEMORY_QUERY_FRAGMENT_BUDGET))
const lastAssistant = lastItemOfKind(history, 'assistant_text')
if (lastAssistant?.text) parts.push(lastAssistant.text.slice(0, MEMORY_QUERY_FRAGMENT_BUDGET))
const lastTool = lastItemOfKind(history, 'tool_result')
if (lastTool) {
const summary = toolResultTextWithoutImages(lastTool.output)
if (summary) parts.push(summary.slice(0, MEMORY_QUERY_TOOL_BUDGET))
}
}
return parts.filter(Boolean).join(' ').slice(0, MEMORY_QUERY_TOTAL_BUDGET)
}

function lastItemOfKind<K extends 'user_message' | 'assistant_text' | 'tool_result'>(
history: readonly TurnItem[],
kind: K
): (TurnItem & { kind: K }) | undefined {
for (let i = history.length - 1; i >= 0; i -= 1) {
const item = history[i]
if (item?.kind === kind) return item as TurnItem & { kind: K }
}
return undefined
}

function normalizeApprovalPolicy(value: string | undefined): ToolHostContext['approvalPolicy'] {
switch (value) {
case 'on-request':
Expand Down
7 changes: 6 additions & 1 deletion kun/src/manager/remote-data-stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,12 @@ export class ManagerRemoteMemoryStore implements MemoryStore {
return MemoryRecord.array().parse(await this.call('list', filter))
}

async retrieve(input: { query: string; workspace?: string; limit: number }) {
async retrieve(input: {
query: string
workspace?: string
limit?: number
allowRecencyFallback?: boolean
}) {
return MemoryRecord.array().parse(await this.call('retrieve', input))
}

Expand Down
Loading