thincoder 0.12.59 → 0.12.61
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/CHANGELOG.md +62 -4
- package/README.md +10 -8
- package/bin/thincoder.mjs +99 -133
- package/package.json +6 -4
- package/src/abort-provenance.mjs +116 -0
- package/src/acp/bridge.mjs +45 -21
- package/src/acp.mjs +6 -1
- package/src/advisor/citations.mjs +83 -21
- package/src/advisor/compaction.mjs +174 -0
- package/src/advisor/loop.mjs +293 -0
- package/src/advisor/messages.mjs +59 -137
- package/src/advisor/project-context.mjs +194 -0
- package/src/advisor/repos.mjs +17 -40
- package/src/advisor/run.mjs +156 -359
- package/src/advisor/truncate.mjs +57 -0
- package/src/advisor.mjs +27 -7
- package/src/agent/completion.mjs +17 -11
- package/src/agent/dispatch.mjs +145 -27
- package/src/agent/helpers.mjs +107 -13
- package/src/agent/record-results.mjs +55 -11
- package/src/agent/relay-prefix.mjs +39 -0
- package/src/agent/run-stages.mjs +242 -0
- package/src/agent/setup-reminders.mjs +69 -0
- package/src/agent/setup.mjs +107 -127
- package/src/agent/spawn-child.mjs +55 -13
- package/src/agent-tools/advisor-async.mjs +346 -0
- package/src/agent-tools/advisor-settle.mjs +231 -0
- package/src/agent-tools/advisor.mjs +167 -116
- package/src/agent-tools/async-settle.mjs +191 -0
- package/src/agent-tools/batch-segment.mjs +195 -0
- package/src/agent-tools/consult.mjs +139 -107
- package/src/agent-tools/design-token.mjs +117 -0
- package/src/agent-tools/digest-budget.mjs +76 -0
- package/src/agent-tools/eng.mjs +24 -29
- package/src/agent-tools/escalate-async.mjs +289 -0
- package/src/agent-tools/read-history.mjs +166 -32
- package/src/agent-tools/recent-changes.mjs +2 -1
- package/src/agent-tools/review-streak.mjs +93 -0
- package/src/agent-tools/settings.mjs +137 -34
- package/src/agent-tools/subagent-actions.mjs +180 -133
- package/src/agent-tools/subagent-async.mjs +184 -177
- package/src/agent-tools/subagent-panel.mjs +160 -0
- package/src/agent-tools/subagent-run.mjs +205 -0
- package/src/agent-tools/subagent-scheduler.mjs +100 -27
- package/src/agent-tools/subagent-spawn.mjs +453 -0
- package/src/agent-tools/subagent.mjs +256 -578
- package/src/agent-tools/verify.mjs +119 -292
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +89 -205
- package/src/cli/distill-command.mjs +12 -6
- package/src/cli/make-agent.mjs +26 -8
- package/src/cli/memory-command.mjs +4 -3
- package/src/cli/permission.mjs +2 -2
- package/src/cli/setup-wizard.mjs +42 -17
- package/src/completions.mjs +114 -0
- package/src/config-migrate.mjs +70 -0
- package/src/config.mjs +180 -63
- package/src/context.mjs +5 -147
- package/src/conventions.mjs +223 -0
- package/src/crash-reports.mjs +128 -0
- package/src/distill.mjs +11 -11
- package/src/expand-home.mjs +16 -0
- package/src/explore-distill.mjs +155 -0
- package/src/generate-title.mjs +1 -1
- package/src/hooks.mjs +7 -3
- package/src/memory/code-index.mjs +9 -3
- package/src/memory/code-sync.mjs +72 -32
- package/src/memory/core.mjs +6 -193
- package/src/memory/delete.mjs +236 -0
- package/src/memory/docs.mjs +68 -54
- package/src/memory/file-walk.mjs +109 -0
- package/src/memory/schema.mjs +15 -3
- package/src/memory.mjs +3 -1
- package/src/model-ref.mjs +66 -0
- package/src/model-specs.mjs +42 -8
- package/src/peer-domains.mjs +265 -0
- package/src/peer-instances.mjs +231 -0
- package/src/prompt-overlays.mjs +82 -0
- package/src/prompts/advisor-design.md +18 -75
- package/src/prompts/advisor-round1.md +14 -67
- package/src/prompts/advisor-round2.md +15 -51
- package/src/prompts/advisor-round3.md +15 -51
- package/src/prompts/common.md +115 -0
- package/src/prompts/consult-base.md +5 -23
- package/src/prompts/discipline-engineering.md +217 -0
- package/src/prompts/discipline-normal.md +179 -0
- package/src/prompts/persona-coder.md +21 -0
- package/src/prompts/persona-eng-coder.md +37 -0
- package/src/prompts/persona-eng-designer.md +55 -0
- package/src/prompts/persona-engineering.md +54 -0
- package/src/prompts/persona-explore.md +15 -0
- package/src/prompts/persona-normal.md +27 -0
- package/src/prompts/persona-plan.md +26 -0
- package/src/provider/anthropic.mjs +4 -4
- package/src/provider/core.mjs +18 -98
- package/src/provider/errors.mjs +101 -0
- package/src/provider/google.mjs +5 -6
- package/src/provider/index.mjs +2 -1
- package/src/provider/list-models.mjs +93 -0
- package/src/provider/rate.mjs +2 -1
- package/src/provider/responses.mjs +5 -3
- package/src/provider/retry.mjs +8 -45
- package/src/provider/sse.mjs +3 -4
- package/src/proxy.mjs +9 -14
- package/src/session-gc.mjs +214 -0
- package/src/session-guard.mjs +47 -0
- package/src/session-rename.mjs +38 -0
- package/src/session-slots.mjs +188 -60
- package/src/session.mjs +104 -124
- package/src/token-ttl.mjs +274 -0
- package/src/tools/{system.mjs → bash.mjs} +19 -221
- package/src/tools/checklist-sync.mjs +181 -0
- package/src/tools/checklist.mjs +52 -39
- package/src/tools/edit-batch.mjs +109 -10
- package/src/tools/edit-diff.mjs +110 -27
- package/src/tools/edit.md +17 -12
- package/src/tools/execute.mjs +31 -4
- package/src/tools/file.mjs +41 -16
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +23 -34
- package/src/tools/glob-dialect.mjs +130 -0
- package/src/tools/glob.md +3 -3
- package/src/tools/grep.md +1 -1
- package/src/tools/index.mjs +9 -8
- package/src/tools/ops.mjs +188 -3
- package/src/tools/patch.mjs +3 -3
- package/src/tools/question.md +4 -0
- package/src/tools/question.mjs +26 -0
- package/src/tools/read.md +1 -2
- package/src/tools/read_image.md +1 -1
- package/src/tools/search.mjs +236 -0
- package/src/tools/shared.mjs +14 -13
- package/src/tools/wait_for.md +22 -0
- package/src/tui/agent-turn.mjs +36 -228
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/clipboard.mjs +7 -1
- package/src/tui/cmd-advisor.mjs +3 -2
- package/src/tui/cmd-config.mjs +142 -30
- package/src/tui/cmd-eng.mjs +28 -40
- package/src/tui/cmd-exit.mjs +6 -8
- package/src/tui/cmd-mcp.mjs +8 -2
- package/src/tui/cmd-model.mjs +14 -12
- package/src/tui/cmd-new.mjs +3 -2
- package/src/tui/cmd-reindex.mjs +7 -0
- package/src/tui/cmd-session.mjs +19 -4
- package/src/tui/cmd-submodel.mjs +8 -5
- package/src/tui/cmd-think.mjs +10 -10
- package/src/tui/cmd-undo.mjs +4 -3
- package/src/tui/cmd-upgrade.mjs +19 -4
- package/src/tui/config-helpers.mjs +28 -16
- package/src/tui/distill-cmd.mjs +1 -1
- package/src/tui/index.mjs +40 -38
- package/src/tui/interaction.mjs +3 -3
- package/src/tui/key-handler.mjs +61 -17
- package/src/tui/key-modes.mjs +86 -8
- package/src/tui/layout.mjs +18 -10
- package/src/tui/model-catalog.mjs +89 -0
- package/src/tui/model-picker.mjs +498 -0
- package/src/tui/mouse.mjs +52 -9
- package/src/tui/pickers.mjs +28 -392
- package/src/tui/render-frame.mjs +32 -16
- package/src/tui/render-loop.mjs +2 -0
- package/src/tui/render-segments.mjs +12 -9
- package/src/tui/render.mjs +37 -5
- package/src/tui/slash-commands.mjs +2 -2
- package/src/tui/startup.mjs +4 -0
- package/src/tui/subagent-blocks.mjs +106 -295
- package/src/tui/subagent-children.mjs +162 -0
- package/src/tui/subagent-freeze.mjs +169 -0
- package/src/tui/subagent-panel.mjs +24 -31
- package/src/tui/suspension-drive.mjs +297 -0
- package/src/tui/tool-args.mjs +7 -5
- package/src/tui/tool-display.mjs +143 -0
- package/src/tui/tool-events.mjs +56 -185
- package/src/tui/tui-lifecycle.mjs +46 -4
- package/src/tui/update-notice.mjs +4 -0
- package/src/tui/wizard.mjs +61 -21
- package/src/tui/wrapped-spawn.mjs +38 -0
- package/src/prompts/coder.md +0 -56
- package/src/prompts/discipline.md +0 -102
- package/src/prompts/eng-coder.md +0 -44
- package/src/prompts/engineering-sub.md +0 -34
- package/src/prompts/engineering.md +0 -340
- package/src/prompts/explore.md +0 -21
- package/src/prompts/main.md +0 -56
- package/src/prompts/methodology-template.md +0 -58
- package/src/prompts/plan.md +0 -16
- package/src/prompts/system.md +0 -63
- package/src/tools/pdf-parse-text.mjs +0 -497
- package/src/tools/pdf-parse-xref.mjs +0 -499
- package/src/tools/pdf.mjs +0 -155
- package/src/tools/read_pdf.md +0 -21
package/src/context.mjs
CHANGED
|
@@ -287,7 +287,7 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
|
|
|
287
287
|
// Silent by design (D11): no onToken/onReasoning — the compaction process must not stream to the frontend.
|
|
288
288
|
// signal propagates user cancellation (Ctrl+C) to the in-flight summary call.
|
|
289
289
|
// Compression visibility (CONTEXT-COMPACTION.md §7 D-C1/D-C2): the frontend learns the compression
|
|
290
|
-
// STARTED right before the LLM call ("Compressing context… / summarizing N messages" panel) — only
|
|
290
|
+
// STARTED right before the summary LLM call ("Compressing context… / summarizing N messages" panel) — only
|
|
291
291
|
// the lifecycle is surfaced, never the summary body. N = the number of history messages being summarized.
|
|
292
292
|
callbacks?.onCompressStart?.({ messages: middle.length })
|
|
293
293
|
const startedAt = performance.now()
|
|
@@ -374,150 +374,8 @@ function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
|
|
|
374
374
|
return shrunk
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
-
// ─── End-of-run exploration distillation
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
// replaces them in the machine line, while agent._fullHistory (the human line) stays untouched.
|
|
377
|
+
// ─── End-of-run exploration distillation(2026-09-05 module-split:524 > 500 硬限——verbatim
|
|
378
|
+
// 迁至 explore-distill.mjs,语义零变——VS Code compact.mjs 同款联动;cross-repo parity 锚改指
|
|
379
|
+
// explore-distill.mjs——消费方 import 面不变(re-export))───────────────────────
|
|
381
380
|
|
|
382
|
-
|
|
383
|
-
export const EXPLORE_TOOLS = new Set([
|
|
384
|
-
"read", "grep", "glob", "ls", "code_search", "doc_search", "repo_outline",
|
|
385
|
-
])
|
|
386
|
-
|
|
387
|
-
/** Summary prompt for turning a burst of exploration results into a semantic summary. */
|
|
388
|
-
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.
|
|
389
|
-
|
|
390
|
-
Requirements:
|
|
391
|
-
- Capture WHAT was discovered, WHERE (which files / directories / symbols), and the KEY CONCLUSIONS — do not list tool calls mechanically
|
|
392
|
-
- Keep actionable facts the main agent needs to continue: code locations, function names, file paths, structure, and open questions the exploration raised
|
|
393
|
-
- Drop raw tool-output noise, repeated lines, and verbatim file dumps — keep only what must be remembered
|
|
394
|
-
- Be honest: mark anything not actually verified as "unverified"; do not present guesses as facts
|
|
395
|
-
- Use bullet points; aim for information completeness, not a hard word limit
|
|
396
|
-
|
|
397
|
-
Exploration log:
|
|
398
|
-
`
|
|
399
|
-
|
|
400
|
-
/** tool_calls name across both stored shapes ({function:{name}} and flat {name}). */
|
|
401
|
-
function toolCallName(tc) {
|
|
402
|
-
return tc?.function?.name ?? tc?.name ?? ""
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/** Tool that produced a tool-result message (falls back to its owner assistant's tool_call). */
|
|
406
|
-
function toolResultName(msg, ownerToolCalls) {
|
|
407
|
-
if (typeof msg?.name === "string" && msg.name) return msg.name
|
|
408
|
-
const owner = (ownerToolCalls ?? []).find((tc) => tc.id === msg?.tool_call_id)
|
|
409
|
-
return owner ? toolCallName(owner) : ""
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Find the pure-exploration "assistant(tool_calls)→tool…" pair blocks added since `start`.
|
|
414
|
-
* A block is explorable only when EVERY tool call AND every tool result in it is an exploration
|
|
415
|
-
* tool — mixed blocks (read + edit in one turn) stay untouched, or we'd orphan the edit pairing.
|
|
416
|
-
*/
|
|
417
|
-
function findExplorationBlocks(history, start) {
|
|
418
|
-
const blocks = []
|
|
419
|
-
let i = start
|
|
420
|
-
while (i < history.length) {
|
|
421
|
-
const m = history[i]
|
|
422
|
-
if (m?.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
|
|
423
|
-
let j = i + 1
|
|
424
|
-
while (j < history.length && history[j]?.role === "tool") j++
|
|
425
|
-
const toolMsgs = history.slice(i + 1, j)
|
|
426
|
-
const allCallsExplore = m.tool_calls.every((tc) => EXPLORE_TOOLS.has(toolCallName(tc)))
|
|
427
|
-
const allResultsExplore = toolMsgs.length > 0 && toolMsgs.every((t) => EXPLORE_TOOLS.has(toolResultName(t, m.tool_calls)))
|
|
428
|
-
if (allCallsExplore && allResultsExplore) {
|
|
429
|
-
blocks.push({ start: i, end: j, messages: history.slice(i, j), toolCount: toolMsgs.length })
|
|
430
|
-
}
|
|
431
|
-
i = j
|
|
432
|
-
} else {
|
|
433
|
-
i++
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
return blocks
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/** Serialize a batch of exploration messages for the summary LLM (same shape as compaction serialization). */
|
|
440
|
-
function serializeExplorationMessages(messages) {
|
|
441
|
-
const cap = 8000 // exploration results ARE the signal to distill — generous cap (quality-first, N1)
|
|
442
|
-
return messages
|
|
443
|
-
.map((m) => {
|
|
444
|
-
const toolNote = m.tool_calls ? ` [called tools: ${m.tool_calls.map(toolCallName).join(", ")}]` : ""
|
|
445
|
-
let text = ""
|
|
446
|
-
if (typeof m.content === "string") text = m.content
|
|
447
|
-
else if (Array.isArray(m.content)) text = m.content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join(" ")
|
|
448
|
-
return `[${m.role}]${toolNote} ${text.slice(0, cap)}`
|
|
449
|
-
})
|
|
450
|
-
.join("\n")
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* Core (shared) distillation: replace this run's pure-exploration pair blocks with a single
|
|
455
|
-
* "[Exploration summary]" note placed where the first block was. Returns a NEW history array,
|
|
456
|
-
* or null when there is nothing to shrink (<3 exploration results / LLM failure). Pairing-safe:
|
|
457
|
-
* whole assistant→tool blocks are removed, so no orphan tool_calls/tool can survive.
|
|
458
|
-
*/
|
|
459
|
-
async function distillExplorations(history, start, provider, signal, agent, depth) {
|
|
460
|
-
if (!Array.isArray(history) || history.length - start < 2) return null
|
|
461
|
-
const blocks = findExplorationBlocks(history, start)
|
|
462
|
-
const resultCount = blocks.reduce((n, b) => n + b.toolCount, 0)
|
|
463
|
-
if (resultCount < 3) return null
|
|
464
|
-
|
|
465
|
-
const serialized = blocks.map((b) => serializeExplorationMessages(b.messages)).join("\n")
|
|
466
|
-
|
|
467
|
-
let summary
|
|
468
|
-
try {
|
|
469
|
-
// Silent by design (D11): thinking:null and no onToken/onReasoning — this internal
|
|
470
|
-
// distillation must not stream to the frontend. signal propagates user cancellation.
|
|
471
|
-
const resp = await chat({ ...provider, thinking: null, reasoningEffort: null }, {
|
|
472
|
-
messages: [{ role: "user", content: EXPLORE_SUMMARY_PROMPT + serialized }],
|
|
473
|
-
signal,
|
|
474
|
-
// §18.6 D-TR4:轨迹元数据增补——kind=distill(探索蒸馏面——agent 元数据透出;
|
|
475
|
-
// depth 经 summarizeRunExplorations 参数透传——agent.mjs 主作用域传入)
|
|
476
|
-
logCtx: {
|
|
477
|
-
stage: "distill", child: agent?._logId ?? null, kind: "distill",
|
|
478
|
-
role: agent?._role ?? null, depth: depth ?? null,
|
|
479
|
-
session: agent?._sessionStart ?? null, cwd: agent?.cwd ?? process.cwd(),
|
|
480
|
-
traces: agent?.config?.traces?.enabled !== false,
|
|
481
|
-
},
|
|
482
|
-
})
|
|
483
|
-
summary = resp?.content
|
|
484
|
-
} catch {
|
|
485
|
-
return null // N3: never block the run's return or lose history — original results stay
|
|
486
|
-
}
|
|
487
|
-
if (!summary) return null
|
|
488
|
-
|
|
489
|
-
const drop = new Set()
|
|
490
|
-
for (const b of blocks) for (let k = b.start; k < b.end; k++) drop.add(k)
|
|
491
|
-
const note = { role: "user", content: "[Exploration summary]\n" + summary }
|
|
492
|
-
const next = []
|
|
493
|
-
let inserted = false
|
|
494
|
-
for (let k = 0; k < history.length; k++) {
|
|
495
|
-
if (drop.has(k)) {
|
|
496
|
-
if (!inserted) { next.push(note); inserted = true }
|
|
497
|
-
continue
|
|
498
|
-
}
|
|
499
|
-
next.push(history[k])
|
|
500
|
-
}
|
|
501
|
-
return next
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* End-of-run exploration distillation (runAgent's final return). Shrinks the MACHINE line
|
|
506
|
-
* (agent.history) only; agent._fullHistory is never touched. Triggers when this run added ≥3
|
|
507
|
-
* exploration tool results; on LLM failure it silently keeps the original history (N3).
|
|
508
|
-
* The distillation itself is silent and never streams (D11); `callbacks.onDistilled` fires
|
|
509
|
-
* ONLY after the replacement actually lands (never on no-op/failure) — callers persist the
|
|
510
|
-
* compressed session (SEND-STALL-DISTILL §2.3).
|
|
511
|
-
*/
|
|
512
|
-
export async function summarizeRunExplorations(agent, callbacks, signal, depth = 0) {
|
|
513
|
-
const next = await distillExplorations(agent.history, agent._runStartHistoryLen ?? 0, agent.provider, signal, agent, depth)
|
|
514
|
-
if (!next) return
|
|
515
|
-
agent.history = next
|
|
516
|
-
// The machine line changed shape — the measured token baseline was for the pre-shrink context.
|
|
517
|
-
// Invalidate so the next compaction check re-estimates instead of over-counting stale history.
|
|
518
|
-
agent._lastPromptTokens = null
|
|
519
|
-
agent._usageAtLen = null
|
|
520
|
-
// The compressed machine line must reach the disk: the run's own save already happened,
|
|
521
|
-
// so without this hook the async distill would leave the session un-compressed on exit.
|
|
522
|
-
callbacks.onDistilled?.()
|
|
523
|
-
}
|
|
381
|
+
export { summarizeRunExplorations, EXPLORE_TOOLS, EXPLORE_SUMMARY_PROMPT } from "./explore-distill.mjs"
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crash-reports.mjs — R25(docs/design/ARCHITECTURE.md §R25——F-R25a/b/c)CLI 异常终止
|
|
3
|
+
* 捕获与留痕机制。
|
|
4
|
+
*
|
|
5
|
+
* 三类能力(与设计逐项对应):
|
|
6
|
+
* - prepareCrashReporting()(F-R25b):入口最前调用——mkdir 预建 ~/.thincoder/crash-reports/
|
|
7
|
+
* + process.report 代码内启用(reportOnFatalError + directory)——V8 OOM/原生 fatal
|
|
8
|
+
* 自动写 report.*.json。实现批实测(2026-09-07):目录缺失时 Node 对 fatal 静默不写
|
|
9
|
+
* 报告——预建是必要动作而非"零成本保险"。
|
|
10
|
+
* - writeCrashRecord()(F-R25a):JS 异常钩子同步落盘 crash-{ts}-{pid}.json(ts = epoch ms
|
|
11
|
+
* UTC + pid——跨进程同 ms 防覆盖——复审 #3)——权限 0600(config.json 先例)。
|
|
12
|
+
* - recentCrashHint()(F-R25c):启动扫描 24h 窗内记录(两类文件模式定死——评审 #8:
|
|
13
|
+
* crash-*.json 自写 + report.*.json Node fatal——mtime 判定)→ 返回提示文本或 null
|
|
14
|
+
* (无匹配不提示——负例)。
|
|
15
|
+
*
|
|
16
|
+
* 保留策略(评审 #6 + 复审 #4):>30 天淘汰——写时自清理(tool-results 先例『落盘目录
|
|
17
|
+
* 写时自清理』段同动机——Windows 磁盘清理不覆盖 ~/.thincoder)——清理搭车点 = F-R25a 写 +
|
|
18
|
+
* F-R25b 入口 mkdir + F-R25c 启动扫描(纯 fatal 序列 JS 不运行——下个进程入口 mkdir 时
|
|
19
|
+
* 清理)。
|
|
20
|
+
*
|
|
21
|
+
* 纪律:全部同步 API(崩溃路径无异步);调用方按执行序列各步独立 try/catch(复审 #2——
|
|
22
|
+
* 写失败/恢复失败不阻断后续步——exit 非 0 恒达)。
|
|
23
|
+
*/
|
|
24
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
25
|
+
import { join } from "node:path"
|
|
26
|
+
import { configDir } from "./config.mjs"
|
|
27
|
+
|
|
28
|
+
/** crash-reports 运行时目录(~/.thincoder/crash-reports——非仓内——写时自清理)。 */
|
|
29
|
+
export function crashReportsDir() {
|
|
30
|
+
return join(configDir, "crash-reports")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 记录文件模式(评审 #8 定死):crash-*.json = 自写(F-R25a);report.*.json = Node
|
|
34
|
+
* fatal(默认命名 report.YYYYMMDD.HHMMSS.<pid>.<seq>.json——2026-09-07 实现批实测)。
|
|
35
|
+
* purge 另含 tui-stderr-*.log(TUI-STDERR-CAPTURE F-2——30 天同族淘汰)——但 recentCrashHint
|
|
36
|
+
* 不计该类:tui-stderr 每次 TUI 启动都生成(正常退出也留档)——计入即正常会话误报"异常终止"。 */
|
|
37
|
+
function isCrashRecordName(name) {
|
|
38
|
+
return /^crash-.+\.json$/.test(name) || /^report\..+\.json$/.test(name)
|
|
39
|
+
}
|
|
40
|
+
function isPurgeRecordName(name) {
|
|
41
|
+
return isCrashRecordName(name) || /^tui-stderr-.+\.log$/.test(name)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** >30 天淘汰(评审 #6)——写时自清理;搭车点 = 写 / 入口 mkdir / 启动扫描(复审 #4)。 */
|
|
45
|
+
function purgeOldCrashReports(dir) {
|
|
46
|
+
const cutoff = Date.now() - 30 * 24 * 3_600_000
|
|
47
|
+
let names
|
|
48
|
+
try { names = readdirSync(dir) } catch { return } // 目录不存在/不可读 → 无事可做
|
|
49
|
+
for (const name of names) {
|
|
50
|
+
if (!isPurgeRecordName(name)) continue
|
|
51
|
+
try {
|
|
52
|
+
if (statSync(join(dir, name)).mtimeMs < cutoff) unlinkSync(join(dir, name))
|
|
53
|
+
} catch { /* 单个文件失败不影响其余 */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* F-R25b:入口最前调用(一切重活前——缩编程期窗口)——预建目录 + process.report 启用。
|
|
59
|
+
* 任何失败不阻断启动(尽力面——record 路径自带降级)。返回目录路径。
|
|
60
|
+
*/
|
|
61
|
+
export function prepareCrashReporting() {
|
|
62
|
+
const dir = crashReportsDir()
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(dir, { recursive: true })
|
|
65
|
+
// 代码内启用:shebang 入口无法携带启动参数(env 单参数限制 + execArgv 仅子进程——评审 #1)
|
|
66
|
+
process.report.directory = dir
|
|
67
|
+
process.report.reportOnFatalError = true
|
|
68
|
+
} catch { /* mkdir/启用失败不阻断启动——尽力面 */ }
|
|
69
|
+
purgeOldCrashReports(dir) // F-R25b 入口 mkdir 搭车清理(复审 #4——纯 fatal 序列也触发)
|
|
70
|
+
return dir
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* F-R25a:JS 异常钩子同步写诊断记录(内容:时间/类型/错误消息+堆栈/uptime/argv/cwd/
|
|
75
|
+
* 内存/node+版本——设计字段集)。
|
|
76
|
+
* @param {string} type uncaughtException | unhandledRejection
|
|
77
|
+
* @param {*} error 原始 error(unhandledRejection 可 reject 任意值——非 Error 兜底 String)
|
|
78
|
+
* @returns {string|null} 记录完整路径;写失败 → null(不抛——调用方步隔离——复审 #2)
|
|
79
|
+
*/
|
|
80
|
+
export function writeCrashRecord({ type, error }) {
|
|
81
|
+
const dir = crashReportsDir()
|
|
82
|
+
try {
|
|
83
|
+
const record = {
|
|
84
|
+
time: new Date().toISOString(),
|
|
85
|
+
type,
|
|
86
|
+
message: error?.message ?? String(error),
|
|
87
|
+
stack: typeof error?.stack === "string" ? error.stack : null,
|
|
88
|
+
uptime: process.uptime(),
|
|
89
|
+
argv: process.argv,
|
|
90
|
+
cwd: process.cwd(),
|
|
91
|
+
memoryUsage: process.memoryUsage(),
|
|
92
|
+
node: process.version,
|
|
93
|
+
}
|
|
94
|
+
// ts = epoch ms UTC + pid——跨进程同 ms 防覆盖(复审 #3)
|
|
95
|
+
const file = join(dir, `crash-${Date.now()}-${process.pid}.json`)
|
|
96
|
+
writeFileSync(file, JSON.stringify(record, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
|
|
97
|
+
try { chmodSync(file, 0o600) } catch { /* Windows chmod 尽力而为——config.json 先例 */ }
|
|
98
|
+
purgeOldCrashReports(dir) // F-R25a 写时搭车清理(评审 #6)
|
|
99
|
+
return file
|
|
100
|
+
} catch {
|
|
101
|
+
return null // 写失败不阻断后续步
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* F-R25c:启动扫描——crash-reports 24h 窗内是否有记录(两类模式——mtime 判定)。
|
|
107
|
+
* @param {object} [opts] 可选 { dir }——目录注入(测试用);默认 crashReportsDir()
|
|
108
|
+
* @returns {string|null} 提示文本(含最新记录完整路径);无匹配 → null(负例——不提示)
|
|
109
|
+
*/
|
|
110
|
+
export function recentCrashHint({ dir = crashReportsDir() } = {}) {
|
|
111
|
+
purgeOldCrashReports(dir) // F-R25c 启动扫描搭车清理(复审 #4)
|
|
112
|
+
if (!existsSync(dir)) return null
|
|
113
|
+
const cutoff = Date.now() - 24 * 3_600_000
|
|
114
|
+
let best = null // 窗内 mtime 最新记录
|
|
115
|
+
let names
|
|
116
|
+
try { names = readdirSync(dir) } catch { return null } // 目录不可读 → 静默无提示
|
|
117
|
+
for (const name of names) {
|
|
118
|
+
if (!isCrashRecordName(name)) continue
|
|
119
|
+
try {
|
|
120
|
+
const st = statSync(join(dir, name))
|
|
121
|
+
if (st.mtimeMs >= cutoff && (!best || st.mtimeMs > best.mtimeMs)) {
|
|
122
|
+
best = { path: join(dir, name), mtimeMs: st.mtimeMs }
|
|
123
|
+
}
|
|
124
|
+
} catch { /* 单文件 stat 失败跳过 */ }
|
|
125
|
+
}
|
|
126
|
+
if (!best) return null
|
|
127
|
+
return `上次运行异常终止(记录:${best.path})`
|
|
128
|
+
}
|
package/src/distill.mjs
CHANGED
|
@@ -18,7 +18,7 @@ Output a JSON array (nothing else):
|
|
|
18
18
|
"title": "Short title",
|
|
19
19
|
"content": "Full content, self-contained — understandable without session context",
|
|
20
20
|
"tags": ["tag1", "tag2"],
|
|
21
|
-
"
|
|
21
|
+
"layer": "personal | project"
|
|
22
22
|
}
|
|
23
23
|
]
|
|
24
24
|
|
|
@@ -27,7 +27,7 @@ Extraction criteria:
|
|
|
27
27
|
- decision: technical decisions made in the session and their rationale
|
|
28
28
|
- pattern: debugging experiences, solutions, reusable workflows
|
|
29
29
|
- rule: coding standards (caution! rules are usually best written manually; only extract rules explicitly established in the session)
|
|
30
|
-
-
|
|
30
|
+
- layer: use "project" for project-specific knowledge; use "personal" for general or personal preferences
|
|
31
31
|
|
|
32
32
|
Do NOT extract:
|
|
33
33
|
- one-off task details ("changed line X in file Y today")
|
|
@@ -53,7 +53,7 @@ function tracesEnabledFromConfig() {
|
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* Extract candidates from a session transcript. transcript: plain-text session record.
|
|
56
|
-
* Returns [{ type, title, content, tags,
|
|
56
|
+
* Returns [{ type, title, content, tags, layer }], or [] on parse failure.
|
|
57
57
|
* opts.traces(可选):§18.6 D-TR6 开关显式透传(测试隔离/未来调用方)——缺省回退
|
|
58
58
|
* 磁盘配置(tracesEnabledFromConfig)——关 = chat() 出口不落盘。
|
|
59
59
|
*/
|
|
@@ -117,23 +117,23 @@ export function historyToTranscript(history, { maxChars = 30_000 } = {}) {
|
|
|
117
117
|
/**
|
|
118
118
|
* Write confirmed candidates to the specified layer.
|
|
119
119
|
* opts: { projectDir, team: { dir } | null, author }
|
|
120
|
-
*
|
|
120
|
+
* layer=team requires opts.team; project requires opts.projectDir.
|
|
121
121
|
* Returns write result description.
|
|
122
122
|
*/
|
|
123
123
|
export async function saveCandidate(memory, candidate, opts = {}) {
|
|
124
|
-
const
|
|
124
|
+
const layer = candidate.layer ?? candidate.scope ?? "personal"
|
|
125
125
|
// tags come from LLM output (untrusted): if not an array, stringify then split by comma/whitespace —
|
|
126
126
|
// calling .split on a non-string would crash, and models often produce "a, b" comma strings
|
|
127
127
|
const tags = Array.isArray(candidate.tags)
|
|
128
128
|
? candidate.tags.map((t) => String(t)).filter(Boolean)
|
|
129
129
|
: String(candidate.tags ?? "").split(/[\s,]+/).filter(Boolean)
|
|
130
130
|
|
|
131
|
-
if (
|
|
131
|
+
if (layer === "personal") {
|
|
132
132
|
const id = await put(memory, { type: candidate.type, title: candidate.title, content: candidate.content, tags: tags.join(" ") })
|
|
133
133
|
return `personal#${id}`
|
|
134
134
|
}
|
|
135
|
-
if (
|
|
136
|
-
if (!opts.projectDir) throw new Error("project
|
|
135
|
+
if (layer === "project") {
|
|
136
|
+
if (!opts.projectDir) throw new Error("project layer unavailable — no project directory configured (set memory.projectDir in ~/.thincoder/config.json)")
|
|
137
137
|
const filename = await putMarkdown(memory, {
|
|
138
138
|
layer: "project", dir: opts.projectDir,
|
|
139
139
|
type: candidate.type, title: candidate.title, content: candidate.content,
|
|
@@ -141,8 +141,8 @@ export async function saveCandidate(memory, candidate, opts = {}) {
|
|
|
141
141
|
})
|
|
142
142
|
return `project:${filename}`
|
|
143
143
|
}
|
|
144
|
-
if (
|
|
145
|
-
if (!opts.team?.dir) throw new Error("team
|
|
144
|
+
if (layer === "team") {
|
|
145
|
+
if (!opts.team?.dir) throw new Error("team layer not configured — configure memory.team in ~/.thincoder/config.json")
|
|
146
146
|
const filename = await putMarkdown(memory, {
|
|
147
147
|
layer: "team", dir: opts.team.dir,
|
|
148
148
|
type: candidate.type, title: candidate.title, content: candidate.content,
|
|
@@ -151,5 +151,5 @@ export async function saveCandidate(memory, candidate, opts = {}) {
|
|
|
151
151
|
await commitAndPush(opts.team.dir, filename, `memory: [${candidate.type}] ${candidate.title} (distilled)`)
|
|
152
152
|
return `team:${filename}`
|
|
153
153
|
}
|
|
154
|
-
throw new Error(`unknown
|
|
154
|
+
throw new Error(`unknown layer: ${layer}`)
|
|
155
155
|
}
|
|
@@ -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
|
+
}
|