thincoder 0.1.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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * distill.mjs — 从会话中提取知识候选条目(双轨制的"自动轨")
3
+ * 原则(已定):手动触发、LLM 出候选、人工逐条确认后入库。
4
+ * 绝不做会话结束后的全自动沉淀。
5
+ */
6
+
7
+ import { chat } from "./provider.mjs"
8
+ import { put, putMarkdown } from "./memory.mjs"
9
+ import { commitAndPush } from "./gitmem.mjs"
10
+
11
+ const DISTILL_PROMPT = `你是知识提取器。阅读下面的 agent 工作会话记录,提取值得跨会话长期记住的知识。
12
+
13
+ 输出一个 JSON 数组(不要输出任何其他内容):
14
+ [
15
+ {
16
+ "type": "rule | knowledge | decision | pattern",
17
+ "title": "简短标题",
18
+ "content": "完整内容,自包含,脱离会话上下文也能看懂",
19
+ "tags": ["tag1", "tag2"],
20
+ "scope": "personal | project"
21
+ }
22
+ ]
23
+
24
+ 提取标准:
25
+ - knowledge:项目的事实性知识(架构、部署、约定俗成的做法)
26
+ - decision:会话中做出的技术决策及理由
27
+ - pattern:调试经验、问题解法、可复用的工作模式
28
+ - rule:编码规范类(谨慎!规范通常应由人手动撰写,只有会话中明确确立的才提取)
29
+ - scope 判断:专属于当前项目的用 project;通用的或个人偏好用 personal
30
+
31
+ 不要提取:
32
+ - 一次性的任务细节("今天改了某个文件的某行")
33
+ - 会话中提到的临时状态(当前的 bug、进行中的工作)
34
+ - 客套话和显而易见的事实
35
+
36
+ 如果没有值得提取的内容,输出 []
37
+ 如果会话太长,优先提取最后出现的、仍在生效的结论。
38
+
39
+ 会话记录:
40
+ `
41
+
42
+ /**
43
+ * 从会话记录提取候选条目。transcript: 纯文本会话记录。
44
+ * 返回 [{ type, title, content, tags, scope }],解析失败返回 []
45
+ */
46
+ export async function extractCandidates(provider, transcript) {
47
+ const res = await chat(provider, {
48
+ messages: [{ role: "user", content: DISTILL_PROMPT + transcript }],
49
+ })
50
+ const match = res.content.match(/\[[\s\S]*\]/)
51
+ if (!match) return []
52
+ try {
53
+ const parsed = JSON.parse(match[0])
54
+ if (!Array.isArray(parsed)) return []
55
+ return parsed.filter((c) => c?.type && c?.title && c?.content)
56
+ } catch {
57
+ return []
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 把 agent 的 OpenAI 格式 history 转成可读的会话记录文本。
63
+ */
64
+ export function historyToTranscript(history, { maxChars = 30_000 } = {}) {
65
+ const lines = []
66
+ for (const m of history) {
67
+ if (m.role === "tool") {
68
+ lines.push(`[工具结果] ${(m.content ?? "").slice(0, 500)}`)
69
+ } else if (m.tool_calls?.length) {
70
+ const calls = m.tool_calls.map((tc) => `${tc.function.name}(${tc.function.arguments?.slice(0, 200) ?? ""})`).join(", ")
71
+ lines.push(`[assistant] ${m.content ?? ""}\n[调用工具] ${calls}`)
72
+ } else {
73
+ lines.push(`[${m.role}] ${m.content ?? ""}`)
74
+ }
75
+ }
76
+ const text = lines.join("\n\n")
77
+ // 超长时保留头尾(最早的需求 + 最新的结论最重要)
78
+ if (text.length <= maxChars) return text
79
+ const half = Math.floor(maxChars / 2)
80
+ return text.slice(0, half) + "\n\n...[中间部分省略]...\n\n" + text.slice(-half)
81
+ }
82
+
83
+ /**
84
+ * 把确认的候选条目写入指定层。
85
+ * opts: { projectDir, team: { dir } | null, author }
86
+ * scope=team 需要 opts.team;project 需要 opts.projectDir。
87
+ * 返回写入结果描述。
88
+ */
89
+ export async function saveCandidate(memory, candidate, opts = {}) {
90
+ const scope = candidate.scope ?? "personal"
91
+ const tags = Array.isArray(candidate.tags) ? candidate.tags : (candidate.tags ?? "").split(/\s+/).filter(Boolean)
92
+
93
+ if (scope === "personal") {
94
+ const id = await put(memory, { type: candidate.type, title: candidate.title, content: candidate.content, tags: tags.join(" ") })
95
+ return `personal#${id}`
96
+ }
97
+ if (scope === "project") {
98
+ if (!opts.projectDir) throw new Error("project scope unavailable")
99
+ const filename = await putMarkdown(memory, {
100
+ layer: "project", dir: opts.projectDir,
101
+ type: candidate.type, title: candidate.title, content: candidate.content,
102
+ tags, author: opts.author ?? "unknown",
103
+ })
104
+ return `project:${filename}`
105
+ }
106
+ if (scope === "team") {
107
+ if (!opts.team?.dir) throw new Error("team scope not configured")
108
+ const filename = await putMarkdown(memory, {
109
+ layer: "team", dir: opts.team.dir,
110
+ type: candidate.type, title: candidate.title, content: candidate.content,
111
+ tags, author: opts.author ?? "unknown",
112
+ })
113
+ await commitAndPush(opts.team.dir, filename, `memory: [${candidate.type}] ${candidate.title} (distilled)`)
114
+ return `team:${filename}`
115
+ }
116
+ throw new Error(`unknown scope: ${scope}`)
117
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * embedding.mjs — 向量嵌入
3
+ * OpenAI 兼容 /v1/embeddings(SiliconFlow bge-m3 / Ollama / OpenAI 均可),
4
+ * 复用 provider.mjs 的 fetch + 重试模式,零依赖。
5
+ * 向量在入库前归一化,之后点积即余弦相似度。
6
+ */
7
+
8
+ import { RETRYABLE_STATUS } from "./provider.mjs"
9
+ const MAX_RETRIES = 3
10
+ const BATCH_SIZE = 32 // 单次请求的文本数上限(SiliconFlow 限制内)
11
+
12
+ /** 创建 embedder。config: { baseURL, apiKey, model } */
13
+ export function createEmbedder(config) {
14
+ if (!config?.baseURL) throw new Error("embedding config: baseURL is required")
15
+ if (!config?.apiKey) throw new Error("embedding config: apiKey is required (config file or SILICONFLOW_API_KEY env)")
16
+ if (!config?.model) throw new Error("embedding config: model is required")
17
+ return {
18
+ baseURL: config.baseURL.replace(/\/+$/, ""),
19
+ apiKey: config.apiKey,
20
+ model: config.model,
21
+ }
22
+ }
23
+
24
+ /**
25
+ * 批量嵌入。texts: string[] → Float32Array[](已归一化)
26
+ * 自动分批,失败重试(指数退避)。
27
+ */
28
+ export async function embed(embedder, texts, { signal } = {}) {
29
+ if (texts.length === 0) return []
30
+ const vectors = []
31
+ for (let i = 0; i < texts.length; i += BATCH_SIZE) {
32
+ const batch = texts.slice(i, i + BATCH_SIZE)
33
+ const data = await requestWithRetry(embedder, batch, signal)
34
+ // API 按 data[].embedding 返回,顺序与输入一致
35
+ for (const item of data.data) {
36
+ vectors.push(normalize(Float32Array.from(item.embedding)))
37
+ }
38
+ }
39
+ return vectors
40
+ }
41
+
42
+ /** 余弦相似度(输入均已归一化,点积即余弦) */
43
+ export function cosine(a, b) {
44
+ let sum = 0
45
+ const n = Math.min(a.length, b.length)
46
+ for (let i = 0; i < n; i++) sum += a[i] * b[i]
47
+ return sum
48
+ }
49
+
50
+ /** Float32Array → 可存 sqlite BLOB 的 Buffer */
51
+ export function toBlob(vec) {
52
+ return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength)
53
+ }
54
+
55
+ /** sqlite BLOB → Float32Array */
56
+ export function fromBlob(buf) {
57
+ return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4)
58
+ }
59
+
60
+ // ---------------------------------------------------------------- 内部
61
+
62
+ async function requestWithRetry(embedder, input, signal) {
63
+ let lastError
64
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
65
+ if (attempt > 0) await sleep(2 ** (attempt - 1) * 1000)
66
+
67
+ let response
68
+ try {
69
+ response = await fetch(`${embedder.baseURL}/embeddings`, {
70
+ method: "POST",
71
+ headers: {
72
+ "Content-Type": "application/json",
73
+ Authorization: `Bearer ${embedder.apiKey}`,
74
+ },
75
+ body: JSON.stringify({ model: embedder.model, input }),
76
+ signal,
77
+ })
78
+ } catch (error) {
79
+ if (error.name === "AbortError") throw error
80
+ lastError = error
81
+ continue
82
+ }
83
+
84
+ if (response.ok) return response.json()
85
+
86
+ const text = await response.text().catch(() => "")
87
+ const message = `Embedding API error ${response.status}: ${text}`
88
+ if (RETRYABLE_STATUS.has(response.status)) {
89
+ lastError = new Error(message)
90
+ continue
91
+ }
92
+ throw new Error(message)
93
+ }
94
+ throw lastError
95
+ }
96
+
97
+ function normalize(vec) {
98
+ let sum = 0
99
+ for (let i = 0; i < vec.length; i++) sum += vec[i] * vec[i]
100
+ const norm = Math.sqrt(sum) || 1
101
+ for (let i = 0; i < vec.length; i++) vec[i] /= norm
102
+ return vec
103
+ }
104
+
105
+ function sleep(ms) {
106
+ return new Promise((resolve) => setTimeout(resolve, ms))
107
+ }
package/src/gitmem.mjs ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * gitmem.mjs — Team 层记忆的 git 同步
3
+ * 全部通过 child_process 调系统 git,零依赖。
4
+ * 冲突策略(已定):不同条目天然不冲突;真冲突时中止 rebase 保持仓库干净,
5
+ * 报带手动指引的错误——不做自动合并。
6
+ */
7
+
8
+ import { execFile } from "node:child_process"
9
+ import { existsSync } from "node:fs"
10
+ import { mkdir } from "node:fs/promises"
11
+ import { dirname, join } from "node:path"
12
+ import { promisify } from "node:util"
13
+
14
+ const execFileAsync = promisify(execFile)
15
+
16
+ /** 在 dir 下执行 git,失败抛带 stderr 的错误 */
17
+ async function git(dir, args) {
18
+ try {
19
+ const { stdout } = await execFileAsync("git", args, { cwd: dir, encoding: "utf8" })
20
+ return stdout.trim()
21
+ } catch (error) {
22
+ const detail = error.stderr?.trim() || error.message
23
+ const err = new Error(`git ${args.join(" ")} failed: ${detail}`)
24
+ err.gitError = true
25
+ err.stderr = detail
26
+ throw err
27
+ }
28
+ }
29
+
30
+ /** 团队仓库不存在则 clone。返回是否发生了 clone */
31
+ export async function ensureClone({ repo, dir }) {
32
+ if (existsSync(join(dir, ".git"))) return false
33
+ await mkdir(dirname(dir), { recursive: true })
34
+ await git(dirname(dir), ["clone", repo, dir])
35
+ return true
36
+ }
37
+
38
+ /**
39
+ * 同步:pull --rebase。远端还是空仓库时直接跳过(首次使用前)。
40
+ * 冲突时中止 rebase(保持仓库干净)并抛带指引的错误。
41
+ * 返回 true=拉取成功(调用方随后 syncDir 重建索引)
42
+ */
43
+ export async function pullTeam(dir) {
44
+ // 远端空仓库:没有可拉取的分支(ls-remote 无输出)
45
+ const refs = await git(dir, ["ls-remote", "--heads", "origin"])
46
+ if (!refs) return false
47
+
48
+ try {
49
+ await git(dir, ["pull", "--rebase"])
50
+ return true
51
+ } catch (error) {
52
+ if (await hasConflict(dir)) {
53
+ await git(dir, ["rebase", "--abort"]).catch(() => {})
54
+ throw new Error(
55
+ `团队记忆同步冲突:本地与远端修改了同一条目。\n` +
56
+ `请到 ${dir} 手动执行 git pull 解决冲突,然后重新运行 thincoder sync。\n` +
57
+ `(本地仓库已恢复到同步前状态,未丢失任何内容)`,
58
+ )
59
+ }
60
+ throw error
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 提交并推送一个条目文件。push 被拒(远端有新提交)时 pull --rebase 后重试一次;
66
+ * rebase 冲突同样中止并报错。
67
+ */
68
+ export async function commitAndPush(dir, filename, message) {
69
+ await git(dir, ["add", filename])
70
+ await git(dir, ["commit", "-m", message])
71
+ try {
72
+ await git(dir, ["push"])
73
+ } catch {
74
+ await pullTeam(dir) // 冲突时这里会抛出带指引的错误
75
+ await git(dir, ["push"])
76
+ }
77
+ }
78
+
79
+ /** 当前是否处于 rebase 冲突状态(存在未合并路径) */
80
+ async function hasConflict(dir) {
81
+ try {
82
+ const out = await git(dir, ["status", "--porcelain"])
83
+ return out.split("\n").some((l) => l.startsWith("UU") || l.startsWith("AA") || l.startsWith("DD"))
84
+ } catch {
85
+ return false
86
+ }
87
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * markdown.mjs — 记忆条目的 markdown + frontmatter 格式
3
+ * 零依赖解析/序列化。条目格式见 ARCHITECTURE-v2.md。
4
+ */
5
+
6
+ const VALID_TYPES = new Set(["rule", "knowledge", "decision", "pattern"])
7
+
8
+ /**
9
+ * 解析 markdown 条目。
10
+ * → { meta: { type, title, tags, author, created, embedding? }, content }
11
+ * 无 frontmatter 或缺必要字段时抛错。
12
+ */
13
+ export function parseEntry(text) {
14
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
15
+ if (!match) throw new Error("entry missing frontmatter (expected --- ... ---)")
16
+
17
+ const meta = parseFrontmatter(match[1])
18
+ const content = match[2].trim()
19
+
20
+ if (!VALID_TYPES.has(meta.type)) {
21
+ throw new Error(`invalid type "${meta.type}"; expected one of: ${[...VALID_TYPES].join(", ")}`)
22
+ }
23
+ if (!meta.title) throw new Error("frontmatter missing required field: title")
24
+
25
+ return {
26
+ meta: {
27
+ type: meta.type,
28
+ title: meta.title,
29
+ tags: Array.isArray(meta.tags) ? meta.tags : meta.tags ? [meta.tags] : [],
30
+ author: meta.author ?? "unknown",
31
+ created: meta.created ?? "",
32
+ ...(meta.embedding ? { embedding: meta.embedding } : {}),
33
+ },
34
+ content,
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 序列化为 markdown 条目文本。
40
+ */
41
+ export function serializeEntry(meta, content) {
42
+ if (!VALID_TYPES.has(meta.type)) throw new Error(`invalid type "${meta.type}"`)
43
+ if (!meta.title) throw new Error("meta.title is required")
44
+ const tags = (meta.tags ?? []).map((t) => `${t}`).join(", ")
45
+ const lines = [
46
+ "---",
47
+ `type: ${meta.type}`,
48
+ `title: ${meta.title}`,
49
+ `tags: [${tags}]`,
50
+ `author: ${meta.author ?? "unknown"}`,
51
+ `created: ${meta.created ?? new Date().toISOString().slice(0, 10)}`,
52
+ ]
53
+ if (meta.embedding) lines.push(`embedding: ${meta.embedding}`)
54
+ lines.push("---", "", content.trim(), "")
55
+ return lines.join("\n")
56
+ }
57
+
58
+ /** 标题转文件名 slug:保留中英文数字,其余转连字符 */
59
+ export function slugify(title) {
60
+ return title
61
+ .trim()
62
+ .toLowerCase()
63
+ .replace(/[^\w一-鿿]+/g, "-")
64
+ .replace(/^-+|-+$/g, "")
65
+ .slice(0, 50) || "untitled"
66
+ }
67
+
68
+ /** 生成条目文件名:YYYYMMDD-<slug>-<rand4>.md */
69
+ export function entryFilename(title, date = new Date()) {
70
+ const ymd = date.toISOString().slice(0, 10).replaceAll("-", "")
71
+ const rand = Math.random().toString(36).slice(2, 6)
72
+ return `${ymd}-${slugify(title)}-${rand}.md`
73
+ }
74
+
75
+ // ---------------------------------------------------------------- 内部
76
+
77
+ /**
78
+ * 极简 YAML 子集解析:只支持 `key: value` 和 `key: [a, b, c]`。
79
+ * 我们的 frontmatter 是自己生成的,不需要完整 YAML。
80
+ */
81
+ function parseFrontmatter(text) {
82
+ const meta = {}
83
+ for (const line of text.split(/\r?\n/)) {
84
+ const m = line.match(/^(\w[\w-]*)\s*:\s*(.*)$/)
85
+ if (!m) continue
86
+ const [, key, raw] = m
87
+ const value = raw.trim()
88
+ if (value.startsWith("[") && value.endsWith("]")) {
89
+ meta[key] = value
90
+ .slice(1, -1)
91
+ .split(",")
92
+ .map((s) => s.trim())
93
+ .filter(Boolean)
94
+ } else {
95
+ meta[key] = value
96
+ }
97
+ }
98
+ return meta
99
+ }