zhitalk 0.0.12 → 0.1.1

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.
@@ -4,9 +4,10 @@ import { StateGraph, Annotation, START, END, interrupt, Command, GraphRecursionE
4
4
  import { messagesStateReducer } from '@langchain/langgraph';
5
5
  import { DB_PATH } from './db.js';
6
6
  import { tools, maybePersistedOutput, initTools } from './tools.js';
7
- import { compressMessages } from './context.js';
8
- import { systemPrompt } from './prompt.js';
9
- import { formatToolLog } from './colors.js';
7
+ import { compressMessages, findSafeCompressionIndex } from './context.js';
8
+ import { buildSystemPrompt } from './prompt.js';
9
+ import { formatToolLog, formatTodoList } from './colors.js';
10
+ import { processTodoCalls, formatTodoListForPrompt } from './todo.js';
10
11
  import { checkReadPermission } from './permission/read.js';
11
12
  import { checkWritePermission } from './permission/write.js';
12
13
  import { checkExecPermission } from './permission/exec.js';
@@ -33,6 +34,10 @@ const StateAnnotation = Annotation.Root({
33
34
  reducer: (_prev, next) => next,
34
35
  default: () => 0,
35
36
  }),
37
+ todoList: Annotation({
38
+ reducer: (_prev, next) => next,
39
+ default: () => null,
40
+ }),
36
41
  });
37
42
  // ── Graph Nodes ───────────────────────────────────────────
38
43
  function shouldContinue(state) {
@@ -121,7 +126,11 @@ function createAgentGraph(toolList) {
121
126
  }
122
127
  return true;
123
128
  });
124
- const messages = [new SystemMessage(systemPrompt), ...modelMessages];
129
+ const messages = [new SystemMessage(buildSystemPrompt())];
130
+ if (state.todoList && state.todoList.length > 0) {
131
+ messages.push(new SystemMessage(`当前任务进度:\n${formatTodoListForPrompt(state.todoList)}`));
132
+ }
133
+ messages.push(...modelMessages);
125
134
  const response = await modelWithTheseTools.invoke(messages, config);
126
135
  return { messages: [response] };
127
136
  }
@@ -240,9 +249,25 @@ function createAgentGraph(toolList) {
240
249
  }
241
250
  }));
242
251
  }
243
- const allowOutputs = await executeCalls(allowCalls);
252
+ // 分离 todo 工具调用
253
+ const regularAllowCalls = [];
254
+ const todoAllowCalls = [];
255
+ for (const call of allowCalls) {
256
+ if (call.name === 'create_todo_list' || call.name === 'update_todo_status') {
257
+ todoAllowCalls.push(call);
258
+ }
259
+ else {
260
+ regularAllowCalls.push(call);
261
+ }
262
+ }
263
+ const regularOutputs = await executeCalls(regularAllowCalls);
264
+ const todoResult = processTodoCalls(todoAllowCalls, state.todoList);
265
+ if (todoResult.todoList && todoResult.todoList.length > 0) {
266
+ console.log(formatTodoList(todoResult.todoList));
267
+ }
268
+ const allowOutputs = [...regularOutputs, ...todoResult.messages];
244
269
  if (confirmCalls.length === 0) {
245
- return { messages: [...allowOutputs, ...blockMessages] };
270
+ return { messages: [...allowOutputs, ...blockMessages], todoList: todoResult.todoList };
246
271
  }
247
272
  const result = interrupt({ toolCalls: confirmCalls });
248
273
  if (result !== 'approved') {
@@ -253,10 +278,11 @@ function createAgentGraph(toolList) {
253
278
  }));
254
279
  return {
255
280
  messages: [...allowOutputs, ...blockMessages, ...deniedMessages],
281
+ todoList: todoResult.todoList,
256
282
  };
257
283
  }
258
284
  const confirmOutputs = await executeCalls(confirmCalls);
259
- return { messages: [...allowOutputs, ...blockMessages, ...confirmOutputs] };
285
+ return { messages: [...allowOutputs, ...blockMessages, ...confirmOutputs], todoList: todoResult.todoList };
260
286
  }
261
287
  const workflow = new StateGraph(StateAnnotation)
262
288
  .addNode('model_request', modelRequest)
@@ -373,14 +399,15 @@ export async function compressContext(threadId) {
373
399
  const lastIndex = currentState.values.lastCompressedIndex || 0;
374
400
  const count = currentState.values.compressionCount || 0;
375
401
  const recentKeep = 6;
376
- if (messages.length <= recentKeep + lastIndex) {
402
+ const safeIndex = findSafeCompressionIndex(messages, recentKeep);
403
+ if (safeIndex <= lastIndex) {
377
404
  return { didCompress: false, count };
378
405
  }
379
- const toCompress = messages.slice(lastIndex, messages.length - recentKeep);
406
+ const toCompress = messages.slice(lastIndex, safeIndex);
380
407
  const newSummary = await compressMessages(toCompress, existingSummary);
381
408
  await getAgent().updateState(config, {
382
409
  contextSummary: newSummary,
383
- lastCompressedIndex: messages.length - recentKeep,
410
+ lastCompressedIndex: safeIndex,
384
411
  compressionCount: count + 1,
385
412
  });
386
413
  return { didCompress: true, count: count + 1 };
@@ -15,5 +15,11 @@ export declare const color: {
15
15
  warn: (text: string) => string;
16
16
  info: (text: string) => string;
17
17
  good: (text: string) => string;
18
+ todoTag: () => string;
18
19
  };
20
+ export interface TodoDisplayItem {
21
+ content: string;
22
+ status: 'pending' | 'in_progress' | 'completed' | 'failed';
23
+ }
24
+ export declare function formatTodoList(items: TodoDisplayItem[]): string;
19
25
  export declare function formatToolLog(name: string, detail?: string): string;
@@ -33,7 +33,25 @@ export const color = {
33
33
  warn: (text) => ck().yellow(text),
34
34
  info: (text) => ck().blueBright(text),
35
35
  good: (text) => ck().greenBright(text),
36
+ todoTag: () => ck().magentaBright('[Todo]'),
36
37
  };
38
+ export function formatTodoList(items) {
39
+ const lines = [color.todoTag()];
40
+ for (const item of items) {
41
+ const symbol = item.status === 'completed'
42
+ ? color.good('✓')
43
+ : item.status === 'in_progress'
44
+ ? color.info('→')
45
+ : item.status === 'failed'
46
+ ? color.error('✗')
47
+ : color.gray('○');
48
+ const desc = item.status === 'completed' || item.status === 'failed'
49
+ ? color.gray(item.content)
50
+ : item.content;
51
+ lines.push(` ${symbol} ${desc}`);
52
+ }
53
+ return '\n' + lines.join('\n') + '\n';
54
+ }
37
55
  export function formatToolLog(name, detail) {
38
56
  const parts = [color.toolTag(), color.toolName(name), color.toolAction()];
39
57
  if (detail) {
@@ -1,3 +1,10 @@
1
1
  import { BaseMessage } from '@langchain/core/messages';
2
2
  export declare function getModelContextLimit(): number;
3
3
  export declare function compressMessages(messages: BaseMessage[], existingSummary: string | null): Promise<string>;
4
+ /**
5
+ * 计算安全的压缩边界索引,确保保留区域内的 tool_calls 和 ToolMessage 配对完整。
6
+ *
7
+ * 策略:从 messages.length - minKeep 开始,如果边界切在了 ToolMessage 或其对应的
8
+ * AIMessage 之前,就把边界往前移动,直到整个工具调用单元都被保留。
9
+ */
10
+ export declare function findSafeCompressionIndex(messages: BaseMessage[], minKeep: number): number;
@@ -1,4 +1,4 @@
1
- import { HumanMessage, } from '@langchain/core/messages';
1
+ import { HumanMessage, AIMessage, } from '@langchain/core/messages';
2
2
  import { createModel } from './model.js';
3
3
  import { getModelConfig } from './config.js';
4
4
  const MODEL_CONTEXT_LIMITS = {
@@ -107,3 +107,45 @@ ${text}`;
107
107
  }
108
108
  return newSummary;
109
109
  }
110
+ /**
111
+ * 计算安全的压缩边界索引,确保保留区域内的 tool_calls 和 ToolMessage 配对完整。
112
+ *
113
+ * 策略:从 messages.length - minKeep 开始,如果边界切在了 ToolMessage 或其对应的
114
+ * AIMessage 之前,就把边界往前移动,直到整个工具调用单元都被保留。
115
+ */
116
+ export function findSafeCompressionIndex(messages, minKeep) {
117
+ let index = Math.max(0, messages.length - minKeep);
118
+ // 收集当前保留区域内所有 ToolMessage 的 tool_call_id
119
+ const toolCallIdsInKeepRegion = new Set();
120
+ for (let i = index; i < messages.length; i++) {
121
+ if (messages[i].type === 'tool') {
122
+ toolCallIdsInKeepRegion.add(messages[i].tool_call_id);
123
+ }
124
+ }
125
+ // 往前移动 index,直到所有 toolCallIdsInKeepRegion 都能在保留区域内找到对应的 AIMessage
126
+ while (index > 0) {
127
+ const prevMsg = messages[index - 1];
128
+ // 如果前一条是 ToolMessage,它也会被纳入保留区,所以加入待匹配集合
129
+ if (prevMsg.type === 'tool') {
130
+ toolCallIdsInKeepRegion.add(prevMsg.tool_call_id);
131
+ index--;
132
+ continue;
133
+ }
134
+ // 如果前一条是 AIMessage 且有 tool_calls,检查它是否匹配保留区内的 ToolMessage
135
+ if (AIMessage.isInstance(prevMsg) && prevMsg.tool_calls?.length) {
136
+ const hasMatchingCall = prevMsg.tool_calls.some((call) => call.id && toolCallIdsInKeepRegion.has(call.id));
137
+ if (hasMatchingCall) {
138
+ // 这个 AIMessage 必须保留,index 前移
139
+ index--;
140
+ // 这个 AIMessage 可能还有其他 tool_calls,它们对应的 ToolMessage 也必须在保留区
141
+ for (const call of prevMsg.tool_calls) {
142
+ if (call.id)
143
+ toolCallIdsInKeepRegion.add(call.id);
144
+ }
145
+ continue;
146
+ }
147
+ }
148
+ break;
149
+ }
150
+ return index;
151
+ }
@@ -16,6 +16,7 @@ export interface MemorySearchResult {
16
16
  final_score: number;
17
17
  }
18
18
  export declare function searchMemories(query: string[], limit?: number): MemorySearchResult[];
19
+ export declare function listRecentMemories(limit?: number): MemorySearchResult[];
19
20
  export declare function threadIdExists(threadId: string): boolean;
20
21
  export declare function initDb(): void;
21
22
  export declare function listRecentSessions(): SessionRow[];
package/dist/agent/db.js CHANGED
@@ -55,6 +55,30 @@ export function searchMemories(query, limit = 10) {
55
55
  db.close();
56
56
  }
57
57
  }
58
+ export function listRecentMemories(limit = 10) {
59
+ const db = new Database(DB_PATH);
60
+ try {
61
+ const rows = db.prepare(`
62
+ SELECT * FROM memory
63
+ ORDER BY updated_at DESC
64
+ LIMIT ?
65
+ `).all(limit);
66
+ return rows.map((r) => ({
67
+ id: r.id,
68
+ type: r.type,
69
+ content: r.content,
70
+ keywords: r.keywords,
71
+ importance: r.importance,
72
+ session_id: r.session_id,
73
+ created_at: r.created_at,
74
+ updated_at: r.updated_at,
75
+ final_score: 0,
76
+ }));
77
+ }
78
+ finally {
79
+ db.close();
80
+ }
81
+ }
58
82
  export function threadIdExists(threadId) {
59
83
  const db = new Database(DB_PATH);
60
84
  try {
@@ -1,7 +1,7 @@
1
1
  import { ChatOpenAI } from '@langchain/openai';
2
2
  import { getModelConfig } from './config.js';
3
- const modelConfig = getModelConfig();
4
3
  export function createModel(options) {
4
+ const modelConfig = getModelConfig();
5
5
  const modelKwargs = {};
6
6
  if (modelConfig.model.startsWith('kimi') ||
7
7
  modelConfig.model.startsWith('minimax')) {
@@ -20,6 +20,7 @@ export function createModel(options) {
20
20
  });
21
21
  }
22
22
  export async function checkModel() {
23
+ const modelConfig = getModelConfig();
23
24
  if (modelConfig.apiKey.length < 20) {
24
25
  try {
25
26
  const model = createModel({ streaming: false });
@@ -1 +1,2 @@
1
- export declare const systemPrompt: string;
1
+ export declare function invalidateMemoryCache(): void;
2
+ export declare function buildSystemPrompt(): string;
@@ -2,6 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { discoverSkills, getSkillsListText } from './skills.js';
4
4
  import { WORKSPACE_DIR } from './config.js';
5
+ import { listRecentMemories } from './db.js';
5
6
  // ── Skills ────────────────────────────────────────────────
6
7
  discoverSkills();
7
8
  const skillsText = getSkillsListText();
@@ -47,12 +48,35 @@ ${skillsText}`
47
48
  const skillPrompt = `## Skills
48
49
 
49
50
  ${skillList}\n\nNote: If you want to add a new skill, it must be placed in the ${path.join(WORKSPACE_DIR, 'skills')} directory.`;
50
- export const systemPrompt = [
51
- basePrompt,
52
- profilePrompt,
53
- memoryPrompt,
54
- skillPrompt,
55
- dateTimePrompt,
56
- ]
57
- .filter(Boolean)
58
- .join('\n\n');
51
+ const todoPrompt = `## Task Planning
52
+
53
+ When a user's request is a complex, multi-step task (such as data analysis, writing a document, planning a task, or any task clearly requiring more than 3 steps), you MUST:
54
+ 1. First call \`create_todo_list\` to create a structured plan with clear, actionable steps
55
+ 2. Then execute each step one by one
56
+ 3. After completing each step, call \`update_todo_status\` to mark it as completed (or failed if it didn't work)
57
+ 4. Only provide the final answer after all steps are done
58
+
59
+ For simple, single-step tasks, do NOT use the todo list tools.`;
60
+ let cachedMemories = null;
61
+ export function invalidateMemoryCache() {
62
+ cachedMemories = null;
63
+ }
64
+ export function buildSystemPrompt() {
65
+ if (!cachedMemories) {
66
+ cachedMemories = listRecentMemories(10);
67
+ }
68
+ const memorySection = cachedMemories.length > 0
69
+ ? `## Recent Memories\n\n${cachedMemories.map((m) => `- ${m.content}`).join('\n')}`
70
+ : '';
71
+ return [
72
+ basePrompt,
73
+ profilePrompt,
74
+ memorySection,
75
+ memoryPrompt,
76
+ skillPrompt,
77
+ dateTimePrompt,
78
+ todoPrompt,
79
+ ]
80
+ .filter(Boolean)
81
+ .join('\n\n');
82
+ }
@@ -0,0 +1,17 @@
1
+ import { ToolMessage } from '@langchain/core/messages';
2
+ export interface TodoItem {
3
+ id: number;
4
+ content: string;
5
+ status: 'pending' | 'in_progress' | 'completed' | 'failed';
6
+ }
7
+ interface TodoCall {
8
+ id?: string;
9
+ name: string;
10
+ args: Record<string, any>;
11
+ }
12
+ export declare function processTodoCalls(calls: TodoCall[], currentTodoList: TodoItem[] | null): {
13
+ messages: ToolMessage[];
14
+ todoList: TodoItem[] | null;
15
+ };
16
+ export declare function formatTodoListForPrompt(items: TodoItem[]): string;
17
+ export {};
@@ -0,0 +1,86 @@
1
+ import { ToolMessage } from '@langchain/core/messages';
2
+ export function processTodoCalls(calls, currentTodoList) {
3
+ const messages = [];
4
+ let todoList = currentTodoList;
5
+ for (const call of calls) {
6
+ if (call.name === 'create_todo_list') {
7
+ const items = call.args.items;
8
+ if (!Array.isArray(items) || items.length === 0) {
9
+ messages.push(new ToolMessage({
10
+ content: 'Error: items must be a non-empty array.',
11
+ tool_call_id: call.id ?? '',
12
+ name: call.name,
13
+ }));
14
+ continue;
15
+ }
16
+ todoList = items.map((content, i) => ({
17
+ id: i,
18
+ content: typeof content === 'string' ? content.trim() : String(content),
19
+ status: 'pending',
20
+ }));
21
+ messages.push(new ToolMessage({
22
+ content: 'Todo list created successfully.',
23
+ tool_call_id: call.id ?? '',
24
+ name: call.name,
25
+ }));
26
+ }
27
+ else if (call.name === 'update_todo_status') {
28
+ const index = call.args.index;
29
+ const status = call.args.status;
30
+ if (!todoList) {
31
+ messages.push(new ToolMessage({
32
+ content: 'Error: no todo list exists.',
33
+ tool_call_id: call.id ?? '',
34
+ name: call.name,
35
+ }));
36
+ continue;
37
+ }
38
+ if (typeof index !== 'number' ||
39
+ !Number.isInteger(index) ||
40
+ index < 0 ||
41
+ index >= todoList.length) {
42
+ messages.push(new ToolMessage({
43
+ content: `Error: index ${index} is out of range.`,
44
+ tool_call_id: call.id ?? '',
45
+ name: call.name,
46
+ }));
47
+ continue;
48
+ }
49
+ const validStatuses = [
50
+ 'pending',
51
+ 'in_progress',
52
+ 'completed',
53
+ 'failed',
54
+ ];
55
+ if (!validStatuses.includes(status)) {
56
+ messages.push(new ToolMessage({
57
+ content: `Error: invalid status "${status}".`,
58
+ tool_call_id: call.id ?? '',
59
+ name: call.name,
60
+ }));
61
+ continue;
62
+ }
63
+ todoList[index].status = status;
64
+ messages.push(new ToolMessage({
65
+ content: `Todo item ${index} updated to ${status}.`,
66
+ tool_call_id: call.id ?? '',
67
+ name: call.name,
68
+ }));
69
+ }
70
+ }
71
+ return { messages, todoList };
72
+ }
73
+ export function formatTodoListForPrompt(items) {
74
+ return items
75
+ .map((item) => {
76
+ const statusMark = item.status === 'completed'
77
+ ? '[x]'
78
+ : item.status === 'in_progress'
79
+ ? '[~]'
80
+ : item.status === 'failed'
81
+ ? '[!]'
82
+ : '[ ]';
83
+ return `${statusMark} ${item.content}`;
84
+ })
85
+ .join('\n');
86
+ }
@@ -1,5 +1,6 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import { DB_PATH } from '../db.js';
3
+ import { invalidateMemoryCache } from '../prompt.js';
3
4
  const VALID_TYPES = ['fact', 'event', 'preference', 'skill'];
4
5
  export async function memoryCreateTool({ type, content, keywords, importance, }, config) {
5
6
  if (!VALID_TYPES.includes(type)) {
@@ -20,6 +21,7 @@ export async function memoryCreateTool({ type, content, keywords, importance, },
20
21
  const stmt = db.prepare(`INSERT INTO memory (type, content, keywords, importance, session_id)
21
22
  VALUES (?, ?, ?, ?, ?)`);
22
23
  stmt.run(type, trimmed, keywordsJson, importanceValue, sessionId);
24
+ invalidateMemoryCache();
23
25
  return 'Memory saved successfully.';
24
26
  }
25
27
  catch (err) {
@@ -1,5 +1,6 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import { DB_PATH } from '../db.js';
3
+ import { invalidateMemoryCache } from '../prompt.js';
3
4
  export async function memoryDeleteTool({ id, }, _config) {
4
5
  if (!Number.isFinite(id) || id <= 0) {
5
6
  return 'Error: id must be a positive integer.';
@@ -11,6 +12,7 @@ export async function memoryDeleteTool({ id, }, _config) {
11
12
  return `Error: no memory found with id ${id}.`;
12
13
  }
13
14
  db.prepare('DELETE FROM memory WHERE id = ?').run(id);
15
+ invalidateMemoryCache();
14
16
  return `Memory ${id} deleted successfully.`;
15
17
  }
16
18
  catch (err) {
@@ -145,6 +145,35 @@ const agentTool = createZhitalkTool(agentToolImpl, {
145
145
  .describe('A clear, self-contained task prompt for the sub-agent. It should include all necessary context because the sub-agent cannot see the current conversation history.'),
146
146
  }),
147
147
  }, 'exec');
148
+ async function createTodoListDummy() {
149
+ return 'Error: create_todo_list should be handled internally by the agent.';
150
+ }
151
+ async function updateTodoStatusDummy() {
152
+ return 'Error: update_todo_status should be handled internally by the agent.';
153
+ }
154
+ const createTodoListTool = createZhitalkTool(createTodoListDummy, {
155
+ name: 'create_todo_list',
156
+ description: 'Create a structured todo list for a complex, multi-step task. Must be called BEFORE starting execution. Each item should be a clear, actionable step.',
157
+ schema: z.object({
158
+ items: z
159
+ .array(z.string().describe('A single todo item description.'))
160
+ .describe('The list of todo items to create. Each item should be a clear, actionable step.'),
161
+ }),
162
+ }, 'read');
163
+ const updateTodoStatusTool = createZhitalkTool(updateTodoStatusDummy, {
164
+ name: 'update_todo_status',
165
+ description: 'Update the status of a todo item in the current todo list. Call this after completing EACH step.',
166
+ schema: z.object({
167
+ index: z
168
+ .number()
169
+ .int()
170
+ .min(0)
171
+ .describe('The zero-based index of the todo item to update.'),
172
+ status: z
173
+ .enum(['in_progress', 'completed', 'failed'])
174
+ .describe('The new status of the todo item.'),
175
+ }),
176
+ }, 'read');
148
177
  export async function maybePersistedOutput(content, toolCallId) {
149
178
  const MAX_LENGTH = 50000;
150
179
  if (content.length <= MAX_LENGTH) {
@@ -180,6 +209,8 @@ export const nativeTools = [
180
209
  memoryDeleteTool,
181
210
  profileUpdateTool,
182
211
  agentTool,
212
+ createTodoListTool,
213
+ updateTodoStatusTool,
183
214
  ];
184
215
  export let tools = [...nativeTools];
185
216
  export async function initTools() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zhitalk",
3
- "version": "0.0.12",
3
+ "version": "0.1.1",
4
4
  "description": "A personal AI Agent like OpenClaw, including tools, skills, memory, hook, sub-agent, MCP server, etc. Run in the terminal.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",