@thincoder/core 0.9.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.
Files changed (203) hide show
  1. package/abort-provenance.mjs +116 -0
  2. package/advisor/citations.mjs +139 -0
  3. package/advisor/compaction.mjs +174 -0
  4. package/advisor/convergence.mjs +80 -0
  5. package/advisor/history.mjs +77 -0
  6. package/advisor/loop.mjs +288 -0
  7. package/advisor/messages.mjs +299 -0
  8. package/advisor/notice.mjs +141 -0
  9. package/advisor/project-context.mjs +197 -0
  10. package/advisor/repos.mjs +150 -0
  11. package/advisor/run.mjs +190 -0
  12. package/advisor/truncate.mjs +57 -0
  13. package/advisor.mjs +281 -0
  14. package/agent/child-marks.mjs +24 -0
  15. package/agent/completion.mjs +145 -0
  16. package/agent/dispatch.mjs +493 -0
  17. package/agent/family-tools.mjs +174 -0
  18. package/agent/helpers.mjs +412 -0
  19. package/agent/post-turn.mjs +70 -0
  20. package/agent/record-results.mjs +174 -0
  21. package/agent/relay-prefix.mjs +39 -0
  22. package/agent/run-stages.mjs +244 -0
  23. package/agent/setup-reminders.mjs +199 -0
  24. package/agent/setup.mjs +234 -0
  25. package/agent/spawn-child.mjs +258 -0
  26. package/agent/suspension.mjs +240 -0
  27. package/agent/write-gate.mjs +87 -0
  28. package/agent-tools/advisor-async.mjs +481 -0
  29. package/agent-tools/advisor-settle.mjs +240 -0
  30. package/agent-tools/advisor.mjs +280 -0
  31. package/agent-tools/async-discard.mjs +143 -0
  32. package/agent-tools/async-settle.mjs +299 -0
  33. package/agent-tools/batch-segment.mjs +265 -0
  34. package/agent-tools/child-permission.mjs +45 -0
  35. package/agent-tools/consult.mjs +471 -0
  36. package/agent-tools/design-token.mjs +117 -0
  37. package/agent-tools/digest-budget.mjs +76 -0
  38. package/agent-tools/eng.mjs +102 -0
  39. package/agent-tools/escalate-async.mjs +302 -0
  40. package/agent-tools/goal.mjs +119 -0
  41. package/agent-tools/panel-blocks.mjs +24 -0
  42. package/agent-tools/parent-channel.mjs +231 -0
  43. package/agent-tools/plan.mjs +86 -0
  44. package/agent-tools/read-history.mjs +309 -0
  45. package/agent-tools/recent-changes.mjs +24 -0
  46. package/agent-tools/review-facts.mjs +31 -0
  47. package/agent-tools/settings.mjs +268 -0
  48. package/agent-tools/skill.mjs +63 -0
  49. package/agent-tools/spawn-gates.mjs +109 -0
  50. package/agent-tools/subagent-actions.mjs +496 -0
  51. package/agent-tools/subagent-async.mjs +456 -0
  52. package/agent-tools/subagent-panel.mjs +160 -0
  53. package/agent-tools/subagent-run.mjs +208 -0
  54. package/agent-tools/subagent-scheduler.mjs +446 -0
  55. package/agent-tools/subagent-spawn.mjs +478 -0
  56. package/agent-tools/subagent.mjs +419 -0
  57. package/agent-tools/task.mjs +87 -0
  58. package/agent-tools/timer.mjs +46 -0
  59. package/agent-tools/verify.mjs +295 -0
  60. package/agent-tools.mjs +23 -0
  61. package/agent.mjs +430 -0
  62. package/auto-think.mjs +115 -0
  63. package/compress-form.mjs +24 -0
  64. package/config-io.mjs +277 -0
  65. package/config-migrate.mjs +178 -0
  66. package/config-presets.mjs +49 -0
  67. package/config.mjs +419 -0
  68. package/context.mjs +495 -0
  69. package/conventions.mjs +223 -0
  70. package/embedding.mjs +120 -0
  71. package/escape.mjs +152 -0
  72. package/expand-home.mjs +16 -0
  73. package/explore-distill.mjs +152 -0
  74. package/generate-title.mjs +123 -0
  75. package/git/checkpoint.mjs +448 -0
  76. package/git/gitmem.mjs +100 -0
  77. package/history-window.mjs +179 -0
  78. package/hooks.mjs +108 -0
  79. package/i18n.mjs +106 -0
  80. package/index-bin.mjs +48 -0
  81. package/index-discover.mjs +176 -0
  82. package/ledger-cmd.mjs +209 -0
  83. package/ledger-db.mjs +86 -0
  84. package/ledger-surface.mjs +76 -0
  85. package/ledger.mjs +202 -0
  86. package/log.mjs +195 -0
  87. package/manifest.mjs +338 -0
  88. package/markdown.mjs +106 -0
  89. package/mcp/helpers.mjs +51 -0
  90. package/mcp/transport-http.mjs +248 -0
  91. package/mcp/transport-stdio.mjs +140 -0
  92. package/mcp/transport-ws.mjs +122 -0
  93. package/mcp.mjs +295 -0
  94. package/memory/code-index.mjs +219 -0
  95. package/memory/code-sync.mjs +427 -0
  96. package/memory/core.mjs +318 -0
  97. package/memory/delete.mjs +242 -0
  98. package/memory/docs.mjs +431 -0
  99. package/memory/file-walk.mjs +109 -0
  100. package/memory/origin.mjs +24 -0
  101. package/memory/scan.mjs +177 -0
  102. package/memory/schema.mjs +460 -0
  103. package/memory.mjs +21 -0
  104. package/model-ref.mjs +66 -0
  105. package/model-specs.mjs +277 -0
  106. package/package.json +32 -0
  107. package/peer-domains.mjs +265 -0
  108. package/peer-instances.mjs +178 -0
  109. package/permission.mjs +79 -0
  110. package/process-probe.mjs +315 -0
  111. package/prompt-files.mjs +113 -0
  112. package/prompt-overlays.mjs +78 -0
  113. package/prompts/advisor-design.md +43 -0
  114. package/prompts/advisor-round1.md +41 -0
  115. package/prompts/advisor-round2.md +46 -0
  116. package/prompts/advisor-round3.md +42 -0
  117. package/prompts/common.md +158 -0
  118. package/prompts/consult-base.md +19 -0
  119. package/prompts/discipline-engineering.md +123 -0
  120. package/prompts/discipline-normal.md +206 -0
  121. package/prompts/persona-coder.md +21 -0
  122. package/prompts/persona-eng-coder.md +41 -0
  123. package/prompts/persona-eng-designer.md +80 -0
  124. package/prompts/persona-engineering.md +160 -0
  125. package/prompts/persona-explore.md +15 -0
  126. package/prompts/persona-normal.md +35 -0
  127. package/prompts/persona-plan.md +27 -0
  128. package/provider/anthropic.mjs +225 -0
  129. package/provider/core.mjs +491 -0
  130. package/provider/errors.mjs +101 -0
  131. package/provider/google.mjs +257 -0
  132. package/provider/index.mjs +7 -0
  133. package/provider/list-models.mjs +163 -0
  134. package/provider/normalize.mjs +81 -0
  135. package/provider/rate.mjs +168 -0
  136. package/provider/responses.mjs +495 -0
  137. package/provider/retry.mjs +88 -0
  138. package/provider/sse.mjs +264 -0
  139. package/provider/wait-status.mjs +59 -0
  140. package/proxy.mjs +274 -0
  141. package/rules.mjs +53 -0
  142. package/session-gc.mjs +248 -0
  143. package/session-guard.mjs +59 -0
  144. package/session-lifecycle.mjs +305 -0
  145. package/session-migrate.mjs +48 -0
  146. package/session-rename.mjs +38 -0
  147. package/session-segments.mjs +99 -0
  148. package/session-slot-write.mjs +168 -0
  149. package/session-slots-manifest.mjs +264 -0
  150. package/session-slots.mjs +298 -0
  151. package/session-store.mjs +441 -0
  152. package/session.mjs +244 -0
  153. package/skills.mjs +234 -0
  154. package/text-budget.mjs +79 -0
  155. package/token-ttl.mjs +285 -0
  156. package/tool-docs/apply_patch.md +15 -0
  157. package/tool-docs/bash.md +38 -0
  158. package/tool-docs/delete.md +13 -0
  159. package/tool-docs/edit.md +30 -0
  160. package/tool-docs/execute.md +21 -0
  161. package/tool-docs/fetch.md +12 -0
  162. package/tool-docs/file_ops.md +17 -0
  163. package/tool-docs/get_current_time.md +8 -0
  164. package/tool-docs/git.md +54 -0
  165. package/tool-docs/glob.md +11 -0
  166. package/tool-docs/grep.md +19 -0
  167. package/tool-docs/hashline_edit.md +14 -0
  168. package/tool-docs/insert_after.md +15 -0
  169. package/tool-docs/lint.md +10 -0
  170. package/tool-docs/ls.md +12 -0
  171. package/tool-docs/lsp.md +10 -0
  172. package/tool-docs/process.md +10 -0
  173. package/tool-docs/question.md +16 -0
  174. package/tool-docs/read.md +20 -0
  175. package/tool-docs/read_image.md +8 -0
  176. package/tool-docs/tree.md +14 -0
  177. package/tool-docs/wait_for.md +22 -0
  178. package/tool-docs/websearch.md +16 -0
  179. package/tool-docs/write.md +11 -0
  180. package/tools/bash.mjs +276 -0
  181. package/tools/edit-batch.mjs +204 -0
  182. package/tools/edit-diff.mjs +388 -0
  183. package/tools/exec-run.mjs +43 -0
  184. package/tools/execute.mjs +243 -0
  185. package/tools/file.mjs +464 -0
  186. package/tools/git-checkpoint.mjs +143 -0
  187. package/tools/git-ext.mjs +173 -0
  188. package/tools/git.mjs +415 -0
  189. package/tools/glob-dialect.mjs +130 -0
  190. package/tools/index.mjs +76 -0
  191. package/tools/linter.mjs +120 -0
  192. package/tools/lsp.mjs +335 -0
  193. package/tools/ops.mjs +293 -0
  194. package/tools/patch.mjs +290 -0
  195. package/tools/question.mjs +26 -0
  196. package/tools/repomap.mjs +314 -0
  197. package/tools/search.mjs +248 -0
  198. package/tools/shared.mjs +467 -0
  199. package/tools/tree.mjs +81 -0
  200. package/tools/web.mjs +224 -0
  201. package/tools/write-path.mjs +191 -0
  202. package/traces/trace-store.mjs +303 -0
  203. package/undo-stack.mjs +47 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * conventions.mjs — the single authority for code / doc / temp path classification,
3
+ * plus the project convention declaration surface (`.thincoder/conventions.json`).
4
+ *
5
+ * Why one module: engineering-mode gates and guards (design gate, review-doc gate,
6
+ * mutation accounting, verify fast path) each carried their own copy of the
7
+ * "what counts as product code" predicate — anchored `^src/` regexes, `docs/`
8
+ * prefix checks, component regexes. Each copy drifted, and each hardcoded THIS
9
+ * repository's layout: a project whose code lives outside `src/` slipped through
10
+ * the design gate silently (PORTABILITY FR12 / PO-10). One classifier + one
11
+ * declaration file = one truth.
12
+ *
13
+ * Defaults are DATA (`DEFAULT_CODE_PATHS`) — overridable per project through the
14
+ * declaration file (§4.1 schema). Missing file → pure defaults (no noise);
15
+ * corrupt/unreadable file → defaults + console.warn + a log event (never crash,
16
+ * never swallow — PORTABILITY FR10).
17
+ *
18
+ * Classification vocabulary (PORTABILITY design §3.1):
19
+ * code — inside a declared code segment (default: the path segment `src`), or
20
+ * not a documentation extension; doc — documentation extension outside
21
+ * any code segment; temp — tmp-* name or .tmp/.temp extension.
22
+ */
23
+ import { readFileSync } from "node:fs"
24
+ import { join, resolve } from "node:path"
25
+ import { logEvent } from "./log.mjs"
26
+
27
+ /** Default code-path segments (data, not logic — a project may replace them). */
28
+ export const DEFAULT_CODE_PATHS = ["src"]
29
+
30
+ /** Project declaration file, relative to the project root. */
31
+ export const CONVENTIONS_REL_PATH = ".thincoder/conventions.json"
32
+
33
+ /** Documentation predicate (moved here verbatim from advisor/repos.mjs — one copy). */
34
+ const DOC_FILE = /(?:^|[/\\])(?:LICENSE|NOTICE|CHANGELOG|AUTHORS)(?:\.\w+)?$|\.(?:md|markdown|mdx|txt|rst|adoc)$/i
35
+
36
+ /** Temp/scratch predicate (moved here verbatim from advisor/repos.mjs). */
37
+ const TEMP_FILE = /(?:^|[/\\])tmp-[^/\\]+$|\.(?:tmp|temp)$/i
38
+
39
+ /** True when a path is a throwaway temp file (tmp-* name or .tmp/.temp extension). */
40
+ export function isTempPath(p) {
41
+ return TEMP_FILE.test(p ?? "")
42
+ }
43
+
44
+ /** Path → segments (both separators accepted; absolute and relative alike). */
45
+ function segmentsOf(p) {
46
+ return String(p ?? "").replace(/\\/g, "/").split("/").filter(Boolean)
47
+ }
48
+
49
+ /**
50
+ * True when the path contains a declared code segment sequence at any depth.
51
+ * Segment matching (not a prefix anchor) is what closes the nested-layout hole:
52
+ * `packages/foo/src/x.md` is product code, not a document. Comparison is
53
+ * case-insensitive — on case-insensitive filesystems `Src/x.mjs` is the same
54
+ * directory, and the gate must not be bypassable by casing.
55
+ */
56
+ function hasCodeSegment(p, conv) {
57
+ const parts = segmentsOf(p).map((s) => s.toLowerCase())
58
+ const wanted = conv?.codePaths ?? DEFAULT_CODE_PATHS
59
+ for (const entry of wanted) {
60
+ const want = segmentsOf(entry).map((s) => s.toLowerCase())
61
+ if (want.length === 0) continue
62
+ for (let i = 0; i + want.length <= parts.length; i++) {
63
+ if (want.every((seg, j) => parts[i + j] === seg)) return true
64
+ }
65
+ }
66
+ return false
67
+ }
68
+
69
+ /** "code" | "doc" | "temp" — the single classification decision.
70
+ * Precedence: code segment first (src/** stays product code even when the name
71
+ * looks scratch — the pre-existing unconditional-src rule), then temp, then a
72
+ * documentation extension, else code (anything not doc/temp is product code). */
73
+ export function classifyPath(p, conv) {
74
+ const s = String(p ?? "")
75
+ if (hasCodeSegment(s, conv)) return "code"
76
+ if (TEMP_FILE.test(s)) return "temp"
77
+ if (DOC_FILE.test(s)) return "doc"
78
+ return "code"
79
+ }
80
+
81
+ /** True when the path is product code (see classifyPath for the precedence). */
82
+ export function isCodePath(p, conv) {
83
+ return classifyPath(p, conv) === "code"
84
+ }
85
+
86
+ /** True when the path is a documentation file — a doc extension that does NOT
87
+ * live inside a declared code segment (src/prompts/*.md is product code). */
88
+ export function isDocPath(p, conv) {
89
+ const s = String(p ?? "")
90
+ return DOC_FILE.test(s) && !hasCodeSegment(s, conv)
91
+ }
92
+
93
+ // ─────────────────────────────────────────────────────────────────────────────
94
+ // Declaration loading (cached per project root — `clearConventionsCache()` is
95
+ // the test seam; declaration files change rarely and only at session scope).
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+
98
+ function normalizeExtensions(v) {
99
+ if (!Array.isArray(v)) return []
100
+ const out = []
101
+ for (const e of v) {
102
+ if (typeof e !== "string") continue
103
+ const t = e.trim().toLowerCase()
104
+ if (!t) continue
105
+ const ext = t.startsWith(".") ? t : `.${t}`
106
+ if (!out.includes(ext)) out.push(ext)
107
+ }
108
+ return out
109
+ }
110
+
111
+ /** Declared code paths replace the default (replacement, not union — §4.1). */
112
+ function normalizeCodePaths(v) {
113
+ if (!Array.isArray(v)) return null
114
+ const out = []
115
+ for (const e of v) {
116
+ if (typeof e !== "string") continue
117
+ const s = e.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "")
118
+ if (!s || out.includes(s)) continue
119
+ out.push(s)
120
+ }
121
+ return out.length > 0 ? out : null
122
+ }
123
+
124
+ function normalizeString(v) {
125
+ return typeof v === "string" && v.trim() ? v.trim() : ""
126
+ }
127
+
128
+ /** Per-key type check for recognized keys (present but wrong type). §3.2/§4.1: a
129
+ * type error degrades WITH a warning — never silently (the fallback semantics stay
130
+ * per-key; only the visibility is added here). */
131
+ function typeErrorsOf(raw) {
132
+ const isObj = (v) => v !== undefined && v !== null && typeof v === "object" && !Array.isArray(v)
133
+ const strArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string")
134
+ const errs = []
135
+ if (raw.codePaths !== undefined && !strArray(raw.codePaths)) errs.push("codePaths must be an array of strings")
136
+ const idx = raw.index
137
+ if (idx !== undefined && !isObj(idx)) errs.push("index must be an object")
138
+ else if (isObj(idx)) {
139
+ for (const k of ["codeExtensions", "docExtensions"]) {
140
+ if (idx[k] !== undefined && !strArray(idx[k])) errs.push(`index.${k} must be an array of strings`)
141
+ }
142
+ }
143
+ const adv = raw.advisor
144
+ if (adv !== undefined && !isObj(adv)) errs.push("advisor must be an object")
145
+ else if (isObj(adv)) {
146
+ for (const k of ["docMap", "standardsDoc"]) {
147
+ if (adv[k] !== undefined && typeof adv[k] !== "string") errs.push(`advisor.${k} must be a string`)
148
+ }
149
+ }
150
+ return errs
151
+ }
152
+
153
+ function buildConventions(raw) {
154
+ const codePaths = normalizeCodePaths(raw?.codePaths)
155
+ const codeExtensions = normalizeExtensions(raw?.index?.codeExtensions)
156
+ const docExtensions = normalizeExtensions(raw?.index?.docExtensions)
157
+ const docMap = normalizeString(raw?.advisor?.docMap)
158
+ const standardsDoc = normalizeString(raw?.advisor?.standardsDoc)
159
+ // `declared` = the declaration actually took effect (at least one recognized key
160
+ // honored) — the design-gate hint reads it to decide whether to point at the
161
+ // declaration file ("declare project conventions … to adjust").
162
+ const declared = Boolean(codePaths || codeExtensions.length || docExtensions.length || docMap || standardsDoc)
163
+ return Object.freeze({
164
+ declared,
165
+ codePaths: Object.freeze(codePaths ?? [...DEFAULT_CODE_PATHS]),
166
+ index: Object.freeze({
167
+ codeExtensions: Object.freeze(codeExtensions),
168
+ docExtensions: Object.freeze(docExtensions),
169
+ }),
170
+ advisor: Object.freeze({ docMap, standardsDoc }),
171
+ })
172
+ }
173
+
174
+ /** Full-default conventions (no declaration) — the fallback every consumer gets. */
175
+ export const DEFAULT_CONVENTIONS = buildConventions(null)
176
+
177
+ const _cache = new Map()
178
+
179
+ /** Drop the per-root cache (test seam — declaration files are read once per root). */
180
+ export function clearConventionsCache() {
181
+ _cache.clear()
182
+ }
183
+
184
+ /**
185
+ * Load (and cache) the normalized conventions for a project root.
186
+ * @param {string} cwd — project root (declaration lives at .thincoder/conventions.json)
187
+ * @returns {Readonly<{declared: boolean, codePaths: readonly string[],
188
+ * index: {codeExtensions: string[], docExtensions: string[]},
189
+ * advisor: {docMap: string, standardsDoc: string}}>}
190
+ */
191
+ export function loadConventions(cwd) {
192
+ const root = resolve(cwd ?? process.cwd())
193
+ const hit = _cache.get(root)
194
+ if (hit) return hit
195
+ let conv = DEFAULT_CONVENTIONS
196
+ try {
197
+ const text = readFileSync(join(root, CONVENTIONS_REL_PATH), "utf8")
198
+ try {
199
+ const raw = JSON.parse(text)
200
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("top level must be a JSON object")
201
+ conv = buildConventions(raw)
202
+ const typeErrs = typeErrorsOf(raw)
203
+ if (typeErrs.length > 0) {
204
+ // Wrong-typed keys fall back per-key — but the user must SEE that their
205
+ // declaration did not take effect (never silently swallowed).
206
+ console.warn(`[conventions] ${CONVENTIONS_REL_PATH} has invalid value types (${typeErrs.join("; ")}) — those keys fall back to defaults`)
207
+ logEvent("conventions:error", { cwd: root, err: `type errors: ${typeErrs.join("; ").slice(0, 160)}` })
208
+ }
209
+ } catch (e) {
210
+ // Corrupt file / wrong shape → defaults, visible: warn + event (never silent).
211
+ console.warn(`[conventions] ${CONVENTIONS_REL_PATH} unreadable (${e?.message ?? e}) — falling back to defaults`)
212
+ logEvent("conventions:error", { cwd: root, err: String(e?.message ?? e).slice(0, 200) })
213
+ }
214
+ } catch (e) {
215
+ if (e?.code !== "ENOENT") {
216
+ // File exists but cannot be read (EACCES etc.) — same visible degradation.
217
+ console.warn(`[conventions] ${CONVENTIONS_REL_PATH} not readable (${e?.message ?? e}) — falling back to defaults`)
218
+ logEvent("conventions:error", { cwd: root, err: String(e?.message ?? e).slice(0, 200) })
219
+ }
220
+ }
221
+ _cache.set(root, conv)
222
+ return conv
223
+ }
package/embedding.mjs ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * embedding.mjs — vector embeddings
3
+ * OpenAI-compatible /v1/embeddings (SiliconFlow bge-m3 / Ollama / OpenAI all supported),
4
+ * reuses provider.mjs fetch + retry pattern, zero dependencies.
5
+ * Vectors are normalized before storage; dot product then equals cosine similarity.
6
+ */
7
+
8
+ import { RETRYABLE_STATUS } from "./provider/index.mjs"
9
+ const MAX_RETRIES = 3
10
+ const BATCH_SIZE = 32 // max texts per request (within SiliconFlow limits)
11
+
12
+ /** Create an embedder. config: { baseURL, apiKey, model } */
13
+ export function createEmbedder(config) {
14
+ if (!config?.baseURL) throw new Error("embedding config: baseURL is required — configure embedding.baseURL in ~/.thincoder/config.json")
15
+ if (!config?.apiKey) throw new Error("embedding config: apiKey is required — configure embedding.apiKey in ~/.thincoder/config.json")
16
+ if (!config?.model) throw new Error("embedding config: model is required — configure embedding.model in ~/.thincoder/config.json")
17
+ return {
18
+ baseURL: config.baseURL.replace(/\/+$/, ""),
19
+ apiKey: config.apiKey,
20
+ model: config.model,
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Batch embedding. texts: string[] → Float32Array[] (normalized)
26
+ * Auto-batches, retries on failure (exponential backoff).
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
+ // Mismatched count is a hard error — silently accepting would misalign vectors with texts, poisoning the entire index
35
+ if (!Array.isArray(data.data) || data.data.length !== batch.length) {
36
+ throw new Error(`Embedding API returned ${data.data?.length ?? 0} vectors for ${batch.length} inputs`)
37
+ }
38
+ // Spec says data[] order matches input, but sort by index field if present — don't bet on server implementation
39
+ const items = data.data.every((d) => typeof d.index === "number")
40
+ ? [...data.data].sort((a, b) => a.index - b.index)
41
+ : data.data
42
+ for (const item of items) {
43
+ vectors.push(normalize(Float32Array.from(item.embedding)))
44
+ }
45
+ }
46
+ return vectors
47
+ }
48
+
49
+ /** Cosine similarity (inputs are normalized, dot product equals cosine) */
50
+ export function cosine(a, b) {
51
+ if (a.length !== b.length) return 0
52
+ let sum = 0
53
+ const n = a.length
54
+ for (let i = 0; i < n; i++) sum += a[i] * b[i]
55
+ return sum
56
+ }
57
+
58
+ /** Float32Array → Buffer suitable for sqlite BLOB storage */
59
+ export function toBlob(vec) {
60
+ return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength)
61
+ }
62
+
63
+ /** sqlite BLOB → Float32Array */
64
+ export function fromBlob(buf) {
65
+ // BLOB may come from Buffer pool where byteOffset isn't 4-aligned; creating a view directly would RangeError — copy to align first
66
+ if (buf.byteOffset % 4 !== 0) buf = new Uint8Array(buf)
67
+ if (buf.byteLength % 4 !== 0) return new Float32Array(0)
68
+ return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4)
69
+ }
70
+
71
+ // ---------------------------------------------------------------- internal
72
+
73
+ async function requestWithRetry(embedder, input, signal) {
74
+ let lastError
75
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
76
+ if (attempt > 0) await sleep(2 ** (attempt - 1) * 1000)
77
+
78
+ let response
79
+ try {
80
+ response = await fetch(`${embedder.baseURL}/embeddings`, {
81
+ method: "POST",
82
+ headers: {
83
+ "Content-Type": "application/json",
84
+ Authorization: `Bearer ${embedder.apiKey}`,
85
+ },
86
+ body: JSON.stringify({ model: embedder.model, input }),
87
+ signal: signal
88
+ ? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
89
+ : AbortSignal.timeout(60_000),
90
+ })
91
+ } catch (error) {
92
+ if (error.name === "AbortError") throw error
93
+ lastError = error
94
+ continue
95
+ }
96
+
97
+ if (response.ok) return response.json()
98
+
99
+ const text = await response.text().catch(() => "")
100
+ const message = `Embedding API error ${response.status}: ${text}`
101
+ if (RETRYABLE_STATUS.has(response.status)) {
102
+ lastError = new Error(message)
103
+ continue
104
+ }
105
+ throw new Error(message)
106
+ }
107
+ throw lastError
108
+ }
109
+
110
+ function normalize(vec) {
111
+ let sum = 0
112
+ for (let i = 0; i < vec.length; i++) sum += vec[i] * vec[i]
113
+ const norm = Math.sqrt(sum) || 1
114
+ for (let i = 0; i < vec.length; i++) vec[i] /= norm
115
+ return vec
116
+ }
117
+
118
+ function sleep(ms) {
119
+ return new Promise((resolve) => setTimeout(resolve, ms))
120
+ }
package/escape.mjs ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * escape.mjs — 中和 OpenAI 兼容服务端在 message content 内做的非标二次转义解析 + 孤立代理净化。
3
+ *
4
+ * 两个独立毒源(均真机实证):
5
+ *
6
+ * ① 字面 hex 转义二次解析(2026-08-06 Kimi 首观察):Kimi/deepseek 等网关会把 content 里的字面
7
+ * "\x5Cx" / "\x5Cu" 当作 hex escape 再解释一遍,不足位时 400("unexpected end of hex escape")。
8
+ * 对策:不足位序列前 double 反斜杠(\\x5CxNN 形态还原为字面量);合法完整序列放行。
9
+ * Known limitation(2026-09-01 v3 修复):反斜杠 run ≥3 时(如 "\\\x5Cu" 三反斜杠+u)v1 的
10
+ * lookbehind 只看前 1 字符会整体放行,但二次解析按配对消费后尾部的 \x5Cu 仍裸露 → 炸。
11
+ * 修复:按 run 奇偶判断——run 为奇数时尾部的 \x5Cu/\x5Cx 裸露(需 double),偶数已配对(放行)。
12
+ *
13
+ * ② 孤立 UTF-16 代理对(2026-09-02 deepseek 真机实锤):content 里的**真实孤立代理字符**
14
+ * (高代理 U+D800-DBFF 无低代理跟随,或低代理 U+DC00-DFFF 无高代理前置)——JSON.stringify
15
+ * 输出 \ud83d(合法 JSON),但 deepseek 解析器严格 UTF-16 解码,孤立代理 → 400
16
+ * ("unexpected end of hex escape" / "lone leading surrogate in hex escape")。
17
+ * 来源实证:doc_search 结果预览 `slice(0, N)` 按 UTF-16 码元截断,emoji 🔴(代理对)恰在
18
+ * 截断边界被切成孤立高代理 → 注入 system reminder → 每轮发送 → deepseek 400。
19
+ * 对策:发送前把孤立代理替换为 U+FFFD(任何来源安全兜底);源头截断点另修 UTF-16 安全切。
20
+ */
21
+
22
+ /** 中和非法字面 hex 转义序列(毒源①)。 */
23
+ export function escapeLiteralEscapes(text) {
24
+ text = String(text ?? "")
25
+ let out = ""
26
+ let i = 0
27
+ const n = text.length
28
+ while (i < n) {
29
+ const ch = text[i]
30
+ if (ch !== "\\") { out += ch; i++; continue }
31
+ // 数反斜杠 run 长度
32
+ let run = 0
33
+ while (i + run < n && text[i + run] === "\\") run++
34
+ const next = text[i + run]
35
+ if ((next === "x" || next === "u") && run % 2 === 1) {
36
+ // run 奇数 → 二次解析配对消费后尾部 \x5Cx/\x5Cu 裸露——hex 不足则网关炸 → 前插反斜杠 double
37
+ const need = next === "u" ? 4 : 2
38
+ const after = text.slice(i + run + 1, i + run + 1 + need)
39
+ if (!new RegExp(`^[0-9a-fA-F]{${need}}$`).test(after)) {
40
+ out += "\\".repeat(run + 1) + next
41
+ i += run + 1
42
+ continue
43
+ }
44
+ // 合法完整:输出全序列并跳过(hex 尾不重新扫描)
45
+ out += text.slice(i, i + run + 1 + need)
46
+ i += run + 1 + need
47
+ continue
48
+ }
49
+ out += "\\".repeat(run)
50
+ i += run
51
+ }
52
+ return out
53
+ }
54
+
55
+ /** 净化孤立 UTF-16 代理对(毒源②):高代理无低代理跟随 / 低代理无高代理前置 → 替换为 。 */
56
+ export function sanitizeLoneSurrogates(text) {
57
+ text = String(text ?? "")
58
+ let out = ""
59
+ let i = 0
60
+ const n = text.length
61
+ while (i < n) {
62
+ const cp = text.charCodeAt(i)
63
+ if (cp >= 0xd800 && cp <= 0xdbff) {
64
+ const next = text.charCodeAt(i + 1)
65
+ if (next >= 0xdc00 && next <= 0xdfff) { out += text[i] + text[i + 1]; i += 2; continue }
66
+ out += ""; i++; continue // 孤立高代理
67
+ }
68
+ if (cp >= 0xdc00 && cp <= 0xdfff) { out += ""; i++; continue } // 孤立低代理
69
+ out += text[i]; i++
70
+ }
71
+ return out
72
+ }
73
+
74
+ /** 发送前文本净化总入口:hex 转义中和 + 孤立代理净化。 */
75
+ export function sanitizeText(text) {
76
+ return sanitizeLoneSurrogates(escapeLiteralEscapes(text))
77
+ }
78
+
79
+ /** 对单条消息的 content 应用 sanitizeText(支持字符串或 OpenAI 多模态 part 数组)。
80
+ * 2026-08-31 会诊 F5:deepseek-v4-flash 网关对 tool_calls[].function.arguments 与
81
+ * reasoning_content 做同样的非标二次转义解析(字面 \\x5Cx/\\x5Cu 经工具参数/思考回传 → 400,
82
+ * 列号确定性复现 = 毒序列在 content 之外)——这两个字符串字段同样需要中和。 */
83
+ export function escapeMessageContent(message) {
84
+ const content = message?.content
85
+ let changed = false
86
+ let next = message
87
+ if (typeof content === "string") {
88
+ const escaped = sanitizeText(content)
89
+ if (escaped !== content) {
90
+ next = { ...next, content: escaped }
91
+ changed = true
92
+ }
93
+ } else if (Array.isArray(content)) {
94
+ const parts = content.map((p) => {
95
+ if (p && typeof p === "object" && p.type === "text" && typeof p.text === "string") {
96
+ const escaped = sanitizeText(p.text)
97
+ if (escaped !== p.text) {
98
+ changed = true
99
+ return { ...p, text: escaped }
100
+ }
101
+ }
102
+ return p
103
+ })
104
+ if (changed) next = { ...next, content: parts }
105
+ }
106
+ if (Array.isArray(next.tool_calls)) {
107
+ let tcChanged = false
108
+ const tool_calls = next.tool_calls.map((tc) => {
109
+ const args = tc?.function?.arguments
110
+ if (typeof args === "string") {
111
+ const escaped = sanitizeText(args)
112
+ if (escaped !== args) {
113
+ tcChanged = true
114
+ return { ...tc, function: { ...tc.function, arguments: escaped } }
115
+ }
116
+ }
117
+ return tc
118
+ })
119
+ if (tcChanged) {
120
+ next = { ...next, tool_calls }
121
+ changed = true
122
+ }
123
+ }
124
+ if (typeof next.reasoning_content === "string") {
125
+ const escaped = sanitizeText(next.reasoning_content)
126
+ if (escaped !== next.reasoning_content) {
127
+ next = { ...next, reasoning_content: escaped }
128
+ changed = true
129
+ }
130
+ }
131
+ return changed ? next : message
132
+ }
133
+
134
+ /** IKBGX4 + SESSION.md §6.9:剥离仅本地使用的整消息标记字段(transient/ts)——发送给 provider 前移除。
135
+ * 严格 OpenAI 兼容服务端(opencode/LiteLLM 等)会拒绝消息级未知 key
136
+ * ("Extra inputs are not permitted, field: 'messages[i].transient'");ts 同理
137
+ * (消息时间戳是本地取证字段,不进任何 provider 请求——T-S3)。copy-on-write:
138
+ * 历史里的原对象不动(read_history 仍能读到 ts)。 */
139
+ export function stripLocalMessageFields(messages) {
140
+ return messages.map((m) => {
141
+ if (m && typeof m === "object" && ("transient" in m || "ts" in m)) {
142
+ const { transient, ts, ...rest } = m
143
+ return rest
144
+ }
145
+ return m
146
+ })
147
+ }
148
+
149
+ /** 对整个 messages 数组逐条应用 escapeMessageContent(先剥离本地字段,再转义)。 */
150
+ export function escapeMessages(messages) {
151
+ return stripLocalMessageFields(messages).map(escapeMessageContent)
152
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * expand-home.mjs — 配置路径字段家目录展开器(第 29 批 HOME-EXPANSION,2026-09-11)。
3
+ * `loadConfig()` 单一规范化点(设计 MEMORY.md §9.3a):纯函数、零依赖、home 可注入。
4
+ */
5
+
6
+ import { homedir } from "node:os"
7
+ import { join } from "node:path"
8
+
9
+ /** 展开配置路径字段的前缀 `~`(`~` / `~/` / `~\`)为主目录绝对路径;不识别形态原样返回。
10
+ * home 第二参 = 测试注入缝(生产缺省 homedir())。 */
11
+ export function expandHome(p, home = homedir()) {
12
+ if (typeof p !== "string" || !p.startsWith("~")) return p // 非字符串 / 非 ~ 前缀 → 原样
13
+ if (p === "~") return home // 裸 ~ = 主目录
14
+ if (p[1] !== "/" && p[1] !== "\\") return p // ~user 等非分隔符 → 原样(不猜用户)
15
+ return join(home, p.slice(2).replaceAll("\\", "/")) // 余段分隔符归一(跨端统一)
16
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * explore-distill.mjs — End-of-run exploration distillation (AGENT-LOOP §13 +
3
+ * CONTEXT-COMPACTION §6.9 H1, 2026-08-23). 2026-09-05 module-split: moved verbatim out of
4
+ * context.mjs (524 > 500 hard limit). The main agent's machine line is flooded by inline
5
+ * step-by-step exploration (read/grep/...). At run end we distill THIS run's exploration
6
+ * tool-results into one semantic summary note that replaces them in the machine line,
7
+ * while agent._fullHistory (the human line) stays untouched. Call form = session continuation
8
+ * (prefix reuse · §6.15): system + `history[0, lastBlockEnd)` verbatim messages + one tail
9
+ * instruction — built by the single-source constructor compress-form.mjs#buildCompressMessages
10
+ * (no second builder · D2); the declaration face (systemPrompt / tools) rides `extras` from the
11
+ * call point, same source as the turn request. VS Code adapter = thincoder-vscode/src/explore-distill.mjs.
12
+ */
13
+
14
+ import { chat } from "./provider/index.mjs"
15
+ import { buildCompressMessages } from "./compress-form.mjs"
16
+
17
+ /** Read-only knowledge tools counted as "exploration" (execute writes files → never exploration). */
18
+ export const EXPLORE_TOOLS = new Set([
19
+ "read", "grep", "glob", "ls", "code_search", "doc_search", "repo_outline",
20
+ ])
21
+
22
+ /** Summary prompt for turning a burst of exploration results into a semantic summary. */
23
+ export const EXPLORE_SUMMARY_PROMPT = `You are distilling exploration tool results. Summarize the following read-only codebase exploration into a compact semantic summary for the main agent's own context.
24
+
25
+ Requirements:
26
+ - Capture WHAT was discovered, WHERE (which files / directories / symbols), and the KEY CONCLUSIONS — do not list tool calls mechanically
27
+ - Keep actionable facts the main agent needs to continue: code locations, function names, file paths, structure, and open questions the exploration raised
28
+ - Drop raw tool-output noise, repeated lines, and verbatim file dumps — keep only what must be remembered
29
+ - Be honest: mark anything not actually verified as "unverified"; do not present guesses as facts
30
+ - Use bullet points; aim for information completeness, not a hard word limit
31
+
32
+ Exploration log:
33
+ `
34
+
35
+ /** tool_calls name across both stored shapes ({function:{name}} and flat {name}). */
36
+ function toolCallName(tc) {
37
+ return tc?.function?.name ?? tc?.name ?? ""
38
+ }
39
+
40
+ /** Tool that produced a tool-result message (falls back to its owner assistant's tool_call). */
41
+ function toolResultName(msg, ownerToolCalls) {
42
+ if (typeof msg?.name === "string" && msg.name) return msg.name
43
+ const owner = (ownerToolCalls ?? []).find((tc) => tc.id === msg?.tool_call_id)
44
+ return owner ? toolCallName(owner) : ""
45
+ }
46
+
47
+ /**
48
+ * Find the pure-exploration "assistant(tool_calls)→tool…" pair blocks added since `start`.
49
+ * A block is explorable only when EVERY tool call AND every tool result in it is an exploration
50
+ * tool — mixed blocks (read + edit in one turn) stay untouched, or we'd orphan the edit pairing.
51
+ */
52
+ function findExplorationBlocks(history, start) {
53
+ const blocks = []
54
+ let i = start
55
+ while (i < history.length) {
56
+ const m = history[i]
57
+ if (m?.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
58
+ let j = i + 1
59
+ while (j < history.length && history[j]?.role === "tool") j++
60
+ const toolMsgs = history.slice(i + 1, j)
61
+ const allCallsExplore = m.tool_calls.every((tc) => EXPLORE_TOOLS.has(toolCallName(tc)))
62
+ const allResultsExplore = toolMsgs.length > 0 && toolMsgs.every((t) => EXPLORE_TOOLS.has(toolResultName(t, m.tool_calls)))
63
+ if (allCallsExplore && allResultsExplore) {
64
+ blocks.push({ start: i, end: j, messages: history.slice(i, j), toolCount: toolMsgs.length })
65
+ }
66
+ i = j
67
+ } else {
68
+ i++
69
+ }
70
+ }
71
+ return blocks
72
+ }
73
+
74
+ /**
75
+ * Core (shared) distillation: replace this run's pure-exploration pair blocks with a single
76
+ * "[Exploration summary]" note placed where the first block was. Returns a NEW history array,
77
+ * or null when there is nothing to shrink (<3 exploration results / LLM failure). Pairing-safe:
78
+ * whole assistant→tool blocks are removed, so no orphan tool_calls/tool can survive.
79
+ */
80
+ async function distillExplorations(history, start, provider, signal, agent, depth, extras) {
81
+ if (!Array.isArray(history) || history.length - start < 2) return null
82
+ const blocks = findExplorationBlocks(history, start)
83
+ const resultCount = blocks.reduce((n, b) => n + b.toolCount, 0)
84
+ if (resultCount < 3) return null
85
+
86
+ // 切点 = 本 run 末块的 `end`(与替换面同一 blocks 数组——不新增第二处切割判据)。请求前缀 =
87
+ // tools 声明 + system + `[0, lastBlockEnd)`——皆回合请求已建缓存面(§6.15);未命中面 =
88
+ // 尾部指令一条(+ ≤255 块对齐残余)。
89
+ const cut = blocks.at(-1).end
90
+
91
+ let summary
92
+ try {
93
+ // Silent by design (D11): thinking:null and no onToken/onReasoning — this internal
94
+ // distillation must not stream to the frontend. signal propagates user cancellation.
95
+ // 会话续写形态(§6.15 / D-CC21):messages 由单源构造器 buildCompressMessages 产出(不设第二构造
96
+ // 点);tools 取与回合请求同一声明面(**不带** tool_choice——实测该参数使服务端丢弃 tools 区);
97
+ // 不覆盖 reasoningEffort ⇒ 与回合侧同源(v3 形态)。extras 缺省 ⇒ 退化面 1(无 system / 无 tools)。
98
+ const resp = await chat({ ...provider, thinking: null }, {
99
+ messages: buildCompressMessages(history, cut, extras?.systemPrompt, EXPLORE_SUMMARY_PROMPT),
100
+ tools: extras?.tools,
101
+ signal,
102
+ // §18.6 D-TR4:轨迹元数据增补——kind=distill(探索蒸馏面——agent 元数据透出;
103
+ // depth 经 summarizeRunExplorations 参数透传——agent.mjs 主作用域传入)
104
+ logCtx: {
105
+ stage: "distill", child: agent?._logId ?? null, kind: "distill",
106
+ role: agent?._role ?? null, depth: depth ?? null,
107
+ session: agent?._sessionStart ?? null, cwd: agent?.cwd ?? process.cwd(),
108
+ traces: agent?.config?.traces?.enabled !== false,
109
+ },
110
+ })
111
+ summary = resp?.content
112
+ } catch {
113
+ return null // N3: never block the run's return or lose history — original results stay
114
+ }
115
+ if (!summary) return null
116
+
117
+ const drop = new Set()
118
+ for (const b of blocks) for (let k = b.start; k < b.end; k++) drop.add(k)
119
+ const note = { role: "user", content: "[Exploration summary]\n" + summary }
120
+ const next = []
121
+ let inserted = false
122
+ for (let k = 0; k < history.length; k++) {
123
+ if (drop.has(k)) {
124
+ if (!inserted) { next.push(note); inserted = true }
125
+ continue
126
+ }
127
+ next.push(history[k])
128
+ }
129
+ return next
130
+ }
131
+
132
+ /**
133
+ * End-of-run exploration distillation (runAgent's final return). Shrinks the MACHINE line
134
+ * (agent.history) only; agent._fullHistory is never touched. Triggers when this run added ≥3
135
+ * exploration tool results; on LLM failure it silently keeps the original history (N3).
136
+ * The distillation itself is silent and never streams (D11); `callbacks.onDistilled` fires
137
+ * ONLY after the replacement actually lands (never on no-op/failure) — callers persist the
138
+ * compressed session (SEND-STALL-DISTILL §2.3).
139
+ * `extras` = 前缀面({ systemPrompt, tools }——调用点透传、与回合请求同源 · §6.15);缺省 ⇒ 退化面 1。
140
+ */
141
+ export async function summarizeRunExplorations(agent, callbacks, signal, depth = 0, extras) {
142
+ const next = await distillExplorations(agent.history, agent._runStartHistoryLen ?? 0, agent.provider, signal, agent, depth, extras)
143
+ if (!next) return
144
+ agent.history = next
145
+ // The machine line changed shape — the measured token baseline was for the pre-shrink context.
146
+ // Invalidate so the next compaction check re-estimates instead of over-counting stale history.
147
+ agent._lastPromptTokens = null
148
+ agent._usageAtLen = null
149
+ // The compressed machine line must reach the disk: the run's own save already happened,
150
+ // so without this hook the async distill would leave the session un-compressed on exit.
151
+ callbacks.onDistilled?.()
152
+ }