@a9i5k4/dsh-auto-memory 2.5.3 → 3.0.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.
- package/README.md +189 -7
- package/README.zh-CN.md +189 -7
- package/docs/CONTRIBUTORS.html +471 -0
- package/docs/FRONTEND-CO-CREATION.md +191 -0
- package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
- package/docs/HANDOFF-CRITERIA.md +92 -0
- package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
- package/docs/INTEGRATION-ANALYSIS.md +350 -348
- package/docs/PROMO-PROMPT-3.0.md +100 -0
- package/docs/USER-GUIDE.en.md +58 -3
- package/docs/USER-GUIDE.zh-CN.md +59 -4
- package/docs/WHITEPAPER.md +207 -0
- package/docs/internal/ACCEPT-35-LIVE.md +143 -0
- package/docs/internal/ACCEPTANCE-20260914.md +90 -0
- package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
- package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
- package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
- package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
- package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
- package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
- package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
- package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
- package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
- package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
- package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
- package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
- package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
- package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
- package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
- package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
- package/docs/internal/FEATURE-INVENTORY.md +531 -0
- package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
- package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
- package/docs/internal/G3-DESIGN-20260918.md +82 -0
- package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
- package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
- package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
- package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
- package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
- package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
- package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
- package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
- package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
- package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
- package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
- package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
- package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
- package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
- package/docs/internal/KICKOFF-P0.md +254 -0
- package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
- package/docs/internal/MASTER-PLAN-3.0.md +411 -0
- package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
- package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
- package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
- package/docs/internal/PENDING-FIXES-20260916.md +289 -0
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
- package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
- package/docs/internal/PROGRESS-20260917.md +93 -0
- package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
- package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
- package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
- package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
- package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
- package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
- package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
- package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
- package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
- package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
- package/docs/internal/RESUME-20260918.md +171 -0
- package/docs/internal/RESUME-20260919.md +104 -0
- package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
- package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
- package/docs/internal/ROADMAP-20260917-WEEK.md +439 -0
- package/docs/internal/ROADMAP.md +106 -0
- package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
- package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +185 -0
- package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
- package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
- package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
- package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
- package/docs/internal/T6-EXECUTION-20260920.md +130 -0
- package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
- package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
- package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
- package/docs/internal/THREE-LAYER-CONTRACT.md +219 -0
- package/docs/internal/TODO-BACKLOG.md +263 -142
- package/docs/internal/TODO-GRAPH.html +715 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
- package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
- package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
- package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
- package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
- package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
- package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
- package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
- package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
- package/docs/prompts/M8-3-enable-verify.md +49 -49
- package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
- package/lib/acceptance.js +71 -0
- package/lib/activation-host.js +153 -18
- package/lib/activation-inbox.js +25 -7
- package/lib/board-mode.js +30 -0
- package/lib/client.js +1758 -90
- package/lib/config-io.js +156 -0
- package/lib/context-bridge.js +5 -2
- package/lib/context-host.js +86 -15
- package/lib/degrade.js +385 -0
- package/lib/dsh-home.js +143 -0
- package/lib/engine-identity.js +149 -0
- package/lib/engine-switch.js +247 -0
- package/lib/episodic-store.js +63 -12
- package/lib/evidence-store.js +10 -3
- package/lib/fact-store.js +22 -3
- package/lib/fs-retry.js +46 -0
- package/lib/index-sync.js +13 -1
- package/lib/index.js +3446 -263
- package/lib/intent-clean-safe.js +258 -0
- package/lib/intent-clean.js +12 -16
- package/lib/l0-extract.js +478 -149
- package/lib/l0-index-sync.js +195 -0
- package/lib/l0-index.js +349 -239
- package/lib/ledger-criteria.js +142 -0
- package/lib/m4-corpus.js +8 -2
- package/lib/m7-index-sync-host.js +73 -5
- package/lib/m7-wire.js +3 -3
- package/lib/memory-anchor.js +56 -1
- package/lib/memory-envelope.js +257 -0
- package/lib/memory-hub.js +138 -13
- package/lib/memory-index.js +4 -2
- package/lib/memory-mutation.js +246 -0
- package/lib/memory-writer.js +204 -24
- package/lib/note-status-apply.js +118 -0
- package/lib/note-status.js +196 -0
- package/lib/procedure-observation.js +48 -0
- package/lib/procedure-store.js +118 -20
- package/lib/python-setup.js +1 -1
- package/lib/python-sidecar-client.js +29 -3
- package/lib/recall-fusion.js +83 -12
- package/lib/rerank-host.js +160 -0
- package/lib/rules-edit.js +159 -0
- package/lib/rules-layer.js +261 -0
- package/lib/semantic-decide.js +41 -8
- package/lib/semantic-js.js +66 -6
- package/lib/shadow-host.js +3 -5
- package/lib/shadow-retrieval.js +3 -3
- package/lib/skill-export-host.js +153 -0
- package/lib/skill-export.js +239 -0
- package/lib/state-commit.js +245 -0
- package/lib/storage-manage.js +6 -0
- package/lib/subagent-gc.js +4 -8
- package/lib/temporal-parse.js +191 -159
- package/lib/tier-layer-inject.js +650 -0
- package/lib/tier0-catalog.js +735 -0
- package/lib/water-window.js +263 -186
- package/lib/wb-contract.js +691 -0
- package/lib/wb-sidecar.js +890 -0
- package/lib/ws-overview-rank.js +2 -2
- package/package.json +1 -1
- package/python/m7_embedding_v1.py +5 -5
- package/python/worker_semantic_v1.py +17 -6
- package/python/worker_v1.py +38 -4
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-0 目录生成器(tier0_catalog_v1)—— 三层检索契约(`docs/internal/THREE-LAYER-CONTRACT.md`)C4。
|
|
3
|
+
*
|
|
4
|
+
* 2026-09-14 建立。作用=**指引层**:把四类来源(用户级记忆 / 项目笔记 / 当日日志 / 白板 PLAN)
|
|
5
|
+
* 压成"每条 1 行"的短目录,用来回答「要不要用某条记忆」,从而决定是否下探 Tier-1 / Tier-2。
|
|
6
|
+
*
|
|
7
|
+
* 一行格式(契约 §1):`标题 · 一句结论 · layer · status · 日期`
|
|
8
|
+
* - layer ∈ { user | project | log | reflection | whiteboard },按**来源路径**归属(§2 判定规则);
|
|
9
|
+
* - status 一律 `current`(superseded / retracted 的判定属写入侧,指引层不臆断);
|
|
10
|
+
* - 预算 ≤ B0 token(契约 I1;默认 800,待实验 E1 校准);
|
|
11
|
+
* - 裁剪优先级:project > whiteboard > user > log(同层按日期倒序);`reflection` 排在 user 与 log
|
|
12
|
+
* 之间,与 §2「notes(project) > log(reflection) > log」一致。
|
|
13
|
+
*
|
|
14
|
+
* **per-layer 配额(2026-09-14 C5,契约 §2.1;opt-in)**:纯严格优先级会被 `project` 吃满预算——真实
|
|
15
|
+
* 语料实测 77 块把 800 token 全占,`whiteboard`/`user`/`log` 一条都进不来,"分层"退化成单层。
|
|
16
|
+
* 故开立 `quota` 选项(`{projectRatio=0.6, floorRatio=0.1, floorLayers=['whiteboard','user']}`):
|
|
17
|
+
* - `project ≤ projectRatio·maxTokens`(硬上限);
|
|
18
|
+
* - `whiteboard`/`user` 各**保底** `floorRatio·maxTokens`(先预留额度再按优先级填充,保底不足则二轮补齐);
|
|
19
|
+
* - 剩余额度按优先级继续填;被裁条目**按层计数**返回(`droppedByLayer` / `layerAccounting`),
|
|
20
|
+
* `degradedLayers` 明确列出"有候选却一条没进"的层 —— 落实 I7「不得静默丢层」。
|
|
21
|
+
* **默认关闭(`quota` 缺省 = 旧行为)**:默认关闭是为保住既有调用方的逐字节行为(C4 的极小预算
|
|
22
|
+
* 压力用例锁的是"纯严格优先级"语义);自动注入路径(`lib/tier-layer-inject.js`)显式开启。
|
|
23
|
+
*
|
|
24
|
+
* 预算口径(**重要**):I1 是硬上限,而仓库现有的 token 估算是 lib/index.js 的
|
|
25
|
+
* `estimateSessionTokens()`(官方 token-meter 移植,`ceil(字符/4)+4`)。该口径对 CJK 语料会**低估
|
|
26
|
+
* 约 2-4 倍**(中文接近 1 token/字符),拿它当预算门会静默突破 B0。故本模块两种口径都实现:
|
|
27
|
+
* - `estimateMode:'repo'` —— 原样复刻仓库既有口径(供对齐 / 复算);
|
|
28
|
+
* - `estimateMode:'conservative'` —— `ceil(字符/2)`,**默认**用它当预算门(CJK 密集体裁的保守上界)。
|
|
29
|
+
* 验收口径:**两种口径下 text 都 ≤ maxTokens**(见 `tests/smoke/smoke-test-tier0-catalog-pre.mjs`)。
|
|
30
|
+
*
|
|
31
|
+
* 单元("一条")切分:① 有 `<!-- memory:mem_<32hex> -->` 锚点 → 一块一条(与 l0-extract-pre 同约定:
|
|
32
|
+
* 锚点标记其**后**内容);② 无锚点 → 按最浅的 ≥2 级标题切(`#` 文档标题成为独立一条);③ 无标题 →
|
|
33
|
+
* 按顶层 `- ` 条目切;④ 都没有 → 整个文件算一条。
|
|
34
|
+
* 标题 = 该单元内**最深**的有效标题(剥掉 `(HH:MM)`/`(YYYY-MM-DD)` 尾巴;纯日期标题不算标题),
|
|
35
|
+
* 否则取首行的 `【标签】`,都没有则为空串(不硬造)。一句结论 = 首条实质内容行的首句,剥掉列表标记 /
|
|
36
|
+
* 时间前缀(`07:41 `)/ 序号(`①`、`1.`),≤ 80 字。
|
|
37
|
+
*
|
|
38
|
+
* 边界(契约 I7 精神):路径为空 / 文件不存在 / 是目录 / 过大 / 空文件 / 格式乱 → 一律**跳过并计数**,
|
|
39
|
+
* 绝不抛异常;目录为空时 text 输出显式空标记,不允许静默无声。
|
|
40
|
+
*
|
|
41
|
+
* 纯函数内核(零 IO,可单独测):estimateTokensPre / splitTier0UnitsPre / extractTier0ItemsPre /
|
|
42
|
+
* renderCatalogLinePre / buildTier0CatalogFromTextPre;IO 只集中在 buildTier0CatalogPre 与
|
|
43
|
+
* readTextSafePre。零第三方依赖(仅 node 内建 fs)。UTF-8 无 BOM。
|
|
44
|
+
*/
|
|
45
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
46
|
+
|
|
47
|
+
export const TIER0_CATALOG_VERSION = 'tier0_catalog_v1'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* ★M1(2026-09-18):白板锚点 id 的**形态判据**(契约 §2)。
|
|
51
|
+
*
|
|
52
|
+
* 与 `wb-sidecar.js:466 WB_ANCHOR_LINE_RE_V1`、契约 §2 的
|
|
53
|
+
* `^mem_[0-9a-f]{32}$` **同源同形**。此处**本地定义而非 import**:
|
|
54
|
+
* - tier0-catalog 是本仓最底层的目录渲染模块,不应反向依赖 wb-sidecar(避免耦合/循环);
|
|
55
|
+
* - 两处形态由**同一份契约文档**约束,套件断言两者**逐字相同**(防双源漂移)。
|
|
56
|
+
*
|
|
57
|
+
* 用途:**只在匹配时才走契约 §3 的链接形态**;id 形态非法则退回纯文本
|
|
58
|
+
* —— fail-closed:宁可输出纯文本,也不产出**指向不存在锚点的坏链接**。
|
|
59
|
+
*/
|
|
60
|
+
export const TIER0_ANCHOR_ID_RE = /^mem_[0-9a-f]{32}$/
|
|
61
|
+
|
|
62
|
+
export const TIER0_DEFAULTS = Object.freeze({
|
|
63
|
+
maxTokens: 800,
|
|
64
|
+
oneLineChars: 80,
|
|
65
|
+
titleChars: 40,
|
|
66
|
+
minChars: 15,
|
|
67
|
+
maxFileBytes: 4 * 1024 * 1024,
|
|
68
|
+
estimateMode: 'conservative',
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/** layer 优先级:数字小者优先保留(契约 §2)。 */
|
|
72
|
+
const LAYER_RANK = Object.freeze({ project: 0, whiteboard: 1, user: 2, reflection: 3, log: 4 })
|
|
73
|
+
|
|
74
|
+
/** 允许的 layer 取值(契约 §2)。 */
|
|
75
|
+
const KNOWN_LAYERS = Object.freeze(['user', 'project', 'log', 'reflection', 'whiteboard'])
|
|
76
|
+
|
|
77
|
+
/** 默认输入键 → layer(契约 §2 判定规则)。 */
|
|
78
|
+
const SOURCE_SPECS = Object.freeze([
|
|
79
|
+
Object.freeze({ key: 'workspaceMemoryPath', layer: 'project' }),
|
|
80
|
+
Object.freeze({ key: 'userMemoryPath', layer: 'user' }),
|
|
81
|
+
Object.freeze({ key: 'todayLogPath', layer: 'log' }),
|
|
82
|
+
Object.freeze({ key: 'handoffPlanPath', layer: 'whiteboard' }),
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
/** 锚点:`<!-- memory:mem_<32hex> -->`(允许空白浮动)。 */
|
|
86
|
+
const MEM_ANCHOR_RE = /<!--\s*memory:(mem_[0-9a-f]{32})\s*-->/g
|
|
87
|
+
/** 标题行:`## 标题`(≤3 空格缩进)。 */
|
|
88
|
+
const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*?)\s*$/
|
|
89
|
+
/** 列表标记:`- ` / `* ` / `+ ` / `• `。 */
|
|
90
|
+
const BULLET_RE = /^\s{0,3}[-*+•]\s+/
|
|
91
|
+
/** 行首时间戳:`07:41 ` / `14:5x `(日志惯例)。 */
|
|
92
|
+
const LEAD_TIME_RE = /^\s*\d{1,2}:\d{2}(?::\d{2})?[a-z]?\s+/
|
|
93
|
+
/** 行首序号:`①` / `1.` / `(2)` / `[3]` / `1、`。 */
|
|
94
|
+
const LEAD_ORDER_RE = /^\s*(?:[((]\s*\d{1,3}\s*[))]|\[\d{1,3}\]|\d{1,3}\s*[.)、.]|[\u2460-\u2473])\s*/
|
|
95
|
+
/** 行首 `【标签】` / `[标签]` / `(标签)`。 */
|
|
96
|
+
const LABEL_RE = /^\s*[【[((]\s*([^】\]))]{2,40})\s*[】\]))]/
|
|
97
|
+
/** 句末/句读分隔符(与 l0-extract-pre 同一字符类)。 */
|
|
98
|
+
const SENTENCE_SPLIT_RE = /[。;;!!??\n]/
|
|
99
|
+
/** 标题尾巴:`(07:41)` / `(2026-09-14)`;标题**开头**的日期与分隔符;日期串本体。 */
|
|
100
|
+
const HEADING_SUFFIX_RE = /\s*[((]\s*(?:\d{1,2}:\d{2}|\d{4}[-/.]\d{1,2}[-/.]\d{1,2})\s*[))]\s*$/
|
|
101
|
+
const LEAD_DATE_RE = /^\s*\d{4}[-/.]\d{1,2}[-/.]\d{1,2}\s*[·::\-—–]?\s*/
|
|
102
|
+
const DATE_STR_RE = /(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/g
|
|
103
|
+
/** 日期:`2026-09-14` / `2026/9/14`。 */
|
|
104
|
+
const DATE_RE = /(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/g
|
|
105
|
+
/** 分隔线 / 注释 / 强调标记。 */
|
|
106
|
+
const RULE_LINE_RE = /^\s*(?:-{3,}|\*{3,}|_{3,}|={3,})\s*$/
|
|
107
|
+
const COMMENT_LINE_RE = /^\s*<!--/
|
|
108
|
+
const EMPHASIS_RE = /\*\*|__/g
|
|
109
|
+
/** 含字母/汉字才算"实质内容行"。 */
|
|
110
|
+
const HAS_LETTER_RE = /\p{L}/u
|
|
111
|
+
|
|
112
|
+
const clean = (s) => String(s == null ? '' : s).replace(/\u0000/g, '').trim()
|
|
113
|
+
const stripBom = (s) => (s && s.charCodeAt(0) === 0xfeff ? s.slice(1) : s)
|
|
114
|
+
const cut = (s, n) => (s.length <= n ? s : s.slice(0, Math.max(1, n - 1)) + '…')
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 纯日期标题(`2026-08-14`、`2026-08-14(周一)`)不作标题用——
|
|
118
|
+
* 判定=把日期串与分隔符剥光后什么都不剩。
|
|
119
|
+
*/
|
|
120
|
+
function isDateOnly(s) {
|
|
121
|
+
const t = String(s || '')
|
|
122
|
+
if (!findDatePre(t)) return false
|
|
123
|
+
DATE_STR_RE.lastIndex = 0
|
|
124
|
+
return t.replace(DATE_STR_RE, '').replace(/[\s\-—–·::()()[\]【】.,,、/]+/g, '') === ''
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* token 估算。两种口径都可用,默认走保守口径(见文件头"预算口径")。
|
|
129
|
+
* 保守口径取 `max(ceil(字符/2), repo 口径)`——即**永不弱于**仓库既有口径(短文本时 repo 口径的
|
|
130
|
+
* `+4` 框定开销更大,取 max 后两边都盖住)。
|
|
131
|
+
* @param {string} text
|
|
132
|
+
* @param {{mode?: 'repo'|'conservative'}} [opts]
|
|
133
|
+
* @returns {number}
|
|
134
|
+
*/
|
|
135
|
+
export function estimateTokensPre(text, opts = {}) {
|
|
136
|
+
const s = String(text == null ? '' : text)
|
|
137
|
+
if (!s) return 0
|
|
138
|
+
const mode = opts && opts.mode === 'repo' ? 'repo' : 'conservative'
|
|
139
|
+
// repo 口径:lib/index.js estimateSessionTokens()(官方 token-meter 移植)的文本分支。
|
|
140
|
+
const repo = Math.ceil(s.length / 4) + 4
|
|
141
|
+
if (mode === 'repo') return repo
|
|
142
|
+
// 保守口径:CJK 密集时 4 字符≈1 token 会低估,按 2 字符≈1 token 封顶。
|
|
143
|
+
return Math.max(Math.ceil(s.length / 2), repo)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 取文本中首个合法日期(`YYYY-MM-DD`);无则空串。 */
|
|
147
|
+
export function findDatePre(text) {
|
|
148
|
+
const s = String(text == null ? '' : text)
|
|
149
|
+
DATE_RE.lastIndex = 0
|
|
150
|
+
let m
|
|
151
|
+
while ((m = DATE_RE.exec(s)) !== null) {
|
|
152
|
+
const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3])
|
|
153
|
+
if (y >= 1900 && y <= 2999 && mo >= 1 && mo <= 12 && d >= 1 && d <= 31) {
|
|
154
|
+
return `${m[1]}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
|
155
|
+
}
|
|
156
|
+
if (m.index === DATE_RE.lastIndex) DATE_RE.lastIndex++
|
|
157
|
+
}
|
|
158
|
+
return ''
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 切分单元(纯函数)。返回 `{ groups, headLines }`;`groups` 每项是一个单元的行数组。
|
|
163
|
+
* 顺序:锚点 → 标题 → 顶层 `- ` 条目 → 整文件兜底。
|
|
164
|
+
*/
|
|
165
|
+
export function splitTier0UnitsPre(text) {
|
|
166
|
+
const src = String(text == null ? '' : text)
|
|
167
|
+
const empty = { groups: [], headLines: [] }
|
|
168
|
+
if (!src.trim()) return empty
|
|
169
|
+
const allLines = src.split(/\r?\n/)
|
|
170
|
+
const headLines = allLines.map(clean).filter(Boolean).slice(0, 3)
|
|
171
|
+
|
|
172
|
+
// ① 记忆锚点:锚点标记其后内容(与 l0-extract-pre.parseMemoryItemsPre 同约定)
|
|
173
|
+
MEM_ANCHOR_RE.lastIndex = 0
|
|
174
|
+
const marks = []
|
|
175
|
+
let m
|
|
176
|
+
while ((m = MEM_ANCHOR_RE.exec(src)) !== null) {
|
|
177
|
+
marks.push({ start: m.index, end: m.index + m[0].length })
|
|
178
|
+
if (m.index === MEM_ANCHOR_RE.lastIndex) MEM_ANCHOR_RE.lastIndex++
|
|
179
|
+
}
|
|
180
|
+
if (marks.length) {
|
|
181
|
+
const groups = []
|
|
182
|
+
for (let i = 0; i < marks.length; i++) {
|
|
183
|
+
const from = marks[i].end
|
|
184
|
+
const to = i + 1 < marks.length ? marks[i + 1].start : src.length
|
|
185
|
+
const body = clean(src.slice(from, to))
|
|
186
|
+
if (body) groups.push(body.split(/\r?\n/))
|
|
187
|
+
}
|
|
188
|
+
if (groups.length) return { groups, headLines }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ② 标题切分:取最浅的 ≥2 级标题作分界(全为 1 级时退化为按 1 级)
|
|
192
|
+
const heads = []
|
|
193
|
+
allLines.forEach((line, i) => {
|
|
194
|
+
const h = HEADING_RE.exec(line)
|
|
195
|
+
if (h) heads.push({ i, level: h[1].length })
|
|
196
|
+
})
|
|
197
|
+
if (heads.length) {
|
|
198
|
+
const levels = [...new Set(heads.map((h) => h.level))].sort((a, b) => a - b)
|
|
199
|
+
const splitLevel = levels.find((lv) => lv >= 2) || levels[0]
|
|
200
|
+
const bounds = heads.filter((h) => h.level <= splitLevel).map((h) => h.i)
|
|
201
|
+
const groups = []
|
|
202
|
+
for (let i = 0; i < bounds.length; i++) {
|
|
203
|
+
const from = bounds[i]
|
|
204
|
+
const to = i + 1 < bounds.length ? bounds[i + 1] : allLines.length
|
|
205
|
+
const lines = allLines.slice(from, to)
|
|
206
|
+
if (lines.some((l) => clean(l))) groups.push(lines)
|
|
207
|
+
}
|
|
208
|
+
if (groups.length) return { groups, headLines }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ③ 无标题:按顶层 `- ` 条目切
|
|
212
|
+
const bullets = []
|
|
213
|
+
allLines.forEach((line, i) => { if (BULLET_RE.test(line)) bullets.push(i) })
|
|
214
|
+
if (bullets.length >= 2) {
|
|
215
|
+
return { groups: bullets.map((i) => [allLines[i]]), headLines }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ④ 兜底:整个文件算一条
|
|
219
|
+
return { groups: [allLines], headLines }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 单元内最深的有效标题(纯日期标题不算;剥掉头尾日期与(HH:MM)尾巴);无则空串。 */
|
|
223
|
+
function pickHeadingTitle(lines, titleChars) {
|
|
224
|
+
let best = null
|
|
225
|
+
for (const line of lines) {
|
|
226
|
+
const h = HEADING_RE.exec(line)
|
|
227
|
+
if (!h) continue
|
|
228
|
+
const t = clean(String(h[2]).replace(HEADING_SUFFIX_RE, '').replace(LEAD_DATE_RE, ''))
|
|
229
|
+
if (!t || isDateOnly(t)) continue
|
|
230
|
+
const level = h[1].length
|
|
231
|
+
if (!best || level > best.level) best = { level, text: t }
|
|
232
|
+
}
|
|
233
|
+
return best ? cut(best.text, titleChars) : ''
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 首条实质内容行:剥列表标记 / 序号 / 时间前缀 / 引用符 / 强调标记;返回 `{ label, body }`。 */
|
|
237
|
+
function pickContentLine(lines) {
|
|
238
|
+
for (const line of lines) {
|
|
239
|
+
if (HEADING_RE.test(line) || COMMENT_LINE_RE.test(line) || RULE_LINE_RE.test(line)) continue
|
|
240
|
+
let t = clean(String(line).replace(/^\s*>\s?/, ''))
|
|
241
|
+
t = t.replace(BULLET_RE, '')
|
|
242
|
+
t = t.replace(LEAD_ORDER_RE, '')
|
|
243
|
+
t = t.replace(LEAD_TIME_RE, '')
|
|
244
|
+
t = clean(t.replace(EMPHASIS_RE, ''))
|
|
245
|
+
if (!t || !HAS_LETTER_RE.test(t)) continue
|
|
246
|
+
const lab = LABEL_RE.exec(t)
|
|
247
|
+
if (lab) {
|
|
248
|
+
const label = clean(lab[1])
|
|
249
|
+
const rest = clean(t.slice(lab[0].length))
|
|
250
|
+
if (label && rest) return { label, body: rest }
|
|
251
|
+
}
|
|
252
|
+
return { label: '', body: t }
|
|
253
|
+
}
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 首句;不足 minChars 时并接后续句子(同 l0-extract-pre 的 growToMin 思路)。 */
|
|
258
|
+
function firstSentence(text, minChars, maxChars) {
|
|
259
|
+
const flat = clean(String(text).replace(/\s+/g, ' '))
|
|
260
|
+
if (!flat) return ''
|
|
261
|
+
const parts = flat.split(SENTENCE_SPLIT_RE).map(clean).filter(Boolean)
|
|
262
|
+
if (!parts.length) return ''
|
|
263
|
+
let acc = parts[0]
|
|
264
|
+
for (let i = 1; i < parts.length && acc.length < minChars; i++) acc += '。' + parts[i]
|
|
265
|
+
return cut(acc, maxChars)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 日期:单元标题行 → 文件名 → 文件头 → 单元正文。 */
|
|
269
|
+
function resolveDatePre(lines, ctx) {
|
|
270
|
+
for (const line of lines) {
|
|
271
|
+
if (!HEADING_RE.test(line)) continue
|
|
272
|
+
const d = findDatePre(line)
|
|
273
|
+
if (d) return d
|
|
274
|
+
}
|
|
275
|
+
if (ctx.baseDate) return ctx.baseDate
|
|
276
|
+
if (ctx.fileDate) return ctx.fileDate
|
|
277
|
+
for (const line of lines) {
|
|
278
|
+
const d = findDatePre(line)
|
|
279
|
+
if (d) return d
|
|
280
|
+
}
|
|
281
|
+
return ''
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 单个单元 → 一条目录项;无实质内容返回 null。 */
|
|
285
|
+
function buildRecordPre(layer, unitLines, ctx, cfg) {
|
|
286
|
+
const lines = unitLines.map(clean).filter(Boolean)
|
|
287
|
+
if (!lines.length) return null
|
|
288
|
+
let title = pickHeadingTitle(lines, cfg.titleChars)
|
|
289
|
+
const content = pickContentLine(lines)
|
|
290
|
+
let oneLine = ''
|
|
291
|
+
if (content) {
|
|
292
|
+
if (!title && content.label) title = cut(content.label, cfg.titleChars)
|
|
293
|
+
oneLine = firstSentence(content.body, cfg.minChars, cfg.oneLineChars)
|
|
294
|
+
}
|
|
295
|
+
if (!title && !oneLine) return null
|
|
296
|
+
return { layer, status: 'current', date: resolveDatePre(lines, ctx), title, oneLine }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 单份文件文本 → 目录项数组(纯函数;layer 由调用方给定,非法 layer 返回空数组,不抛错)。
|
|
301
|
+
* @param {string} text
|
|
302
|
+
* @param {'user'|'project'|'log'|'reflection'|'whiteboard'} layer
|
|
303
|
+
* @param {{path?:string, maxTokens?:number, oneLineChars?:number, titleChars?:number, minChars?:number}} [opts]
|
|
304
|
+
*/
|
|
305
|
+
export function extractTier0ItemsPre(text, layer, opts = {}) {
|
|
306
|
+
const o = opts && typeof opts === 'object' ? opts : {}
|
|
307
|
+
return extractSourcePre(text, layer, { path: clean(o.path) }, resolveOptsPre(o)).items
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** 单份来源的抽取(内部):同时给出单元数与丢弃数。 */
|
|
311
|
+
function extractSourcePre(text, layer, ctx, cfg) {
|
|
312
|
+
if (!KNOWN_LAYERS.includes(layer)) return { items: [], units: 0, dropped: 0 }
|
|
313
|
+
const src = stripBom(String(text == null ? '' : text))
|
|
314
|
+
if (!src.trim()) return { items: [], units: 0, dropped: 0 }
|
|
315
|
+
const { groups, headLines } = splitTier0UnitsPre(src)
|
|
316
|
+
const base = clean(String(ctx.path || '').replace(/\\/g, '/').split('/').pop())
|
|
317
|
+
const dateCtx = { baseDate: findDatePre(base), fileDate: findDatePre(headLines.join(' ')) }
|
|
318
|
+
const items = []
|
|
319
|
+
let dropped = 0
|
|
320
|
+
for (const unitLines of groups) {
|
|
321
|
+
const rec = buildRecordPre(layer, unitLines, dateCtx, cfg)
|
|
322
|
+
if (rec) items.push(rec)
|
|
323
|
+
else dropped++
|
|
324
|
+
}
|
|
325
|
+
return { items, units: groups.length, dropped }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 一条目录项 → 一行文本。**导出**:渲染口径的唯一实现。
|
|
330
|
+
*
|
|
331
|
+
* ★M1 契约行渲染(2026-09-18):**同一次派生、两种渲染**。
|
|
332
|
+
* - 无 `path`/`id` ⇒ 纯文本(**逐字节同旧实现**,老调用方与老套件零影响)
|
|
333
|
+
* - 有 `path`+`id` ⇒ 契约 §3 链接形态:
|
|
334
|
+
* `- [标题](<页面路径>#mem_<32hex>) — 一句话摘要 · layer=whiteboard · status=current · 日期`
|
|
335
|
+
*
|
|
336
|
+
* 为什么是"两种渲染"而不是"新文件":契约 §8 要求
|
|
337
|
+
* 「由页面派生出的 index 与 Tier-0 目录条目**一致**」——
|
|
338
|
+
* 两者出自**同一份 items**、同一个渲染函数,一致性**天然成立**,不需要额外同步机制。
|
|
339
|
+
* (新建独立文件会新增状态源,违反 S10.4「不建状态机」。)
|
|
340
|
+
*/
|
|
341
|
+
export function renderCatalogLinePre(item) {
|
|
342
|
+
const it = item && typeof item === 'object' ? item : {}
|
|
343
|
+
const title = clean(it.title)
|
|
344
|
+
const oneLine = clean(it.oneLine)
|
|
345
|
+
const date = clean(it.date)
|
|
346
|
+
const layer = clean(it.layer) || 'log'
|
|
347
|
+
const status = clean(it.status) || 'current'
|
|
348
|
+
|
|
349
|
+
// ── 契约 §3 链接形态(仅当 path + id 齐备时启用)──
|
|
350
|
+
const p = clean(it.path)
|
|
351
|
+
const id = clean(it.id)
|
|
352
|
+
if (p && id && TIER0_ANCHOR_ID_RE.test(id)) {
|
|
353
|
+
const head = title || oneLine
|
|
354
|
+
const link = '[' + head + '](' + p + '#' + id + ')'
|
|
355
|
+
const segs = [link]
|
|
356
|
+
// 摘要:与标题不同才追加(同旧实现口径:title===oneLine 时不重复)
|
|
357
|
+
if (oneLine && oneLine !== title) segs.push(oneLine)
|
|
358
|
+
const line = segs.join(' — ')
|
|
359
|
+
return '- ' + line + ' · layer=' + layer + ' · status=' + status + (date ? ' · ' + date : '')
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── 纯文本形态(逐字节同旧实现)──
|
|
363
|
+
const segs = []
|
|
364
|
+
if (title && title !== oneLine) segs.push(title)
|
|
365
|
+
if (oneLine) segs.push(oneLine)
|
|
366
|
+
segs.push(layer)
|
|
367
|
+
segs.push(status)
|
|
368
|
+
if (date) segs.push(date)
|
|
369
|
+
return segs.join(' · ')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** 契约要求的对外字段(五字段,顺序固定)。 */
|
|
373
|
+
const toPublicItemPre = (r) => ({
|
|
374
|
+
layer: r.layer, status: r.status, date: r.date, title: r.title, oneLine: r.oneLine,
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
/** 排序:layer 优先级升序 → 同层日期倒序 → 原顺序(确定性)。 */
|
|
378
|
+
function compareRecordsPre(a, b) {
|
|
379
|
+
const ra = LAYER_RANK[a.layer] === undefined ? 9 : LAYER_RANK[a.layer]
|
|
380
|
+
const rb = LAYER_RANK[b.layer] === undefined ? 9 : LAYER_RANK[b.layer]
|
|
381
|
+
if (ra !== rb) return ra - rb
|
|
382
|
+
if (a.date !== b.date) {
|
|
383
|
+
if (!a.date) return 1
|
|
384
|
+
if (!b.date) return -1
|
|
385
|
+
return a.date < b.date ? 1 : -1
|
|
386
|
+
}
|
|
387
|
+
return a._seq - b._seq
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** per-layer 配额默认值(契约 §2.1)。 */
|
|
391
|
+
export const TIER0_QUOTA_DEFAULTS = Object.freeze({
|
|
392
|
+
projectRatio: 0.6,
|
|
393
|
+
floorRatio: 0.1,
|
|
394
|
+
projectLayer: 'project',
|
|
395
|
+
floorLayers: Object.freeze(['whiteboard', 'user']),
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
/** 配额参数归一化(`quota===true` 即全默认;非法值一律回退默认,不抛错)。 */
|
|
399
|
+
function resolveQuotaPre(q) {
|
|
400
|
+
const o = q === true ? {} : (q && typeof q === 'object' ? q : {})
|
|
401
|
+
const ratio = (v, d) => { const n = Number(v); return Number.isFinite(n) && n > 0 && n <= 1 ? n : d }
|
|
402
|
+
const fl = Array.isArray(o.floorLayers) ? o.floorLayers.filter((l) => KNOWN_LAYERS.includes(l)) : [...TIER0_QUOTA_DEFAULTS.floorLayers]
|
|
403
|
+
return {
|
|
404
|
+
projectRatio: ratio(o.projectRatio, TIER0_QUOTA_DEFAULTS.projectRatio),
|
|
405
|
+
floorRatio: ratio(o.floorRatio, TIER0_QUOTA_DEFAULTS.floorRatio),
|
|
406
|
+
projectLayer: KNOWN_LAYERS.includes(o.projectLayer) ? o.projectLayer : TIER0_QUOTA_DEFAULTS.projectLayer,
|
|
407
|
+
floorLayers: [...new Set(fl)],
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* per-layer 配额分配(纯函数,确定性;契约 §2.1)。
|
|
413
|
+
*
|
|
414
|
+
* 两轮:
|
|
415
|
+
* ① 优先级填充(project > whiteboard > user > reflection > log,同层日期倒序):逐条试装;
|
|
416
|
+
* 超 `project` 层上限(`projectRatio·maxTokens`)或超"总预算 − 其它保底层尚欠额度"者跳过;
|
|
417
|
+
* ② 保底补齐:对仍低于保底的层(`floorLayers`),把①里被预留挤掉的条目补进来(仍受总预算约束)。
|
|
418
|
+
*
|
|
419
|
+
* @param {Array<{layer:string,date:string,title:string,oneLine:string,status:string,_seq?:number}>} candidates
|
|
420
|
+
* @param {{maxTokens?:number, estimateMode?:'repo'|'conservative',
|
|
421
|
+
* quota?:boolean|{projectRatio?:number,floorRatio?:number,projectLayer?:string,floorLayers?:string[]}}} [opts]
|
|
422
|
+
* @returns {{picked:Array, text:string, tokens:number, dropped:number,
|
|
423
|
+
* droppedByLayer:Record<string,number>, perLayer:Record<string,object>,
|
|
424
|
+
* caps:Record<string,number|null>, floors:Record<string,number>,
|
|
425
|
+
* degradedLayers:string[], emptyLayers:string[], quotaApplied:boolean}}
|
|
426
|
+
*/
|
|
427
|
+
export function allocateTier0QuotaPre(candidates, opts = {}) {
|
|
428
|
+
const cfg = opts && typeof opts === 'object' ? opts : {}
|
|
429
|
+
const maxTokens = Number(cfg.maxTokens) > 0 ? Number(cfg.maxTokens) : TIER0_DEFAULTS.maxTokens
|
|
430
|
+
const estimateMode = cfg.estimateMode === 'repo' ? 'repo' : 'conservative'
|
|
431
|
+
const q = resolveQuotaPre(cfg.quota === undefined ? true : cfg.quota)
|
|
432
|
+
const list = (Array.isArray(candidates) ? candidates : []).map((r, i) => Object.assign({}, r, {
|
|
433
|
+
_seq: Number.isFinite(r && r._seq) ? r._seq : i,
|
|
434
|
+
}))
|
|
435
|
+
const sorted = list.slice().sort(compareRecordsPre)
|
|
436
|
+
const has = new Set(sorted.map((r) => r.layer))
|
|
437
|
+
|
|
438
|
+
const caps = {}
|
|
439
|
+
const floors = {}
|
|
440
|
+
for (const layer of KNOWN_LAYERS) {
|
|
441
|
+
caps[layer] = layer === q.projectLayer ? Math.max(1, Math.floor(maxTokens * q.projectRatio)) : null
|
|
442
|
+
floors[layer] = (q.floorLayers.includes(layer) && has.has(layer)) ? Math.max(1, Math.ceil(maxTokens * q.floorRatio)) : 0
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const layerChars = {}
|
|
446
|
+
const tokensOf = (layer) => (layerChars[layer] ? estimateTokensPre(layerChars[layer], { mode: estimateMode }) : 0)
|
|
447
|
+
/** 其它保底层尚欠的额度(要预留,不能被高优先级层先吃掉)。 */
|
|
448
|
+
const reserveFor = (exceptLayer) => {
|
|
449
|
+
let sum = 0
|
|
450
|
+
for (const layer of q.floorLayers) {
|
|
451
|
+
const f = floors[layer] || 0
|
|
452
|
+
if (!f || layer === exceptLayer) continue
|
|
453
|
+
sum += Math.max(0, f - tokensOf(layer))
|
|
454
|
+
}
|
|
455
|
+
return sum
|
|
456
|
+
}
|
|
457
|
+
const chosen = []
|
|
458
|
+
const accepted = new Set()
|
|
459
|
+
let text = ''
|
|
460
|
+
const accept = (rec, line) => {
|
|
461
|
+
layerChars[rec.layer] = layerChars[rec.layer] ? layerChars[rec.layer] + '\n' + line : line
|
|
462
|
+
chosen.push(rec)
|
|
463
|
+
accepted.add(rec)
|
|
464
|
+
text = text ? text + '\n' + line : line
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ① 优先级填充(预留保底额度)
|
|
468
|
+
for (const rec of sorted) {
|
|
469
|
+
const line = renderCatalogLinePre(rec)
|
|
470
|
+
const layerTok = estimateTokensPre(layerChars[rec.layer] ? layerChars[rec.layer] + '\n' + line : line, { mode: estimateMode })
|
|
471
|
+
if (caps[rec.layer] !== null && layerTok > caps[rec.layer]) continue
|
|
472
|
+
const reserve = reserveFor(rec.layer) + Math.max(0, (floors[rec.layer] || 0) - layerTok)
|
|
473
|
+
const next = text ? text + '\n' + line : line
|
|
474
|
+
if (estimateTokensPre(next, { mode: estimateMode }) > maxTokens - reserve) continue
|
|
475
|
+
accept(rec, line)
|
|
476
|
+
}
|
|
477
|
+
// ② 保底补齐(补①中被预留挤掉的保底层条目)
|
|
478
|
+
for (const rec of sorted) {
|
|
479
|
+
if (accepted.has(rec)) continue
|
|
480
|
+
const floor = floors[rec.layer] || 0
|
|
481
|
+
if (!floor || tokensOf(rec.layer) >= floor) continue
|
|
482
|
+
const line = renderCatalogLinePre(rec)
|
|
483
|
+
const layerTok = estimateTokensPre(layerChars[rec.layer] ? layerChars[rec.layer] + '\n' + line : line, { mode: estimateMode })
|
|
484
|
+
if (caps[rec.layer] !== null && layerTok > caps[rec.layer]) continue
|
|
485
|
+
const next = text ? text + '\n' + line : line
|
|
486
|
+
if (estimateTokensPre(next, { mode: estimateMode }) > maxTokens) continue
|
|
487
|
+
accept(rec, line)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
chosen.sort(compareRecordsPre)
|
|
491
|
+
const perLayer = {}
|
|
492
|
+
const droppedByLayer = {}
|
|
493
|
+
const degradedLayers = []
|
|
494
|
+
const emptyLayers = []
|
|
495
|
+
for (const layer of KNOWN_LAYERS) {
|
|
496
|
+
const total = sorted.filter((r) => r.layer === layer).length
|
|
497
|
+
const picked = chosen.filter((r) => r.layer === layer).length
|
|
498
|
+
perLayer[layer] = {
|
|
499
|
+
candidates: total, picked, dropped: total - picked, tokens: tokensOf(layer),
|
|
500
|
+
cap: caps[layer] === null ? null : caps[layer], floor: floors[layer] || 0,
|
|
501
|
+
}
|
|
502
|
+
if (total - picked > 0) droppedByLayer[layer] = total - picked
|
|
503
|
+
if (total > 0 && picked === 0) degradedLayers.push(layer)
|
|
504
|
+
if (total === 0) emptyLayers.push(layer)
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
picked: chosen,
|
|
508
|
+
text,
|
|
509
|
+
tokens: text ? estimateTokensPre(text, { mode: estimateMode }) : 0,
|
|
510
|
+
dropped: sorted.length - chosen.length,
|
|
511
|
+
droppedByLayer,
|
|
512
|
+
perLayer,
|
|
513
|
+
caps,
|
|
514
|
+
floors,
|
|
515
|
+
degradedLayers,
|
|
516
|
+
emptyLayers,
|
|
517
|
+
quotaApplied: true,
|
|
518
|
+
projectRatio: q.projectRatio,
|
|
519
|
+
floorRatio: q.floorRatio,
|
|
520
|
+
floorLayers: [...q.floorLayers],
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function resolveOptsPre(opts) {
|
|
525
|
+
const o = opts && typeof opts === 'object' ? opts : {}
|
|
526
|
+
const num = (v, d) => {
|
|
527
|
+
const n = Number(v)
|
|
528
|
+
return Number.isFinite(n) && n > 0 ? n : d
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
maxTokens: num(o.maxTokens, TIER0_DEFAULTS.maxTokens),
|
|
532
|
+
oneLineChars: num(o.oneLineChars, TIER0_DEFAULTS.oneLineChars),
|
|
533
|
+
titleChars: num(o.titleChars, TIER0_DEFAULTS.titleChars),
|
|
534
|
+
minChars: num(o.minChars, TIER0_DEFAULTS.minChars),
|
|
535
|
+
maxFileBytes: num(o.maxFileBytes, TIER0_DEFAULTS.maxFileBytes),
|
|
536
|
+
estimateMode: o.estimateMode === 'repo' ? 'repo' : 'conservative',
|
|
537
|
+
markTruncation: o.markTruncation === true,
|
|
538
|
+
// C5:per-layer 配额(opt-in;缺省=旧行为,逐字节不变)
|
|
539
|
+
quota: o.quota === true || (o.quota && typeof o.quota === 'object') ? o.quota : false,
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* 已读到内存的多来源 → Tier-0 目录(纯函数,无 IO;确定性)。
|
|
545
|
+
*
|
|
546
|
+
* @param {Array<{layer:string, text:string, path?:string}>} sources
|
|
547
|
+
* @param {{maxTokens?:number, estimateMode?:'repo'|'conservative', oneLineChars?:number,
|
|
548
|
+
* titleChars?:number, minChars?:number, markTruncation?:boolean}} [opts]
|
|
549
|
+
* @returns {{text:string, items:Array<{layer:string,status:string,date:string,title:string,oneLine:string}>,
|
|
550
|
+
* tokens:number, maxTokens:number, estimateMode:string, tokenRepo:number, dropped:number,
|
|
551
|
+
* truncated:boolean, units:number, droppedUnits:number, candidates:number,
|
|
552
|
+
* skipped:Array<{layer:string,path:string,reason:string}>}}
|
|
553
|
+
*/
|
|
554
|
+
export function buildTier0CatalogFromTextPre(sources, opts = {}) {
|
|
555
|
+
const cfg = resolveOptsPre(opts)
|
|
556
|
+
const list = Array.isArray(sources) ? sources : []
|
|
557
|
+
const candidates = []
|
|
558
|
+
const skipped = []
|
|
559
|
+
let units = 0
|
|
560
|
+
let droppedUnits = 0
|
|
561
|
+
let seq = 0
|
|
562
|
+
|
|
563
|
+
for (const src of list) {
|
|
564
|
+
const layer = clean(src && src.layer)
|
|
565
|
+
const path = clean(src && src.path)
|
|
566
|
+
if (!KNOWN_LAYERS.includes(layer)) {
|
|
567
|
+
skipped.push({ layer, path, reason: 'unknown-layer' })
|
|
568
|
+
continue
|
|
569
|
+
}
|
|
570
|
+
if (!clean(src && src.text)) {
|
|
571
|
+
skipped.push({ layer, path, reason: 'empty' })
|
|
572
|
+
continue
|
|
573
|
+
}
|
|
574
|
+
const r = extractSourcePre(src.text, layer, { path }, cfg)
|
|
575
|
+
units += r.units
|
|
576
|
+
droppedUnits += r.dropped
|
|
577
|
+
if (!r.items.length) {
|
|
578
|
+
skipped.push({ layer, path, reason: 'no-items' })
|
|
579
|
+
continue
|
|
580
|
+
}
|
|
581
|
+
for (const rec of r.items) candidates.push(Object.assign({}, rec, { _seq: seq++, _path: path }))
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
candidates.sort(compareRecordsPre)
|
|
585
|
+
|
|
586
|
+
const picked = []
|
|
587
|
+
let text = ''
|
|
588
|
+
let dropped = 0
|
|
589
|
+
// C5:per-layer 配额(opt-in)。关闭时下面这段 = C4 既有贪心(逐字节不变)。
|
|
590
|
+
const alloc = cfg.quota
|
|
591
|
+
? allocateTier0QuotaPre(candidates, { maxTokens: cfg.maxTokens, estimateMode: cfg.estimateMode, quota: cfg.quota })
|
|
592
|
+
: null
|
|
593
|
+
if (alloc) {
|
|
594
|
+
for (const rec of alloc.picked) picked.push(rec)
|
|
595
|
+
text = alloc.text
|
|
596
|
+
dropped = alloc.dropped
|
|
597
|
+
} else {
|
|
598
|
+
for (const rec of candidates) {
|
|
599
|
+
const line = renderCatalogLinePre(rec)
|
|
600
|
+
const next = text ? text + '\n' + line : line
|
|
601
|
+
if (estimateTokensPre(next, { mode: cfg.estimateMode }) > cfg.maxTokens) {
|
|
602
|
+
dropped++
|
|
603
|
+
continue
|
|
604
|
+
}
|
|
605
|
+
picked.push(rec)
|
|
606
|
+
text = next
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (!picked.length) {
|
|
611
|
+
// I7 精神:目录为空也必须显式发声,不许静默注入空串。
|
|
612
|
+
text = skipped.length
|
|
613
|
+
? `[Tier-0 目录为空 · 跳过来源 ${skipped.length}]`
|
|
614
|
+
: '[Tier-0 目录为空 · 无可渲染条目]'
|
|
615
|
+
} else if (cfg.markTruncation && dropped > 0) {
|
|
616
|
+
const mark = `…已裁剪 ${dropped} 条(低优先级)`
|
|
617
|
+
if (estimateTokensPre(text + '\n' + mark, { mode: cfg.estimateMode }) <= cfg.maxTokens) {
|
|
618
|
+
text = text + '\n' + mark
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return {
|
|
623
|
+
text,
|
|
624
|
+
items: picked.map(toPublicItemPre),
|
|
625
|
+
tokens: estimateTokensPre(text, { mode: cfg.estimateMode }),
|
|
626
|
+
maxTokens: cfg.maxTokens,
|
|
627
|
+
estimateMode: cfg.estimateMode,
|
|
628
|
+
tokenRepo: estimateTokensPre(text, { mode: 'repo' }),
|
|
629
|
+
dropped,
|
|
630
|
+
truncated: dropped > 0,
|
|
631
|
+
units,
|
|
632
|
+
droppedUnits,
|
|
633
|
+
candidates: candidates.length,
|
|
634
|
+
skipped,
|
|
635
|
+
// C5 配额账(quota 关闭时 quota=null,键仍在,调用方无需判空取法)
|
|
636
|
+
quota: alloc
|
|
637
|
+
? {
|
|
638
|
+
applied: true,
|
|
639
|
+
projectRatio: alloc.projectRatio,
|
|
640
|
+
floorRatio: alloc.floorRatio,
|
|
641
|
+
floorLayers: alloc.floorLayers,
|
|
642
|
+
caps: alloc.caps,
|
|
643
|
+
floors: alloc.floors,
|
|
644
|
+
perLayer: alloc.perLayer,
|
|
645
|
+
droppedByLayer: alloc.droppedByLayer,
|
|
646
|
+
degradedLayers: alloc.degradedLayers,
|
|
647
|
+
emptyLayers: alloc.emptyLayers,
|
|
648
|
+
}
|
|
649
|
+
: null,
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* 读文本文件(容错,永不抛错)。
|
|
655
|
+
* @param {string} p
|
|
656
|
+
* @param {{maxFileBytes?:number}} [opts]
|
|
657
|
+
* @returns {{ok:boolean, text:string, path:string, reason:string}}
|
|
658
|
+
*/
|
|
659
|
+
export function readTextSafePre(p, opts = {}) {
|
|
660
|
+
const maxBytes = Number((opts && opts.maxFileBytes) || TIER0_DEFAULTS.maxFileBytes) || TIER0_DEFAULTS.maxFileBytes
|
|
661
|
+
const path = typeof p === 'string' ? p.trim() : ''
|
|
662
|
+
const fail = (reason) => ({ ok: false, text: '', path, reason })
|
|
663
|
+
if (!path) return fail('missing-path')
|
|
664
|
+
let st = null
|
|
665
|
+
try {
|
|
666
|
+
st = statSync(path)
|
|
667
|
+
} catch (e) {
|
|
668
|
+
return fail(e && e.code === 'ENOENT' ? 'not-found' : 'stat-error')
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
if (st.isDirectory()) return fail('not-a-file')
|
|
672
|
+
if (Number(st.size) > maxBytes) return fail('too-large')
|
|
673
|
+
} catch (e) {
|
|
674
|
+
return fail('stat-error')
|
|
675
|
+
}
|
|
676
|
+
try {
|
|
677
|
+
const text = stripBom(readFileSync(path, 'utf8'))
|
|
678
|
+
if (!text.trim()) return fail('empty')
|
|
679
|
+
return { ok: true, text, path, reason: '' }
|
|
680
|
+
} catch (e) {
|
|
681
|
+
return fail('read-error')
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Tier-0 目录生成器(主入口,含 IO)。四类路径**都可能不存在**,逐一路径容错并计数。
|
|
687
|
+
*
|
|
688
|
+
* @param {{userMemoryPath?:string, workspaceMemoryPath?:string, todayLogPath?:string,
|
|
689
|
+
* handoffPlanPath?:string, extraPaths?:Array<{path:string, layer:string}>, [k:string]:any}} input
|
|
690
|
+
* @param {object} [opts] 同 buildTier0CatalogFromTextPre(另加 maxFileBytes)
|
|
691
|
+
* @returns {ReturnType<typeof buildTier0CatalogFromTextPre>}
|
|
692
|
+
*/
|
|
693
|
+
export function buildTier0CatalogPre(input = {}, opts = {}) {
|
|
694
|
+
const cfg = resolveOptsPre(opts)
|
|
695
|
+
const src = input && typeof input === 'object' ? input : {}
|
|
696
|
+
const sources = []
|
|
697
|
+
const skipped = []
|
|
698
|
+
|
|
699
|
+
for (const spec of SOURCE_SPECS) {
|
|
700
|
+
const r = readTextSafePre(src[spec.key], cfg)
|
|
701
|
+
if (!r.ok) {
|
|
702
|
+
skipped.push({ layer: spec.layer, path: String(src[spec.key] == null ? '' : src[spec.key]), reason: r.reason })
|
|
703
|
+
continue
|
|
704
|
+
}
|
|
705
|
+
sources.push({ layer: spec.layer, text: r.text, path: r.path })
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// 扩展点(C5 接反思目录等):显式给出 layer,避免路径猜层。
|
|
709
|
+
const extra = Array.isArray(src.extraPaths) ? src.extraPaths : []
|
|
710
|
+
for (const e of extra) {
|
|
711
|
+
const layer = clean(e && e.layer)
|
|
712
|
+
const r = readTextSafePre(e && e.path, cfg)
|
|
713
|
+
if (!r.ok) {
|
|
714
|
+
skipped.push({ layer, path: String((e && e.path) == null ? '' : e.path), reason: r.reason })
|
|
715
|
+
continue
|
|
716
|
+
}
|
|
717
|
+
sources.push({ layer, text: r.text, path: r.path })
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const out = buildTier0CatalogFromTextPre(sources, cfg)
|
|
721
|
+
const merged = out.skipped.concat(skipped)
|
|
722
|
+
// 目录为空时,空标记必须用**合并后**的跳过数重算(IO 层的跳过发生在纯函数之外)。
|
|
723
|
+
if (!out.items.length) {
|
|
724
|
+
const text = merged.length
|
|
725
|
+
? `[Tier-0 目录为空 · 跳过来源 ${merged.length}]`
|
|
726
|
+
: '[Tier-0 目录为空 · 无可渲染条目]'
|
|
727
|
+
return Object.assign({}, out, {
|
|
728
|
+
text,
|
|
729
|
+
tokens: estimateTokensPre(text, { mode: cfg.estimateMode }),
|
|
730
|
+
tokenRepo: estimateTokensPre(text, { mode: 'repo' }),
|
|
731
|
+
skipped: merged,
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
return Object.assign({}, out, { skipped: merged })
|
|
735
|
+
}
|