@kkelly-offical/kkcode 0.2.1 → 0.2.3-preview.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # kkcode
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@kkelly-offical/kkcode?label=v0.2.1)](https://www.npmjs.com/package/@kkelly-offical/kkcode)
3
+ [![npm version](https://img.shields.io/npm/v/@kkelly-offical/kkcode?label=v0.2.3-preview.2)](https://www.npmjs.com/package/@kkelly-offical/kkcode)
4
4
  [![GitHub Release](https://img.shields.io/github/v/release/kkelly-offical/kkcode)](https://github.com/kkelly-offical/kkcode/releases)
5
5
  ![Node](https://img.shields.io/badge/Node.js-%3E%3D22-green)
6
6
  ![License](https://img.shields.io/badge/License-GPL--3.0-blue)
@@ -351,20 +351,44 @@ Run `kkcode --help` or `kkcode <command> --help` for the full surface.
351
351
 
352
352
  ---
353
353
 
354
+ <a id="updates"></a>
355
+ ## Updates / 更新
356
+
357
+ KKCode checks npm dist-tags in the background on startup and caches the result under `~/.kkcode/update-state.json`. By default it only prints a notice; it does not modify your global install unless you explicitly run the updater.
358
+
359
+ ```bash
360
+ kkcode update --check
361
+ kkcode update --install --channel latest
362
+ kkcode update --install --channel preview
363
+ ```
364
+
365
+ Config:
366
+
367
+ ```yaml
368
+ update:
369
+ enabled: true
370
+ notify_on_startup: true
371
+ auto_install: false
372
+ channel: "latest"
373
+ check_interval_hours: 12
374
+ ```
375
+
354
376
  <a id="release-status"></a>
355
377
  ## Release Status / 发布状态
356
378
 
357
- **Current stable / 当前稳定版本**: `v0.2.1`
379
+ **Current preview / 当前预览版本**: `v0.2.3-preview.2`
358
380
  **Latest releases / 最新发布**: [GitHub Releases](https://github.com/kkelly-offical/kkcode/releases)
359
381
  **Package / 包地址**: [npm](https://www.npmjs.com/package/@kkelly-offical/kkcode)
360
382
 
361
383
  **English**
362
- - `0.2.1` rebuilds kkcode around Assistant as the default general-purpose lane, with dedicated Agent and LongAgent modes for coding work.
363
- - The public Ask lane has been removed; question, explanation, and lightweight personal-assistant tasks now route to Assistant.
384
+ - `0.2.3-preview.2` is the Preview V2 context release: compaction now merges prior context state, keeps evidence before pruning, and preserves turn metadata for reliable recent-turn retention.
385
+ - `0.2.3-preview.1` is the Preview V1 updater release: kkcode can check npm dist-tags at startup and exposes `kkcode update` for manual upgrades.
386
+ - `0.2.1` rebuilt kkcode around Assistant as the default general-purpose lane, with dedicated Agent and LongAgent modes for coding work.
364
387
 
365
388
  **中文**
389
+ - `0.2.3-preview.2` 是 Preview V2 上下文版本:压缩会合并旧上下文状态,在裁剪前保留关键证据,并保留 turn 元数据以稳定保留最近对话。
390
+ - `0.2.3-preview.1` 是 Preview V1 更新器版本:kkcode 可在启动时检查 npm dist-tag,并提供 `kkcode update` 手动升级入口。
366
391
  - `0.2.1` 将 kkcode 重构为以 Assistant 为默认入口的通用个人助手,同时保留专门面向代码工作的 Agent 和 LongAgent 模式。
367
- - 公共 Ask 通道已移除;问答、解释和轻量个人助手任务现在统一路由到 Assistant。
368
392
 
369
393
  ---
370
394
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kkelly-offical/kkcode",
3
- "version": "0.2.1",
3
+ "version": "0.2.3-preview.2",
4
4
  "description": "CLI-first personal assistant with dedicated coding and LongAgent modes for governed terminal workflows, MCP integrations, and extensible automation.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.5.2",
@@ -8,7 +8,7 @@
8
8
  "author": "kkelly-offical",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/kkelly-offical/kkcode.git"
11
+ "url": "git+https://github.com/kkelly-offical/kkcode.git"
12
12
  },
13
13
  "homepage": "https://github.com/kkelly-offical/kkcode",
14
14
  "bugs": {
@@ -25,7 +25,7 @@
25
25
  "mcp"
26
26
  ],
27
27
  "bin": {
28
- "kkcode": "./src/index.mjs"
28
+ "kkcode": "src/index.mjs"
29
29
  },
30
30
  "files": [
31
31
  "src/",
@@ -0,0 +1,32 @@
1
+ import { Command } from "commander"
2
+ import { loadConfig } from "../config/load-config.mjs"
3
+ import { PACKAGE_VERSION } from "../version.mjs"
4
+ import { checkForUpdate, installUpdate, updateMessage } from "../update/checker.mjs"
5
+
6
+ export function createUpdateCommand() {
7
+ return new Command("update")
8
+ .description("check for and install kkcode updates")
9
+ .option("--check", "only check for updates", false)
10
+ .option("--install", "install the selected update", false)
11
+ .option("--channel <channel>", "npm dist-tag to follow", null)
12
+ .option("--json", "print structured result", false)
13
+ .action(async (options) => {
14
+ const state = await loadConfig(process.cwd())
15
+ const config = { ...state.config, update: { ...(state.config.update || {}) } }
16
+ if (options.channel) config.update.channel = options.channel
17
+ const result = await checkForUpdate(config, { force: true, currentVersion: PACKAGE_VERSION })
18
+ if (options.json) {
19
+ console.log(JSON.stringify(result, null, 2))
20
+ } else if (result.hasUpdate) {
21
+ console.log(updateMessage(result))
22
+ } else {
23
+ console.log(`kkcode is up to date (${result.currentVersion}) on ${result.channel}`)
24
+ }
25
+
26
+ if (options.check || !options.install) return
27
+ if (!result.hasUpdate) return
28
+ const installed = await installUpdate(config, { channel: result.channel })
29
+ if (!installed.ok) throw new Error(`update install failed: ${installed.error}`)
30
+ console.log(`installed kkcode ${result.latestVersion}; restart your shell or kkcode session if needed`)
31
+ })
32
+ }
@@ -252,6 +252,15 @@ export const DEFAULT_CONFIG = {
252
252
  strategy: "warn"
253
253
  }
254
254
  },
255
+ update: {
256
+ enabled: true,
257
+ notify_on_startup: true,
258
+ auto_install: false,
259
+ channel: "latest",
260
+ check_interval_hours: 12,
261
+ registry: "https://registry.npmjs.org",
262
+ timeout_ms: 2500
263
+ },
255
264
  ui: {
256
265
  theme_file: null,
257
266
  mode_colors: {
@@ -512,6 +512,19 @@ export function validateConfig(config) {
512
512
  }
513
513
  }
514
514
 
515
+ if (config.update !== undefined) {
516
+ if (!isObj(config.update)) err(errors, "update", "must be object")
517
+ else {
518
+ if (config.update.enabled !== undefined && typeof config.update.enabled !== "boolean") err(errors, "update.enabled", "must be boolean")
519
+ if (config.update.notify_on_startup !== undefined && typeof config.update.notify_on_startup !== "boolean") err(errors, "update.notify_on_startup", "must be boolean")
520
+ if (config.update.auto_install !== undefined && typeof config.update.auto_install !== "boolean") err(errors, "update.auto_install", "must be boolean")
521
+ if (config.update.channel !== undefined && typeof config.update.channel !== "string") err(errors, "update.channel", "must be string")
522
+ if (config.update.registry !== undefined && typeof config.update.registry !== "string") err(errors, "update.registry", "must be string")
523
+ if (config.update.check_interval_hours !== undefined) checkInt(errors, "update.check_interval_hours", config.update.check_interval_hours, 0)
524
+ if (config.update.timeout_ms !== undefined) checkInt(errors, "update.timeout_ms", config.update.timeout_ms, 100)
525
+ }
526
+ }
527
+
515
528
  if (config.ui !== undefined) {
516
529
  if (!isObj(config.ui)) err(errors, "ui", "must be object")
517
530
  else {
package/src/index.mjs CHANGED
@@ -20,6 +20,8 @@ import { createInitCommand } from "./commands/init.mjs"
20
20
  import { createAuditCommand } from "./commands/audit.mjs"
21
21
  import { createSkillCommand } from "./commands/skill.mjs"
22
22
  import { startRepl } from "./repl.mjs"
23
+ import { PACKAGE_VERSION } from "./version.mjs"
24
+ import { createUpdateCommand } from "./commands/update.mjs"
23
25
 
24
26
  async function main() {
25
27
  const hasTrust = process.argv.includes("--trust")
@@ -55,7 +57,7 @@ async function main() {
55
57
  }
56
58
 
57
59
  const program = new Command()
58
- program.name("kkcode").description("kkcode CLI").version("0.2.1")
60
+ program.name("kkcode").description("kkcode CLI").version(PACKAGE_VERSION)
59
61
  program.addCommand(createChatCommand())
60
62
  program.addCommand(createThemeCommand())
61
63
  program.addCommand(createUsageCommand())
@@ -75,6 +77,7 @@ async function main() {
75
77
  program.addCommand(createAuditCommand())
76
78
  program.addCommand(createInitCommand())
77
79
  program.addCommand(createSkillCommand())
80
+ program.addCommand(createUpdateCommand())
78
81
  await program.parseAsync(process.argv)
79
82
  }
80
83
 
@@ -1,2 +1,4 @@
1
+ import { PACKAGE_VERSION } from "../version.mjs"
2
+
1
3
  export const MCP_PROTOCOL_VERSION = "2024-11-05"
2
- export const MCP_CLIENT_INFO = { name: "kkcode", version: "0.2.1" }
4
+ export const MCP_CLIENT_INFO = { name: "kkcode", version: PACKAGE_VERSION }
package/src/repl.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { PACKAGE_VERSION } from "./version.mjs"
2
+ import { maybeNotifyUpdateOnStartup } from "./update/checker.mjs"
1
3
  import { stdin as input, stdout as output } from "node:process"
2
4
  import { createInterface } from "node:readline/promises"
3
5
  import { emitKeypressEvents } from "node:readline"
@@ -3310,10 +3312,11 @@ export async function startRepl({ trust = false } = {}) {
3310
3312
  const { checkWorkspaceTrust } = await import("./permission/workspace-trust.mjs")
3311
3313
  const trustState = await checkWorkspaceTrust({ cwd: process.cwd(), cliTrust: trust, isTTY: process.stdin.isTTY })
3312
3314
 
3313
- const splash = startSplash({ version: "v0.2.1" })
3315
+ const splash = startSplash({ version: `v${PACKAGE_VERSION}` })
3314
3316
 
3315
3317
  const ctx = await buildContext({ trust, trustState })
3316
3318
  printContextWarnings(ctx)
3319
+ void maybeNotifyUpdateOnStartup(ctx.configState.config, { currentVersion: PACKAGE_VERSION })
3317
3320
 
3318
3321
  splash.update("loading tools & MCP servers...")
3319
3322
  await ToolRegistry.initialize({ config: ctx.configState.config, cwd: process.cwd() })
@@ -5,36 +5,36 @@ import { saveCheckpoint } from "./checkpoint.mjs"
5
5
  import { recordTurn } from "../usage/usage-meter.mjs"
6
6
  import { loadPricing, calculateCost } from "../usage/pricing.mjs"
7
7
 
8
- const COMPACTION_SYSTEM = `You are a conversation summarizer. Create a structured summary preserving all critical information for continued work.
8
+ const COMPACTION_SYSTEM = `You are a conversation summarizer. Create a structured, merge-safe summary preserving all critical information for continued work.
9
9
 
10
10
  ## Output Format
11
11
 
12
+ Return exactly:
13
+
14
+ <context-state>
15
+ {
16
+ "goal": "The user's current overall goal",
17
+ "completed": ["Completed work with specific file paths, function names, and line numbers"],
18
+ "in_progress": ["Current work being done"],
19
+ "files_modified": [{"path":"path/to/file","changes":["specific change"]}],
20
+ "key_decisions": ["Decision, constraint, or user preference"],
21
+ "errors_resolved": ["Error -> fix applied"],
22
+ "evidence": ["Important command output, test result, provider error, or exact failure detail"],
23
+ "next_steps": ["Specific next action item"]
24
+ }
25
+ </context-state>
26
+
12
27
  <summary>
13
- <goal>The user's overall goal or current task</goal>
14
- <completed>
15
- - Completed task with specific details (file paths, function names, line numbers)
16
- </completed>
17
- <in_progress>Current work being done, if any</in_progress>
18
- <files_modified>
19
- - path/to/file: specific change description
20
- </files_modified>
21
- <key_decisions>
22
- - Decision and reasoning
23
- - User preferences or constraints
24
- </key_decisions>
25
- <errors_resolved>
26
- - Error description → fix applied
27
- </errors_resolved>
28
- <next_steps>
29
- - Specific next action items
30
- </next_steps>
28
+ Concise human-readable continuation summary.
31
29
  </summary>
32
30
 
33
31
  Rules:
34
32
  - Use the SAME LANGUAGE as the conversation
33
+ - Merge the prior context state with the new conversation delta; do not summarize the prior state as another chat message
35
34
  - Preserve ALL file paths, function names, variable names, and technical identifiers exactly
36
35
  - Include specific code changes, not just "modified file X"
37
36
  - Omit tool call metadata and message formatting details
37
+ - Preserve exact errors, failing test names, package versions, release labels, and user constraints in evidence
38
38
  - Be concise but never drop actionable information`
39
39
 
40
40
  const DEFAULT_THRESHOLD_MESSAGES = 50
@@ -42,6 +42,118 @@ const DEFAULT_THRESHOLD_RATIO = 0.7
42
42
  const DEFAULT_KEEP_RECENT = 6
43
43
  const DEFAULT_KEEP_RECENT_TURNS = 3
44
44
  const TOOL_RESULT_PREVIEW_LIMIT = 200
45
+ const EVIDENCE_PREVIEW_LIMIT = 900
46
+ const PATH_RE = /(?:^|\s)([A-Za-z0-9_.@~/-]+\.(?:mjs|js|ts|tsx|jsx|json|yaml|yml|md|txt|rs|go|py|sh|toml|lock))(?:[:\s]|$)/g
47
+ const IMPORTANT_LINE_RE = /(error|failed|failure|exception|traceback|assert|reject|denied|unauthorized|context|compact|version|publish|npm|test|lint|typecheck|diff|modified)/i
48
+
49
+ export function isCompactionSummaryMessage(msg) {
50
+ const content = msg?.content
51
+ if (typeof content === "string") return content.includes("<compaction-summary")
52
+ if (Array.isArray(content)) {
53
+ return content.some((block) => {
54
+ if (typeof block === "string") return block.includes("<compaction-summary")
55
+ return block?.type === "text" && typeof block.text === "string" && block.text.includes("<compaction-summary")
56
+ })
57
+ }
58
+ return false
59
+ }
60
+
61
+ export function extractCompactionSummary(content) {
62
+ const text = Array.isArray(content)
63
+ ? content.map((block) => typeof block === "string" ? block : (block?.text || block?.content || "")).join("\n")
64
+ : String(content || "")
65
+ const match = text.match(/<compaction-summary(?:\s[^>]*)?>\s*([\s\S]*?)\s*<\/compaction-summary>/i)
66
+ return (match ? match[1] : "").trim()
67
+ }
68
+
69
+ function clip(text, limit = EVIDENCE_PREVIEW_LIMIT) {
70
+ const raw = String(text || "")
71
+ if (raw.length <= limit) return raw
72
+ return raw.slice(0, limit) + "... [truncated " + raw.length + " chars]"
73
+ }
74
+
75
+ function extractPaths(text) {
76
+ const paths = new Set()
77
+ let match
78
+ PATH_RE.lastIndex = 0
79
+ while ((match = PATH_RE.exec(String(text || ""))) !== null) {
80
+ paths.add(match[1])
81
+ if (paths.size >= 12) break
82
+ }
83
+ return [...paths]
84
+ }
85
+
86
+ function importantLines(text, limit = 12) {
87
+ return String(text || "")
88
+ .split(/\r?\n/)
89
+ .map((line) => line.trim())
90
+ .filter((line) => line && IMPORTANT_LINE_RE.test(line))
91
+ .slice(0, limit)
92
+ }
93
+
94
+ export function collectEvidenceLedger(messages, previewLimit = EVIDENCE_PREVIEW_LIMIT) {
95
+ const evidence = []
96
+ for (const msg of messages) {
97
+ const content = msg.content
98
+ const blocks = Array.isArray(content) ? content : [{ type: "text", text: content }]
99
+ for (const block of blocks) {
100
+ const raw = String(block?.content || block?.text || "")
101
+ if (!raw) continue
102
+ if (block.type === "tool_result") {
103
+ const lines = importantLines(raw)
104
+ const paths = extractPaths(raw)
105
+ if (block.is_error || lines.length || paths.length) {
106
+ evidence.push([
107
+ "- role=" + msg.role + " tool_result" + (block.is_error ? " ERROR" : ""),
108
+ paths.length ? " paths: " + paths.join(", ") : "",
109
+ lines.length ? " key_lines:\n" + lines.map((line) => " " + clip(line, 220)).join("\n") : " preview: " + clip(raw, previewLimit)
110
+ ].filter(Boolean).join("\n"))
111
+ }
112
+ } else if (typeof content === "string" && raw.length > 1000) {
113
+ const lines = importantLines(raw)
114
+ const paths = extractPaths(raw)
115
+ if (lines.length || paths.length) {
116
+ evidence.push([
117
+ "- role=" + msg.role + " long_text",
118
+ paths.length ? " paths: " + paths.join(", ") : "",
119
+ lines.length ? " key_lines:\n" + lines.map((line) => " " + clip(line, 220)).join("\n") : ""
120
+ ].filter(Boolean).join("\n"))
121
+ }
122
+ }
123
+ if (evidence.length >= 20) return evidence
124
+ }
125
+ }
126
+ return evidence
127
+ }
128
+
129
+ export function buildCompactionPrompt({ previousSummary = "", messages, evidence = [] }) {
130
+ const transcript = messages.map((m) => {
131
+ const content = m.content
132
+ if (Array.isArray(content)) {
133
+ return "[" + m.role + "]: " + content.map((b) => {
134
+ if (b.type === "text") return b.text || ""
135
+ if (b.type === "tool_use") return "[tool_use:" + b.name + "(" + JSON.stringify(b.input || {}).slice(0, 120) + ")]"
136
+ if (b.type === "tool_result") return "[tool_result:" + (b.is_error ? "ERROR " : "") + (b.content || "") + "]"
137
+ return ""
138
+ }).filter(Boolean).join("\n")
139
+ }
140
+ return "[" + m.role + "]: " + content
141
+ }).join("\n\n")
142
+
143
+ return [
144
+ "<prior-context-state>",
145
+ previousSummary || "No prior compacted context.",
146
+ "</prior-context-state>",
147
+ "",
148
+ "<evidence-ledger>",
149
+ evidence.length ? evidence.join("\n") : "No extracted evidence ledger.",
150
+ "</evidence-ledger>",
151
+ "",
152
+ "<conversation-delta>",
153
+ transcript,
154
+ "</conversation-delta>"
155
+ ].join("\n")
156
+ }
45
157
 
46
158
  // Estimate tokens from a string, accounting for CJK characters (~1.5 chars/token vs ~4 for Latin)
47
159
  export function estimateStringTokens(str) {
@@ -193,8 +305,12 @@ export async function compactSession({
193
305
  baseUrl = null,
194
306
  apiKeyEnv = null
195
307
  }) {
196
- const history = await getConversationHistory(sessionId, 9999)
308
+ const history = await getConversationHistory(sessionId, 9999, { includeMetadata: true })
197
309
  if (history.length <= keepRecent + 2) return { compacted: false, reason: "too few messages" }
310
+ const previousSummary = isCompactionSummaryMessage(history[0])
311
+ ? extractCompactionSummary(history[0].content)
312
+ : ""
313
+ const workingHistory = previousSummary ? history.slice(1) : history
198
314
 
199
315
  // Turn-based split: keep last keepRecentTurns complete turns
200
316
  // A "turn" = one user interaction cycle (user msg + model response + all tool calls)
@@ -202,7 +318,7 @@ export async function compactSession({
202
318
  let splitIdx
203
319
  const turnIds = []
204
320
  const seenTurns = new Set()
205
- for (const msg of history) {
321
+ for (const msg of workingHistory) {
206
322
  if (msg.turnId && !seenTurns.has(msg.turnId)) {
207
323
  seenTurns.add(msg.turnId)
208
324
  turnIds.push(msg.turnId)
@@ -210,29 +326,19 @@ export async function compactSession({
210
326
  }
211
327
  if (turnIds.length > keepRecentTurns) {
212
328
  const keepFromTurnId = turnIds[turnIds.length - keepRecentTurns]
213
- splitIdx = history.findIndex(msg => msg.turnId === keepFromTurnId)
214
- if (splitIdx < 0) splitIdx = history.length - keepRecent
329
+ splitIdx = workingHistory.findIndex(msg => msg.turnId === keepFromTurnId)
330
+ if (splitIdx < 0) splitIdx = workingHistory.length - keepRecent
215
331
  } else {
216
332
  // Fallback: not enough turns, use message count
217
- splitIdx = history.length - keepRecent
333
+ splitIdx = workingHistory.length - keepRecent
218
334
  }
219
- const toSummarize = history.slice(0, splitIdx)
220
- const kept = history.slice(splitIdx)
335
+ const toSummarize = workingHistory.slice(0, splitIdx)
336
+ const kept = workingHistory.slice(splitIdx)
221
337
 
222
- // Layer 1: prune large tool outputs before sending to LLM
338
+ // Layer 1: extract exact evidence, then prune large tool outputs before sending to LLM
339
+ const evidence = collectEvidenceLedger(toSummarize)
223
340
  const pruned = pruneForSummary(toSummarize)
224
- const summaryPrompt = pruned.map((m) => {
225
- const content = m.content
226
- if (Array.isArray(content)) {
227
- return `[${m.role}]: ${content.map((b) => {
228
- if (b.type === "text") return b.text || ""
229
- if (b.type === "tool_use") return `[tool_use:${b.name}(${JSON.stringify(b.input || {}).slice(0, 120)})]`
230
- if (b.type === "tool_result") return `[tool_result:${b.is_error ? "ERROR " : ""}${b.content || ""}]`
231
- return ""
232
- }).filter(Boolean).join("\n")}`
233
- }
234
- return `[${m.role}]: ${content}`
235
- }).join("\n\n")
341
+ const summaryPrompt = buildCompactionPrompt({ previousSummary, messages: pruned, evidence })
236
342
 
237
343
  const hookPayload = await HookBus.sessionCompacting({
238
344
  sessionId,
@@ -266,7 +372,7 @@ export async function compactSession({
266
372
  // Replace all messages with: [summary] + [kept recent messages]
267
373
  const summaryMessage = {
268
374
  role: "user",
269
- content: `<compaction-summary>\n${summaryText}\n</compaction-summary>`
375
+ content: `<compaction-summary version="2">\n${summaryText}\n</compaction-summary>`
270
376
  }
271
377
  await replaceMessages(sessionId, [summaryMessage, ...kept])
272
378
 
@@ -285,8 +391,10 @@ export async function compactSession({
285
391
  compactedAt: Date.now(),
286
392
  summarizeCount: toSummarize.length,
287
393
  keepCount: kept.length,
288
- summaryVersion: 1,
289
- summaryLength: summaryText.length
394
+ summaryVersion: 2,
395
+ summaryLength: summaryText.length,
396
+ previousSummaryLength: previousSummary.length,
397
+ evidenceCount: evidence.length
290
398
  })
291
399
 
292
400
  return {
@@ -323,7 +323,7 @@ export async function listSessions({ cwd = null, limit = 100, includeChildren =
323
323
  })
324
324
  }
325
325
 
326
- export async function getConversationHistory(sessionId, limit = 30) {
326
+ export async function getConversationHistory(sessionId, limit = 30, options = {}) {
327
327
  return withLock(async () => {
328
328
  await ensureLoadedUnsafe()
329
329
  const data = await loadSessionDataUnsafe(sessionId)
@@ -339,10 +339,19 @@ export async function getConversationHistory(sessionId, limit = 30) {
339
339
  const sliced = firstIsCompaction
340
340
  ? [msgs[0], ...msgs.slice(1).slice(-limit)]
341
341
  : msgs.slice(-limit)
342
- return sliced.map((msg) => ({
343
- role: msg.role,
344
- content: msg.content
345
- }))
342
+ return sliced.map((msg) => {
343
+ const base = {
344
+ role: msg.role,
345
+ content: msg.content
346
+ }
347
+ if (!options.includeMetadata) return base
348
+ return {
349
+ ...base,
350
+ turnId: msg.turnId,
351
+ step: msg.step,
352
+ synthetic: msg.synthetic
353
+ }
354
+ })
346
355
  })
347
356
  }
348
357
 
@@ -111,6 +111,10 @@ export function auditStorePath() {
111
111
  return path.join(userRootDir(), "audit-log.json")
112
112
  }
113
113
 
114
+ export function updateStatePath() {
115
+ return path.join(userRootDir(), "update-state.json")
116
+ }
117
+
114
118
  export async function ensureUserRoot() {
115
119
  await mkdir(userRootDir(), { recursive: true })
116
120
  }
@@ -0,0 +1,184 @@
1
+ import { spawn } from "node:child_process"
2
+ import { readFile, writeFile } from "node:fs/promises"
3
+ import { dirname } from "node:path"
4
+ import { mkdir } from "node:fs/promises"
5
+ import { PACKAGE_NAME, PACKAGE_VERSION } from "../version.mjs"
6
+ import { updateStatePath } from "../storage/paths.mjs"
7
+
8
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org"
9
+ const DEFAULT_TIMEOUT_MS = 2500
10
+
11
+ function normalizeRegistry(registry = DEFAULT_REGISTRY) {
12
+ return String(registry || DEFAULT_REGISTRY).replace(/\/+$/, "")
13
+ }
14
+
15
+ function encodePackageName(name) {
16
+ return String(name).replace("/", "%2F")
17
+ }
18
+
19
+ function parseVersion(version) {
20
+ const match = String(version || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/)
21
+ if (!match) return null
22
+ return {
23
+ major: Number(match[1]),
24
+ minor: Number(match[2]),
25
+ patch: Number(match[3]),
26
+ prerelease: match[4] ? match[4].split(".").map((part) => (/^\d+$/.test(part) ? Number(part) : part)) : []
27
+ }
28
+ }
29
+
30
+ function compareIdentifier(a, b) {
31
+ if (a === b) return 0
32
+ const aNum = typeof a === "number"
33
+ const bNum = typeof b === "number"
34
+ if (aNum && bNum) return a > b ? 1 : -1
35
+ if (aNum) return -1
36
+ if (bNum) return 1
37
+ return String(a) > String(b) ? 1 : -1
38
+ }
39
+
40
+ export function compareVersions(a, b) {
41
+ const av = parseVersion(a)
42
+ const bv = parseVersion(b)
43
+ if (!av || !bv) return String(a || "").localeCompare(String(b || ""))
44
+ for (const key of ["major", "minor", "patch"]) {
45
+ if (av[key] !== bv[key]) return av[key] > bv[key] ? 1 : -1
46
+ }
47
+ const aPre = av.prerelease
48
+ const bPre = bv.prerelease
49
+ if (!aPre.length && !bPre.length) return 0
50
+ if (!aPre.length) return 1
51
+ if (!bPre.length) return -1
52
+ const len = Math.max(aPre.length, bPre.length)
53
+ for (let i = 0; i < len; i++) {
54
+ if (aPre[i] === undefined) return -1
55
+ if (bPre[i] === undefined) return 1
56
+ const cmp = compareIdentifier(aPre[i], bPre[i])
57
+ if (cmp !== 0) return cmp
58
+ }
59
+ return 0
60
+ }
61
+
62
+ export function updateConfig(config = {}) {
63
+ return {
64
+ enabled: config.update?.enabled !== false,
65
+ notifyOnStartup: config.update?.notify_on_startup !== false,
66
+ autoInstall: Boolean(config.update?.auto_install),
67
+ channel: config.update?.channel || "latest",
68
+ checkIntervalHours: Number(config.update?.check_interval_hours ?? 12),
69
+ registry: config.update?.registry || DEFAULT_REGISTRY,
70
+ timeoutMs: Number(config.update?.timeout_ms ?? DEFAULT_TIMEOUT_MS)
71
+ }
72
+ }
73
+
74
+ async function readUpdateState(file = updateStatePath()) {
75
+ try {
76
+ return JSON.parse(await readFile(file, "utf8"))
77
+ } catch {
78
+ return {}
79
+ }
80
+ }
81
+
82
+ async function writeUpdateState(state, file = updateStatePath()) {
83
+ await mkdir(dirname(file), { recursive: true })
84
+ await writeFile(file, `${JSON.stringify(state, null, 2)}\n`)
85
+ }
86
+
87
+ export async function fetchPackageMetadata({ packageName = PACKAGE_NAME, registry = DEFAULT_REGISTRY, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = globalThis.fetch } = {}) {
88
+ if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable in this Node runtime")
89
+ const controller = new AbortController()
90
+ const timer = setTimeout(() => controller.abort(), Math.max(100, Number(timeoutMs || DEFAULT_TIMEOUT_MS)))
91
+ try {
92
+ const url = `${normalizeRegistry(registry)}/${encodePackageName(packageName)}`
93
+ const res = await fetchImpl(url, {
94
+ headers: { accept: "application/vnd.npm.install-v1+json, application/json" },
95
+ signal: controller.signal
96
+ })
97
+ if (!res.ok) throw new Error(`npm registry returned HTTP ${res.status}`)
98
+ return await res.json()
99
+ } finally {
100
+ clearTimeout(timer)
101
+ }
102
+ }
103
+
104
+ export async function checkForUpdate(config = {}, options = {}) {
105
+ const cfg = updateConfig(config)
106
+ if (!cfg.enabled && !options.force) return { ok: false, skipped: true, reason: "disabled" }
107
+
108
+ const now = Number(options.now ?? Date.now())
109
+ const stateFile = options.stateFile || updateStatePath()
110
+ const state = options.state ?? await readUpdateState(stateFile)
111
+ const intervalMs = Math.max(0, cfg.checkIntervalHours) * 60 * 60 * 1000
112
+ if (!options.force && intervalMs > 0 && state.checkedAt && now - Date.parse(state.checkedAt) < intervalMs) {
113
+ return { ok: true, skipped: true, reason: "interval", state }
114
+ }
115
+
116
+ const metadata = await fetchPackageMetadata({
117
+ packageName: options.packageName || PACKAGE_NAME,
118
+ registry: cfg.registry,
119
+ timeoutMs: cfg.timeoutMs,
120
+ fetchImpl: options.fetchImpl
121
+ })
122
+ const distTags = metadata["dist-tags"] || {}
123
+ const latestVersion = distTags[cfg.channel] || distTags.latest || metadata.version
124
+ const currentVersion = options.currentVersion || PACKAGE_VERSION
125
+ const hasUpdate = Boolean(latestVersion && compareVersions(latestVersion, currentVersion) > 0)
126
+ const result = {
127
+ ok: true,
128
+ packageName: options.packageName || PACKAGE_NAME,
129
+ channel: cfg.channel,
130
+ currentVersion,
131
+ latestVersion,
132
+ hasUpdate,
133
+ installSpec: `${options.packageName || PACKAGE_NAME}@${cfg.channel}`,
134
+ checkedAt: new Date(now).toISOString()
135
+ }
136
+ await writeUpdateState(result, stateFile)
137
+ return result
138
+ }
139
+
140
+ export function updateMessage(result) {
141
+ if (!result?.hasUpdate) return null
142
+ return `Update available: kkcode ${result.currentVersion} -> ${result.latestVersion} (${result.channel}). Run: kkcode update --channel ${result.channel}`
143
+ }
144
+
145
+ export async function maybeNotifyUpdateOnStartup(config = {}, options = {}) {
146
+ const cfg = updateConfig(config)
147
+ if (!cfg.enabled || !cfg.notifyOnStartup || process.env.KKCODE_DISABLE_UPDATE_CHECK === "1") return null
148
+ try {
149
+ const result = await checkForUpdate(config, options)
150
+ const message = updateMessage(result)
151
+ if (message) {
152
+ const print = options.print || console.error
153
+ print(message)
154
+ if (cfg.autoInstall) {
155
+ const install = await installUpdate(config, { channel: result.channel, stdio: "ignore" })
156
+ if (install.ok) print(`kkcode update installed ${result.latestVersion}; restart kkcode to use it.`)
157
+ else print(`kkcode auto-update failed: ${install.error}`)
158
+ }
159
+ }
160
+ return result
161
+ } catch (error) {
162
+ if (options.verbose) (options.print || console.error)(`kkcode update check failed: ${error.message}`)
163
+ return { ok: false, error: error.message }
164
+ }
165
+ }
166
+
167
+ function runCommand(command, args, { cwd = process.cwd(), env = process.env, stdio = "inherit" } = {}) {
168
+ return new Promise((resolve) => {
169
+ const child = spawn(command, args, { cwd, env, stdio, shell: process.platform === "win32" })
170
+ child.on("exit", (code) => resolve({ ok: code === 0, code }))
171
+ child.on("error", (error) => resolve({ ok: false, code: 1, error: error.message }))
172
+ })
173
+ }
174
+
175
+ export async function installUpdate(config = {}, options = {}) {
176
+ const cfg = updateConfig(config)
177
+ const channel = options.channel || cfg.channel || "latest"
178
+ const packageName = options.packageName || PACKAGE_NAME
179
+ const npm = options.npmCommand || process.env.npm_execpath || "npm"
180
+ const args = ["install", "-g", `${packageName}@${channel}`]
181
+ const result = await (options.runCommand || runCommand)(npm, args, options)
182
+ if (!result.ok) return { ok: false, code: result.code, error: result.error || `npm exited with ${result.code}` }
183
+ return { ok: true, command: npm, args }
184
+ }
@@ -0,0 +1,3 @@
1
+ export const PACKAGE_NAME = "@kkelly-offical/kkcode"
2
+ export const PACKAGE_VERSION = "0.2.3-preview.2"
3
+ export const RELEASE_LABEL = "0.2.3 Preview V2"