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
4 changes: 4 additions & 0 deletions packages/kiana-v6/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## Kiana Project


- Use `bun` as a package manager
1 change: 1 addition & 0 deletions packages/kiana-v6/CLAUDE.md
File renamed without changes.
48 changes: 47 additions & 1 deletion packages/kiana-v6/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ export class CodingAgent {
private abortController: AbortController
private streamCallbacks: Set<StreamCallback> = new Set()
private mcpInitialized = false
private isAborted = false
private currentAssistantMessageID: string | null = null
private currentAssistantParts: Part[] | null = null

constructor(config: CodingAgentConfig) {
this.config = config
Expand Down Expand Up @@ -244,6 +247,9 @@ export class CodingAgent {
* Generate a response (non-streaming) - still emits stream parts for compatibility
*/
async generate(params: GenerateParams): Promise<{ text: string; usage?: any }> {
// Reset abort flag for new generation
this.isAborted = false

// Initialize MCP servers if configured
await this.initializeMCP()

Expand All @@ -270,6 +276,10 @@ export class CodingAgent {
data: { sessionID: this.id },
})

// Clear current message tracking
this.currentAssistantMessageID = null
this.currentAssistantParts = null

this.saveSession()

return {
Expand All @@ -285,6 +295,9 @@ export class CodingAgent {
textStream: AsyncIterable<string>
text: Promise<string>
}> {
// Reset abort flag for new stream
this.isAborted = false

// Initialize MCP servers if configured
await this.initializeMCP()

Expand Down Expand Up @@ -387,6 +400,10 @@ export class CodingAgent {
data: { sessionID: self.id },
})

// Clear current message tracking
self.currentAssistantMessageID = null
self.currentAssistantParts = null

self.saveSession()
return text
})
Expand All @@ -401,9 +418,30 @@ export class CodingAgent {
* Abort the current generation
*/
abort(): void {
this.isAborted = true
this.abortController.abort()
this.abortController = new AbortController()

// Add interruption marker to current message if there is one
if (this.currentAssistantParts && this.currentAssistantMessageID) {
const interruptionPart: TextPart = {
id: generateId("part"),
sessionID: this.id,
messageID: this.currentAssistantMessageID,
type: "text",
text: "\n\n[Interrupted by user]",
synthetic: true,
}
this.currentAssistantParts.push(interruptionPart)

// Save the session with the interruption marker
this.saveSession()

// Clear current message tracking
this.currentAssistantMessageID = null
this.currentAssistantParts = null
}

this.emit({
type: "data-session-idle",
data: { sessionID: this.id },
Expand Down Expand Up @@ -500,6 +538,10 @@ export class CodingAgent {
const assistantParts: Part[] = []
this.messages.push({ info: assistantMessage, parts: assistantParts })

// Track current assistant message for interruption handling
this.currentAssistantMessageID = assistantMessageID
this.currentAssistantParts = assistantParts

// Build v6 tools
const aiTools = this.buildAITools(assistantMessageID, assistantParts)

Expand Down Expand Up @@ -532,7 +574,11 @@ export class CodingAgent {
model: this.config.model,
instructions,
tools: aiTools,
stopWhen: stepCountIs(this.config.maxSteps ?? 50),
stopWhen: [
stepCountIs(this.config.maxSteps ?? 50),
// Stop when abort is triggered (after current step completes)
() => self.isAborted,
],
maxRetries: this.config.maxRetries ?? 3,
providerOptions,
onStepFinish: (step) => {
Expand Down
114 changes: 89 additions & 25 deletions packages/kiana-v6/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import * as fs from "node:fs"
import * as readline from "node:readline"
import { loadConfig, writeConfigTemplate, normalizeConfig, getAvailableModels, type NormalizedConfig } from "./config.js"
import { loadConfig, loadGlobalConfig, writeConfigTemplate, saveGlobalConfig, GLOBAL_CONFIG_PATH, normalizeConfig, getAvailableModels, type Config, type NormalizedConfig } from "./config.js"
import { createLanguageModel } from "./provider.js"
import { CodingAgent, formatSSE, type StreamPart } from "./agent.js"
import { InteractiveInput } from "./interactive.js"
Expand Down Expand Up @@ -65,7 +65,9 @@ interface Args {
session?: string
log?: string
createConfig?: boolean
createGlobalConfig?: boolean
listModels?: boolean
listGlobalModels?: boolean
interactive: boolean
humanReadable: boolean
verbose: boolean
Expand Down Expand Up @@ -109,6 +111,15 @@ function parseArgs(argv: string[]): Args {
case "--create-config":
args.createConfig = true
break
case "--create-global-config":
args.createGlobalConfig = true
break
case "--list-models":
args.listModels = true
break
case "--list-global-models":
args.listGlobalModels = true
break
case "-i":
case "--interactive":
args.interactive = true
Expand Down Expand Up @@ -137,18 +148,20 @@ Kiana v6 - Minimal Headless Coding Agent (AI SDK UI Stream Protocol)
Usage:
kiana-v6 [options]

Options:
--config, -c Path to config file (default: ./kiana.jsonc)
--model, -m Select model from config (e.g., sonnet, grok)
--list-models List available models in config
--prompt, -p Send a single prompt and exit
--interactive, -i Interactive REPL mode (Enter sends, Ctrl+J newline)
--session, -s Session directory for persistence
--log, -l Log all events to file (JSONL format)
--create-config Generate a template config file
-H Human-readable output (instead of SSE)
-v, --verbose Show verbose output (tool inputs/outputs)
--help, -h Show help
Options:
--config, -c Path to config file (default: ./kiana.jsonc)
--model, -m Select model from config (e.g., sonnet, grok)
--list-models List available models in config
--list-global-models List available models in global config
--prompt, -p Send a single prompt and exit
--interactive, -i Interactive REPL mode (Enter sends, Ctrl+J newline)
--session, -s Session directory for persistence
--log, -l Log all events to file (JSONL format)
--create-config Generate a template config file
--create-global-config Generate global config template
-H Human-readable output (instead of SSE)
-v, --verbose Show verbose output (tool inputs/outputs)
--help, -h Show help

Examples:
# Interactive mode with default model
Expand All @@ -171,6 +184,16 @@ Interactive mode keybindings:
Ctrl+J Insert newline
ESC ESC Cancel current operation (double-tap within 2s)
Ctrl+C Exit interactive mode

Emacs-like editing:
Ctrl+A Move to beginning of line
Ctrl+E Move to end of line
Ctrl+B Move backward one character
Ctrl+F Move forward one character
Ctrl+U Delete from cursor to beginning of line
Ctrl+K Delete from cursor to end of line
Ctrl+W Delete word backwards
Arrow keys Left/Right cursor movement
`)
}

Expand Down Expand Up @@ -489,28 +512,69 @@ async function main(): Promise<void> {
process.exit(0)
}

if (args.createGlobalConfig) {
const template = writeConfigTemplate()
// Write to a temporary file and load it using loadConfig
const tempPath = `/tmp/kiana-global-config-template-${Date.now()}.jsonc`
fs.writeFileSync(tempPath, template, 'utf-8')
const config = loadConfig(tempPath)
fs.unlinkSync(tempPath)
saveGlobalConfig(config)
console.log(`Global config created at ${GLOBAL_CONFIG_PATH}`)
process.exit(0)
}

if (args.listGlobalModels) {
const globalConfig = loadGlobalConfig()
if (!globalConfig) {
console.log("No global config found")
process.exit(0)
}
const availableModels = getAvailableModels(globalConfig)
if (availableModels.length === 0) {
console.log("No models configured in global config")
} else {
console.log("Global config models:")
for (const modelName of availableModels) {
const defaultMarker = ("defaultModel" in globalConfig && globalConfig.defaultModel === modelName) ? " (default)" : ""
console.log(` - ${modelName}${defaultMarker}`)
}
}
process.exit(0)
}

if (args.createConfig) {
console.log(writeConfigTemplate())
process.exit(0)
}

// Default to ./kiana.jsonc if no config specified
const configPath = args.config || "./kiana.jsonc"

if (!fs.existsSync(configPath)) {
if (args.config) {
console.error(`Error: Config file not found: ${configPath}`)
} else {
console.error("Error: No config file found. Either:")
console.error(" - Create ./kiana.jsonc in the current directory")
console.error(" - Specify a config file with --config <path>")
console.error(" - Generate a template with --create-config > kiana.jsonc")
const hasLocalConfig = fs.existsSync(configPath)

// Check if we can proceed:
// 1. Local config exists, OR
// 2. No explicit --config flag AND global config exists
if (!hasLocalConfig) {
const globalConfig = loadGlobalConfig()
if (args.config || !globalConfig) {
// User explicitly specified a config that doesn't exist, OR
// No local config and no global config
if (args.config) {
console.error(`Error: Config file not found: ${configPath}`)
} else {
console.error("Error: No config file found. Either:")
console.error(" - Create ./kiana.jsonc in the current directory")
console.error(" - Create a global config with --create-global-config")
console.error(" - Specify a config file with --config <path>")
console.error(" - Generate a template with --create-config > kiana.jsonc")
}
process.exit(1)
}
process.exit(1)
}

// Load config
const rawConfig = loadConfig(configPath)
// Load config (will use global config as fallback if local doesn't exist)
const rawConfig = hasLocalConfig ? loadConfig(configPath) : loadGlobalConfig()!

// Handle --list-models
if (args.listModels) {
Expand Down
73 changes: 71 additions & 2 deletions packages/kiana-v6/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { z } from "zod"
import { readFileSync, writeFileSync, existsSync } from "node:fs"
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"
import { parse as parseJsonc } from "jsonc-parser"
import { config as dotenvConfig } from "dotenv"
import { resolve, dirname } from "node:path"
import { homedir } from "node:os"

// Load .env file (searches current dir and parent dirs)
let envLoaded = false
Expand All @@ -27,6 +28,13 @@ function loadEnvFile(configPath?: string): void {
return
}

// Try loading from home directory
const homeEnvPath = resolve(homedir(), ".env")
if (existsSync(homeEnvPath)) {
dotenvConfig({ path: homeEnvPath })
return
}

// Fallback to default dotenv behavior (searches up from cwd)
dotenvConfig()
}
Expand Down Expand Up @@ -70,6 +78,57 @@ function resolveEnvVarsInObject(obj: unknown): unknown {
return obj
}

const GLOBAL_CONFIG_DIR = `${homedir()}/.kiana`
export const GLOBAL_CONFIG_PATH = `${GLOBAL_CONFIG_DIR}/config.json`

/**
* Deep merge two objects, with source taking precedence.
* Arrays are replaced, not merged.
*/
function deepMerge(target: any, source: any): any {
if (source == null || typeof source !== 'object') return source
if (target == null || typeof target !== 'object') return source
if (Array.isArray(source)) return source
const result = { ...target }
for (const key in source) {
if (source.hasOwnProperty(key)) {
result[key] = deepMerge(target[key], source[key])
}
}
return result
}

/**
* Load global config from ~/.kiana/config.json if it exists.
*/
export function loadGlobalConfig(): Config | null {
if (!existsSync(GLOBAL_CONFIG_PATH)) return null
try {
return loadConfigFromFile(GLOBAL_CONFIG_PATH)
} catch (e) {
// Global config is optional, ignore errors
return null
}
}

/**
* Save global config to ~/.kiana/config.json.
*/
export function saveGlobalConfig(config: Config): void {
// Ensure directory exists
mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true })
// Write as formatted JSON
const json = JSON.stringify(config, null, 2)
writeFileSync(GLOBAL_CONFIG_PATH, json, "utf-8")
}

/**
* Merge global config as defaults with session config as overrides.
*/
function mergeConfigs(defaults: Config, overrides: Config): Config {
return deepMerge(defaults, overrides) as Config
}

export const ThinkingConfigSchema = z.object({
// Enable extended thinking (default: false)
enabled: z.boolean().optional().default(false),
Expand Down Expand Up @@ -228,7 +287,7 @@ function normalizeProviderBaseUrl(obj: Record<string, unknown>): void {
* Environment variables in format ${VAR} or ${VAR:-default} are resolved.
* Loads .env file from config directory or current directory.
*/
export function loadConfig(path: string): Config {
function loadConfigFromFile(path: string): Config {
// Load .env file before resolving environment variables
loadEnvFile(path)

Expand Down Expand Up @@ -270,6 +329,16 @@ export function loadConfig(path: string): Config {
return result.data
}

/**
* Load config from file, merging with global config if available.
* Global config provides defaults, session config overrides.
*/
export function loadConfig(path: string): Config {
const globalConfig = loadGlobalConfig()
const sessionConfig = loadConfigFromFile(path)
return globalConfig ? mergeConfigs(globalConfig, sessionConfig) : sessionConfig
}

/**
* Default system prompt for Kiana headless mode.
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/kiana-v6/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export type {
} from "./stream.js"

// Config
export { loadConfig, writeConfigTemplate, DEFAULT_SYSTEM_PROMPT, type Config, type MCPServerConfig } from "./config.js"
export { loadConfig, loadGlobalConfig, saveGlobalConfig, writeConfigTemplate, DEFAULT_SYSTEM_PROMPT, GLOBAL_CONFIG_PATH, type Config, type MCPServerConfig } from "./config.js"

// Provider
export { createLanguageModel } from "./provider.js"
Expand Down
Loading
Loading