@ganziliang/kb 0.2.0 → 0.3.0

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
  # @ganziliang/kb
2
2
 
3
- 本地知识库命令行工具。它会把本地 Markdown、文本、JSON、CSV 或 Excel 文件导入 SQLite 知识库,然后通过关键词检索相关内容,并调用已配置的模型回答问题。同时支持导入图片:入库时自动生成中文描述参与检索,提问命中后把原图发给模型。
3
+ 本地知识库命令行工具。它会把本地 Markdown、文本、JSON、CSV 或 Excel 文件导入 SQLite 知识库,也可以直接录入输入框中的文字,然后通过关键词检索相关内容,并调用已配置的模型回答问题。同时支持导入图片:入库时自动生成中文描述参与检索,提问命中后把原图发给模型。
4
4
 
5
5
  界面基于 [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui) 构建,采用差分渲染:Markdown 回答排版、带边框的编辑器、加载动画与命令浮层。
6
6
 
@@ -144,13 +144,16 @@ kb
144
144
  | 按键 | 作用 |
145
145
  | --- | --- |
146
146
  | `Enter` | 发送 |
147
+ | `Shift+Enter` / `Ctrl+J` | 输入换行(录入多行内容用) |
147
148
  | `Esc` | 关闭浮层 |
148
149
  | `Ctrl+C` | 退出 |
149
150
  | `Ctrl+A` / `Ctrl+E` | 行首 / 行尾 |
150
151
  | `Ctrl+U` / `Ctrl+K` | 删除到行首 / 行尾 |
151
152
  | `Ctrl+W` | 删除前一个单词 |
153
+ | `Ctrl+-` | 撤销 |
154
+ | `Ctrl+Y` | 粘贴(yank 最近删除的内容) |
152
155
 
153
- 输入框支持多行编辑与撤销:`Ctrl+Z` 撤销,`Ctrl+Y` 重做。
156
+ 光标可以用方向键或 `Ctrl+B` / `Ctrl+F` 左右移动;按单词移动用 `Alt+←` / `Alt+→`。
154
157
 
155
158
  ### 导入文件
156
159
 
@@ -189,6 +192,55 @@ import D:\docs\api.txt
189
192
 
190
193
  导入后会复制一份原始文件,并建立版本记录。对同一个文件再次导入时,会创建新版本,不会直接覆盖旧版本。
191
194
 
195
+ ### 直接录入文本
196
+
197
+ 不需要先把内容存成文件,也不需要记命令——用自然语言告诉 kb 就行:
198
+
199
+ ```text
200
+ 记住:我的工位在 A 区 12 号,门禁卡号 8823
201
+ 记一下,下周三下午两点和产品对需求
202
+ 帮我存个配置:接口超时时间 30 秒,失败重试 3 次
203
+ 这段你帮我记着:报销单必须贴发票原件
204
+ ```
205
+
206
+ kb 每一轮输入都会先判断你的意图:
207
+
208
+ - **提问** → 检索知识库并回答(附来源与版本)
209
+ - **录入** → 调用 `save_knowledge` 工具存入知识库
210
+
211
+ 判断由模型根据语义完成,**不依赖固定关键词**。识别为录入后,模型会:
212
+
213
+ 1. **生成标题**:用一句话概括内容,作为知识条目标题。
214
+ 2. **整理内容**:转成规范 Markdown,完整保留你给的信息。
215
+ 3. **落盘建索引**:写入 `<知识库>/originals/`,并入关键词检索,随后提问就能命中。
216
+
217
+ 多行内容按 **Shift+Enter**(或 `Ctrl+J`)换行,全部输入完再按 `Enter` 提交:
218
+
219
+ ```text
220
+ 帮我记下值班规范:
221
+
222
+ - 线上告警先看 Grafana 面板 pay-dashboard
223
+ - P0 故障 15 分钟内必须响应
224
+ - 值班电话 8001,仅限 P0 使用
225
+ ```
226
+
227
+ 录入的内容同样遵循版本规则:内容完全相同时再次录入,会生成新版本而不是重复来源。
228
+
229
+ #### 显式命令(兜底)
230
+
231
+ 模型判断有误、或模型暂时不可用时,可以用 `/add` 强制录入;
232
+ 以 `/` 开头的形式不会与自然语言混淆:
233
+
234
+ ```text
235
+ /add 支付网关的退款接口是 /api/pay/refund,请求方式 POST
236
+ /note 团队周会是每周三下午三点
237
+ ```
238
+
239
+ 显式命令不调用模型,标题由程序从内容推导(优先取 Markdown 标题,否则取首个非空行)。
240
+
241
+ > 区别:`导入 <路径>` 是把**已有的文件**收录进来,`/add <内容>` 是把**输入框里的文字**收录进来,
242
+ > 而直接说「记住 …」则由模型自己判断。
243
+
192
244
  ### 图片知识
193
245
 
194
246
  图片本身无法参与关键词检索,因此 `kb` 对图片使用双通道处理:
@@ -229,6 +281,7 @@ import D:\docs\api.txt
229
281
  | `/help` | 查看命令列表 |
230
282
  | `/clear` | 清空当前对话上下文 |
231
283
  | `/sources` | 查看当前知识库中的来源文件、路径和版本 |
284
+ | `/add <内容>` | 显式录入文本(正常直接说「记住…」即可) |
232
285
  | `/models` 或 `/model` | 查看已配置模型 |
233
286
  | `/model <编号>` | 切换模型,例如 `/model 1` |
234
287
  | `/kb list` | 查看所有知识库 |
package/dist/cli.d.ts CHANGED
@@ -27,6 +27,7 @@ export declare class KbApp {
27
27
  private shutdown;
28
28
  private submit;
29
29
  private run;
30
+ private addNote;
30
31
  private ingest;
31
32
  private ask;
32
33
  private command;
package/dist/cli.js CHANGED
@@ -2,15 +2,58 @@
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { Container, Editor, Loader, ProcessTerminal, SelectList, Spacer, TuiMainScreen, matchesKey, } from "@earendil-works/pi-tui";
4
4
  import { ensureModelConfig, saveModels } from "./config.js";
5
- import { Agent, describeImage, fetchTransport } from "./model.js";
6
- import { backupKnowledgeBase, copyOriginal, defaultDataRoot, deleteKnowledgeBase, ensureKnowledgeBase, isImageFile, KnowledgeStore, listKnowledgeBases, readImage, readLocalFile, restoreKnowledgeBase, } from "./storage.js";
5
+ import { SAVE_KNOWLEDGE_TOOL, describeImage, fetchTransport } from "./model.js";
6
+ import { backupKnowledgeBase, copyOriginal, defaultDataRoot, deleteKnowledgeBase, ensureKnowledgeBase, isImageFile, KnowledgeStore, listKnowledgeBases, readImage, readLocalFile, restoreKnowledgeBase, writeNote, } from "./storage.js";
7
7
  import { color, editorTheme, selectListTheme } from "./theme.js";
8
8
  import { AssistantMessage, HelpPanel, ImageNotice, Notice, Rule, Splash, UserMessage } from "./ui.js";
9
9
  const errText = (error) => (error instanceof Error ? error.message : String(error));
10
+ /**
11
+ * 显式录入命令,仅作为模型不可用时的兜底。
12
+ * 正常情况交给模型按语义判断(见 buildSystemPrompt),
13
+ * 因此这里只保留 / 开头的命令,不硬匹配「记住」这类自然语言词。
14
+ */
15
+ const NOTE_PATTERN = /^\/(?:add|note)(?:\s+([\s\S]*))?$/;
16
+ /** 从录入内容里推导一个标题:优先取 Markdown 标题,否则取首个非空行。 */
17
+ function deriveTitle(content) {
18
+ const firstLine = content.split("\n").map((l) => l.trim()).find(Boolean) ?? "笔记";
19
+ const heading = firstLine.match(/^#{1,6}\s+(.+)$/);
20
+ const base = (heading ? heading[1] : firstLine).replace(/[#*`>_]/g, "").trim();
21
+ return base.slice(0, 40) || "���记";
22
+ }
23
+ /** 多行内容回显时只显示首行,避免刷屏。 */
24
+ function shortPreview(text) {
25
+ const lines = text.split("\n");
26
+ return lines.length > 1 ? `${lines[0]} …(共 ${lines.length} 行)` : text;
27
+ }
28
+ /**
29
+ * 把「回答」和「录入」放进同一轮对话:模型自己决定是回答还是调用工具,
30
+ * 因此判断意图不会带来额外的一次模型调用。
31
+ */
32
+ function buildSystemPrompt(context) {
33
+ return [
34
+ "你是本地知识库助手。每一轮输入,先判断用户意图:",
35
+ "",
36
+ "A) 提问 —— 用户想从知识库获取信息。",
37
+ " 用「知识库内容」回答,并标注来源文件名与版本。",
38
+ " 内容不足以回答时明确说明没有找到,不要编造。",
39
+ "",
40
+ "B) 录入 —— 用户想把一段内容长期存进知识库。",
41
+ " 调用 save_knowledge 工具,title 用一句话概括,content 用规范 Markdown 并完整保留用户信息。",
42
+ " 此时不要回答内容本身,也不要反问确认。",
43
+ "",
44
+ "判断依据是用户表达的意图,不要依赖固定关键词。",
45
+ "",
46
+ "知识库内容:",
47
+ "---",
48
+ context,
49
+ "---",
50
+ ].join("\n");
51
+ }
10
52
  const HELP_ITEMS = [
11
53
  ["/help", "显示本帮助"],
12
54
  ["/clear", "清空对话上下文"],
13
55
  ["/sources", "列出知识来源与版本"],
56
+ ["/add <内容>", "显式录入(通常直接说「记住…」即可)"],
14
57
  ["/model", "查看 / 切换模型"],
15
58
  ["/kb", "多知识库管理"],
16
59
  ["/backup", "备份当前知识库"],
@@ -127,6 +170,17 @@ export class KbApp {
127
170
  return;
128
171
  this.editor.setText("");
129
172
  this.tui.requestRender();
173
+ const note = value.match(NOTE_PATTERN);
174
+ if (note) {
175
+ const content = (note[1] ?? "").trim();
176
+ if (!content) {
177
+ this.add(new Notice(ROLE.warn, color.warn, "用法:/add <内容>;也可以直接说「记住 ……」,模型会自动识别为录入。"));
178
+ return;
179
+ }
180
+ this.add(new UserMessage(shortPreview(`/add ${content}`)));
181
+ await this.addNote(content);
182
+ return;
183
+ }
130
184
  if (value.startsWith("/")) {
131
185
  await this.command(value);
132
186
  return;
@@ -143,6 +197,23 @@ export class KbApp {
143
197
  }
144
198
  await this.ask(value);
145
199
  }
200
+ // ---------- 直接录入文本 ----------
201
+ async addNote(content, explicitTitle = "") {
202
+ const body = content.trim();
203
+ if (!body) {
204
+ this.add(new Notice(ROLE.warn, color.warn, "没有可录入的内容。"));
205
+ return;
206
+ }
207
+ try {
208
+ const title = explicitTitle.trim() || deriveTitle(body);
209
+ const file = writeNote(this.store.root, title, body);
210
+ const result = this.store.addOrUpdateSource(file, title, body);
211
+ this.add(new Notice(ROLE.ok, color.ok, `已录入「${result.title}」(v${result.version},${body.length} 字符)`));
212
+ }
213
+ catch (error) {
214
+ this.add(new Notice(ROLE.err, color.err, `录入失败:${errText(error)}`));
215
+ }
216
+ }
146
217
  // ---------- 导入 ----------
147
218
  async ingest(path) {
148
219
  try {
@@ -191,30 +262,39 @@ export class KbApp {
191
262
  const results = this.store.search(question);
192
263
  const context = results.length
193
264
  ? results.map((item) => `[${item.id}] ${item.title} (source: ${item.source}, v${item.version})\n${item.content}`).join("\n\n")
194
- : "No matching knowledge was found.";
195
- const prompt = `Answer only from the local knowledge below. If it is insufficient, say no knowledge was found. Include source filename and version.\n\n${context}\n\nQuestion: ${question}`;
196
- const blocks = [{ type: "text", text: prompt }];
265
+ : "(没有检索到相关内容)";
197
266
  const attached = [];
267
+ const questionBlocks = [{ type: "text", text: question }];
198
268
  for (const item of results.filter((hit) => hit.originalPath && isImageFile(hit.originalPath)).slice(0, 4)) {
199
269
  try {
200
270
  const image = readImage(item.originalPath);
201
- blocks.push({ type: "text", text: `[附图] ${item.source} v${item.version}` });
202
- blocks.push({ type: "image", mediaType: image.mediaType, base64: image.base64 });
271
+ questionBlocks.push({ type: "text", text: `[附图] ${item.source} v${item.version}` });
272
+ questionBlocks.push({ type: "image", mediaType: image.mediaType, base64: image.base64 });
203
273
  attached.push({ name: item.source, version: item.version });
204
274
  }
205
275
  catch { /* 跳过无法读取的图片 */ }
206
276
  }
207
- this.setBusy(true, attached.length ? `命中 ${results.length} 条,附图 ${attached.length} 张,正在生成回答…` : `命中 ${results.length} 条,正在生成回答…`);
208
- const agent = new Agent(this.store, config, fetchTransport);
209
- const answer = await agent.answer(blocks.length > 1 ? blocks : prompt, this.messages);
210
- this.messages = answer.messages;
277
+ this.setBusy(true, attached.length ? `命中 ${results.length} 条,附图 ${attached.length} 张,正在处理…` : `检索到 ${results.length} 条,正在理解意图…`);
278
+ const reply = await fetchTransport.complete(config, [
279
+ { role: "system", content: buildSystemPrompt(context) },
280
+ ...this.messages,
281
+ { role: "user", content: questionBlocks.length > 1 ? questionBlocks : question },
282
+ ], [SAVE_KNOWLEDGE_TOOL]);
283
+ const save = reply.toolCalls?.find((call) => call.name === SAVE_KNOWLEDGE_TOOL.name);
284
+ if (save) {
285
+ this.setBusy(true, "正在录入知识库…");
286
+ await this.addNote(String(save.arguments.content ?? ""), String(save.arguments.title ?? ""));
287
+ return;
288
+ }
289
+ const answer = (reply.text ?? "").trim();
290
+ this.messages = [...this.messages, { role: "user", content: question }, { role: "assistant", content: answer }];
211
291
  this.store.saveMessage("user", question);
212
- this.store.saveMessage("assistant", answer.text);
292
+ this.store.saveMessage("assistant", answer);
213
293
  for (const item of attached)
214
294
  this.add(new ImageNotice(item.name, item.version));
215
295
  const seconds = ((Date.now() - started) / 1000).toFixed(1);
216
- const meta = `检索 ${results.length} 条 · 命中 ${results.length} 条 · ${seconds}s` + (attached.length ? ` · 附图 ${attached.length} 张` : "");
217
- this.add(new AssistantMessage(answer.text || "(模型返回空回答)", meta));
296
+ const meta = `检索 ${results.length} 条 · 用时 ${seconds}s` + (attached.length ? ` · 附图 ${attached.length} 张` : "");
297
+ this.add(new AssistantMessage(answer || "(模型返回空回答)", meta));
218
298
  }
219
299
  catch (error) {
220
300
  this.add(new Notice(ROLE.err, color.err, `模型请求失败:${errText(error)}`));
package/dist/model.d.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import type { ModelConfig } from "@ganziliang/kb-model-setup";
2
2
  import type { SearchResult, KnowledgeStore } from "./storage.js";
3
- export type ToolCall = {
4
- name: "search" | "read" | "write";
3
+ export type NativeToolCall = {
4
+ id: string;
5
+ name: string;
5
6
  arguments: Record<string, unknown>;
6
7
  };
8
+ export type ToolDefinition = {
9
+ name: string;
10
+ description: string;
11
+ inputSchema: Record<string, unknown>;
12
+ };
7
13
  export type ContentBlock = {
8
14
  type: "text";
9
15
  text: string;
@@ -19,13 +25,13 @@ export type ModelMessage = {
19
25
  };
20
26
  export type ModelReply = {
21
27
  text?: string;
22
- toolCalls?: ToolCall[];
28
+ toolCalls?: NativeToolCall[];
23
29
  };
24
30
  export declare function toAnthropicContent(content: string | ContentBlock[]): unknown;
25
31
  export declare function toOpenAIContent(content: string | ContentBlock[]): unknown;
26
32
  export declare function contentToText(content: string | ContentBlock[]): string;
27
33
  export type ModelTransport = {
28
- complete(config: ModelConfig, messages: ModelMessage[]): Promise<ModelReply>;
34
+ complete(config: ModelConfig, messages: ModelMessage[], tools?: ToolDefinition[]): Promise<ModelReply>;
29
35
  };
30
36
  export declare const fetchTransport: ModelTransport;
31
37
  export declare class Agent {
@@ -40,6 +46,11 @@ export declare class Agent {
40
46
  private execute;
41
47
  }
42
48
  export declare function formatResults(results: SearchResult[]): string;
49
+ /**
50
+ * 让模型在一轮对话里自己决定「回答」还是「录入」,
51
+ * 避免为了判断意图而多跑一次模型调用。
52
+ */
53
+ export declare const SAVE_KNOWLEDGE_TOOL: ToolDefinition;
43
54
  type VisionTransport = {
44
55
  complete: (config: ModelConfig, messages: ModelMessage[]) => Promise<ModelReply>;
45
56
  };
package/dist/model.js CHANGED
@@ -23,7 +23,7 @@ function endpoint(config) {
23
23
  return `${config.baseURL.replace(/\/$/, "")}${config.api === "anthropic-messages" ? "/v1/messages" : "/v1/responses"}`;
24
24
  }
25
25
  export const fetchTransport = {
26
- async complete(config, messages) {
26
+ async complete(config, messages, tools) {
27
27
  const headers = { "content-type": "application/json" };
28
28
  if (config.api === "anthropic-messages") {
29
29
  headers["x-api-key"] = config.apiKey;
@@ -31,19 +31,46 @@ export const fetchTransport = {
31
31
  }
32
32
  else
33
33
  headers.authorization = `Bearer ${config.apiKey}`;
34
+ const anthropicTools = tools?.length
35
+ ? { tools: tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.inputSchema })) }
36
+ : {};
37
+ const openaiTools = tools?.length
38
+ ? { tools: tools.map((tool) => ({ type: "function", name: tool.name, description: tool.description, parameters: tool.inputSchema })) }
39
+ : {};
34
40
  const response = await fetch(endpoint(config), {
35
41
  method: "POST",
36
42
  headers,
37
43
  body: JSON.stringify(config.api === "anthropic-messages"
38
- ? { model: config.model, max_tokens: 4096, messages: messages.filter((m) => m.role !== "system").map((m) => ({ role: m.role, content: toAnthropicContent(m.content) })), system: messages.find((m) => m.role === "system")?.content }
39
- : { model: config.model, input: messages.map((m) => ({ role: m.role, content: toOpenAIContent(m.content) })) }),
44
+ ? { model: config.model, max_tokens: 4096, messages: messages.filter((m) => m.role !== "system").map((m) => ({ role: m.role, content: toAnthropicContent(m.content) })), system: messages.find((m) => m.role === "system")?.content, ...anthropicTools }
45
+ : { model: config.model, input: messages.map((m) => ({ role: m.role, content: toOpenAIContent(m.content) })), ...openaiTools }),
40
46
  });
41
47
  if (!response.ok)
42
48
  throw new Error(`Model request failed (${response.status}): ${await response.text()}`);
43
49
  const data = await response.json();
44
- if (config.api === "anthropic-messages")
45
- return { text: data.content?.map((item) => item.text ?? "").join("") };
46
- return { text: data.output_text ?? data.output?.map((item) => item.content?.map((part) => part.text ?? "").join("")).join("") ?? "" };
50
+ if (config.api === "anthropic-messages") {
51
+ let text = "";
52
+ const toolCalls = [];
53
+ for (const item of (data.content ?? [])) {
54
+ if (item.type === "text")
55
+ text += item.text ?? "";
56
+ else if (item.type === "tool_use")
57
+ toolCalls.push({ id: String(item.id ?? ""), name: String(item.name), arguments: (item.input ?? {}) });
58
+ }
59
+ return toolCalls.length ? { text, toolCalls } : { text };
60
+ }
61
+ const toolCalls = [];
62
+ for (const item of (data.output ?? [])) {
63
+ if (item.type !== "function_call")
64
+ continue;
65
+ let parsed = {};
66
+ try {
67
+ parsed = JSON.parse(item.arguments ?? "{}");
68
+ }
69
+ catch { /* 保留空参数 */ }
70
+ toolCalls.push({ id: String(item.call_id ?? item.id ?? ""), name: String(item.name), arguments: parsed });
71
+ }
72
+ const text = data.output_text ?? (data.output ?? []).map((item) => (item.content ?? []).map((part) => part.text ?? "").join("")).join("") ?? "";
73
+ return toolCalls.length ? { text, toolCalls } : { text };
47
74
  },
48
75
  };
49
76
  export class Agent {
@@ -72,12 +99,34 @@ export class Agent {
72
99
  return this.store.search(String(call.arguments.query ?? ""));
73
100
  if (call.name === "read")
74
101
  return this.store.read(String(call.arguments.id ?? "")) ?? { error: "Knowledge record not found" };
75
- return { error: "write requires CLI ingestion flow" };
102
+ return { error: `Unknown tool: ${call.name}` };
76
103
  }
77
104
  }
78
105
  export function formatResults(results) {
79
106
  return results.map((result) => `[${result.id}] ${result.title} (source: ${result.source}, v${result.version})\n${result.content}`).join("\n\n");
80
107
  }
108
+ /**
109
+ * 让模型在一轮对话里自己决定「回答」还是「录入」,
110
+ * 避免为了判断意图而多跑一次模型调用。
111
+ */
112
+ export const SAVE_KNOWLEDGE_TOOL = {
113
+ name: "save_knowledge",
114
+ description: [
115
+ "把用户提供的内容保存到本地知识库。",
116
+ "当用户意图是「记录 / 保存 / 录入 / 收藏 / 备忘 / 以后要用」某段内容时调用,",
117
+ "例如:记住…、记一下…、记录一下…、把这段存起来、帮我存个配置、以后就按这个来。",
118
+ "只有用户确实在提供一段希望长期保存的知识时才调用。",
119
+ "如果用户在询问、查询、确认、闲聊,不要调用,直接回答。",
120
+ ].join(""),
121
+ inputSchema: {
122
+ type: "object",
123
+ properties: {
124
+ title: { type: "string", description: "对内容的一句话概括,作为知识条目标题,20 字以内" },
125
+ content: { type: "string", description: "要保存的完整内容,规范 Markdown。必须完整保留用户给出的全部信息,不要自行增删或改写事实" },
126
+ },
127
+ required: ["title", "content"],
128
+ },
129
+ };
81
130
  const IMAGE_QUESTION = "\u8bf7\u7528\u4e2d\u6587\u8be6\u7ec6\u63cf\u8ff0\u8fd9\u5f20\u56fe\u7247\u7684\u5185\u5bb9\uff0c\u5199\u6e05\u6240\u6709\u53ef\u89c1\u7684\u6587\u5b57\u3001\u6570\u5b57\u3001\u8868\u683c\u3001\u754c\u9762\u5143\u7d20\u548c\u5173\u952e\u7ec6\u8282\uff0c\u4ee5\u53ca\u56fe\u7247\u6574\u4f53\u5728\u8bb2\u4ec0\u4e48\u3002\u53ea\u8f93\u51fa\u63cf\u8ff0\u6b63\u6587\uff0c\u4e0d\u8981\u5ba2\u5957\u8bdd\u3002";
82
131
  export async function describeImage(config, transport, image) {
83
132
  const reply = await transport.complete(config, [{
package/dist/storage.d.ts CHANGED
@@ -54,6 +54,8 @@ type KnowledgeStoreResult = {
54
54
  };
55
55
  export declare function readLocalFile(filePath: string): LocalFile;
56
56
  export declare function copyOriginal(filePath: string, root: string): string;
57
+ /** \u628a\u624b\u52a8\u5f55\u5165\u7684\u6587\u672c\u843d\u76d8\u4e3a originals/ \u4e0b\u7684 Markdown\uff0c\u8fd4\u56de\u8be5\u6587\u4ef6\u8def\u5f84\u3002 */
58
+ export declare function writeNote(root: string, title: string, content: string): string;
57
59
  export declare function defaultDataRoot(): string;
58
60
  export declare function knowledgeBasePath(root: string, id: string): string;
59
61
  export declare function listKnowledgeBases(root: string): KnowledgeBase[];
package/dist/storage.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, copyFileSync, existsSync, readFileSync, statSync, cpSync, rmSync, readdirSync } from "node:fs";
1
+ import { mkdirSync, copyFileSync, existsSync, readFileSync, statSync, cpSync, rmSync, readdirSync, writeFileSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { join, resolve, extname, basename } from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
@@ -115,6 +115,15 @@ export function copyOriginal(filePath, root) {
115
115
  copyFileSync(filePath, destination);
116
116
  return destination;
117
117
  }
118
+ /** \u628a\u624b\u52a8\u5f55\u5165\u7684\u6587\u672c\u843d\u76d8\u4e3a originals/ \u4e0b\u7684 Markdown\uff0c\u8fd4\u56de\u8be5\u6587\u4ef6\u8def\u5f84\u3002 */
119
+ export function writeNote(root, title, content) {
120
+ const dir = join(root, "originals");
121
+ mkdirSync(dir, { recursive: true });
122
+ const slug = title.replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "note";
123
+ const target = join(dir, `${Date.now()}-${slug}.md`);
124
+ writeFileSync(target, content, "utf8");
125
+ return target;
126
+ }
118
127
  export function defaultDataRoot() {
119
128
  const home = process.env.HOME ?? process.env.USERPROFILE ?? process.cwd();
120
129
  return process.env.KB_DATA_DIR ?? join(home, ".kb");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganziliang/kb",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Local knowledge base agent CLI with a pi-tui interface",
5
5
  "type": "module",
6
6
  "bin": {