feiyu-memory 0.0.0-stage → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * 人读版日记 —— 把 JSONL 渲染成 markdown。
3
+ *
4
+ * ── 为什么是"派生视图"而不是"再存一份" ──
5
+ *
6
+ * 双写两个真相源 = bug 工厂。这条我们已经踩过一次了:
7
+ * 索引原本要落盘,后来砍成纯派生的,就是因为"索引和日记不同步"这类 bug
8
+ * 极难发现、极难复现。
9
+ *
10
+ * 所以这份 markdown 的定位和索引一样:**单向生成、只读、随时可重建**。
11
+ * 唯一的区别是它值得留在磁盘上(用户想直接打开看),而索引留在内存里就够了。
12
+ *
13
+ * 推论:**改它没用,下次对话就被覆盖。** 文件头会写清楚这一点。
14
+ *
15
+ * JSONL 仍然是唯一真相源:追加安全、坏一行只丢一行。
16
+ */
17
+
18
+ import { calendarDaysBetween } from './timeline.mjs';
19
+
20
+ const KINDS = {
21
+ failure: '搞砸了',
22
+ win: '干得不错',
23
+ chat: '闲聊',
24
+ disclosure: '他说了点自己的事',
25
+ grievance: '他不高兴了',
26
+ note: '我自己写的',
27
+ };
28
+
29
+ const pad = (n) => String(n).padStart(2, '0');
30
+
31
+ /** 本地时间的 YYYY-MM-DD */
32
+ function localDate(ts) {
33
+ const d = new Date(ts);
34
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
35
+ }
36
+
37
+ /** 本地时间的 HH:MM */
38
+ function localTime(ts) {
39
+ const d = new Date(ts);
40
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
41
+ }
42
+
43
+ /** 认识多久了,说人话 */
44
+ function humanDuration(fromTs, now) {
45
+ // 自然日,跟注入层、面板用同一把尺子(timeline.mjs)
46
+ const days = calendarDaysBetween(fromTs, now);
47
+ if (days < 1) return '今天刚认识';
48
+ if (days < 30) return `认识 ${days} 天`;
49
+ const months = Math.floor(days / 30);
50
+ if (months < 12) return `认识 ${months} 个月`;
51
+ return `认识 ${Math.floor(days / 365)} 年`;
52
+ }
53
+
54
+ /**
55
+ * 渲染成人读的 markdown。
56
+ *
57
+ * @param {object} archive
58
+ * @param {{now?: number, generatedAt?: number}} [options]
59
+ * @returns {string}
60
+ */
61
+ export function renderDiaryMarkdown(archive, options = {}) {
62
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
63
+ const generatedAt = Number.isFinite(options.generatedAt) ? options.generatedAt : now;
64
+ const diary = Array.isArray(archive?.diary) ? archive.diary : [];
65
+ const rel = archive?.relationship ?? {};
66
+ const out = [];
67
+
68
+ out.push('# 肥鱼日记');
69
+ out.push('');
70
+ out.push('> **这份文件是自动生成的,改它没用** —— 下一次对话就会被覆盖。');
71
+ out.push('> ');
72
+ out.push('> · 想改人格:编辑 `persona.md`');
73
+ out.push('> · 想删某条记忆:编辑 `diary/*.jsonl` 删掉那一行');
74
+ out.push('> · 这里只是给**人**看的窗口,真正的数据是那些 `.jsonl`');
75
+ out.push('');
76
+ out.push(
77
+ `记了 **${diary.length}** 条 · ${humanDuration(Number(archive?.manifest?.created_at) || now, now)}` +
78
+ ` · 生成于 ${localDate(generatedAt)} ${localTime(generatedAt)}`,
79
+ );
80
+ out.push('');
81
+
82
+ // ── 我们 ──
83
+ const relLines = [];
84
+ if (typeof rel.nickname === 'string' && rel.nickname.trim() !== '') {
85
+ relLines.push(`- 他管我叫「${rel.nickname.trim()}」`);
86
+ }
87
+ const traits = (rel.traits ?? [])
88
+ .filter((t) => t && t.value)
89
+ .sort((a, b) => (b.salience ?? 0) - (a.salience ?? 0))
90
+ .slice(0, 8);
91
+ if (traits.length > 0) {
92
+ relLines.push(`- 他:${traits.map((t) => t.value).join(';')}`);
93
+ }
94
+ // 「我还欠着他的」/「已经翻篇的」两行随功能一起删了(2026-10-24)。
95
+
96
+ if (relLines.length > 0) {
97
+ out.push('## 我们');
98
+ out.push('');
99
+ out.push(...relLines);
100
+ out.push('');
101
+ }
102
+
103
+ const lexicon = (archive?.lexicon?.items ?? [])
104
+ .filter((i) => i && typeof i.term === 'string' && (i.salience ?? 0) >= 0.25)
105
+ .sort((a, b) => (b.salience ?? 0) - (a.salience ?? 0))
106
+ .slice(0, 12);
107
+ if (lexicon.length > 0) {
108
+ out.push('## 我最近常说的');
109
+ out.push('');
110
+ out.push(lexicon.map((i) => i.term).join('、'));
111
+ out.push('');
112
+ }
113
+
114
+ if (diary.length === 0) {
115
+ out.push('---');
116
+ out.push('');
117
+ out.push('## 还没有日记');
118
+ out.push('');
119
+ out.push('聊点值得记的事,这里就会有东西了。');
120
+ out.push('');
121
+ return out.join('\n');
122
+ }
123
+
124
+ // ── 日记:按月分组,月内倒序(打开就想看最近的) ──
125
+ const byMonth = new Map();
126
+ for (const entry of diary) {
127
+ const month = (entry.date ?? localDate(entry.created_at ?? now)).slice(0, 7);
128
+ const list = byMonth.get(month) ?? [];
129
+ list.push(entry);
130
+ byMonth.set(month, list);
131
+ }
132
+
133
+ const months = [...byMonth.keys()].sort().reverse();
134
+ for (const month of months) {
135
+ const list = [...(byMonth.get(month) ?? [])].sort(
136
+ (a, b) => (b.created_at ?? 0) - (a.created_at ?? 0),
137
+ );
138
+ const [y, m] = month.split('-');
139
+ out.push('---');
140
+ out.push('');
141
+ out.push(`## ${y} 年 ${Number(m)} 月`);
142
+ out.push('');
143
+
144
+ for (const entry of list) {
145
+ const kind = KINDS[entry.kind] ?? '记一笔';
146
+ const when = entry.created_at ? localTime(entry.created_at) : '';
147
+ const day = entry.date ? Number(entry.date.slice(8, 10)) : '';
148
+ out.push(`### ${day} 日 ${when} · ${kind}`);
149
+ out.push('');
150
+ out.push(String(entry.text ?? '').trim());
151
+ out.push('');
152
+
153
+ const meta = [];
154
+ if (Number.isFinite(entry.salience)) meta.push(`重要度 ${entry.salience.toFixed(2)}`);
155
+ if (Array.isArray(entry.tags) && entry.tags.length > 0) meta.push(entry.tags.join('、'));
156
+ if (entry.kind === 'failure') meta.push(entry.resolved === true ? '已解决' : '还欠着');
157
+ if (typeof entry.id === 'string') meta.push(entry.id);
158
+ if (meta.length > 0) {
159
+ out.push(`<sub>${meta.join(' · ')}</sub>`);
160
+ out.push('');
161
+ }
162
+ }
163
+ }
164
+
165
+ return out.join('\n');
166
+ }
167
+
168
+ /** 给文件用:末尾保证一个换行 */
169
+ export function renderDiaryFile(archive, options) {
170
+ return `${renderDiaryMarkdown(archive, options).trimEnd()}\n`;
171
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * 时间怎么说人话 —— **"一天"和"时间词"的唯一真相源**。
3
+ *
4
+ * ─────────────────────────────────────────────────────────────
5
+ * 这个模块是一次真实事故的产物,别把它拆回去。
6
+ *
7
+ * 有位读者(另一个 DSH 会话)从**渲染输出那一侧**读到了这么一块:
8
+ *
9
+ * 我们:认识 1 天
10
+ * 最近的事:
11
+ * - 昨天:他今天正式给了我一条规矩……
12
+ * - 2 天前:今天他让我介绍自己……
13
+ *
14
+ * 两个毛病,都不是遗忘机制能管的,也不会随时间自愈:
15
+ *
16
+ * 【一】时间词写了两遍
17
+ * 正文里的"今天"(写入时模型写的)+ 前缀里的"昨天:"(渲染时加的)
18
+ * → 读出来是"昨天,他今天……"。人不会这么记事儿。
19
+ *
20
+ * 更难看的是根因在我自己:`consolidate.mjs` 的**正例**里明明白白写着
21
+ * `"text":"今天他让我写个东西……"` —— **我在教模型写"今天"。**
22
+ *
23
+ * 【二】"一天"在一个文件里有三种算法
24
+ * renderRelationship: floor((now − since) / 86400000) ← 整 24 小时
25
+ * dayLabel: round((当日零点差) / 86400000) ← 自然日
26
+ * softWhen: (now − t) / 86400000 ← 带小数
27
+ *
28
+ * 晚上 23:00 认识、次日上午 9:00 看:前一个算出"认识 1 天",
29
+ * 后一个算出"2 天前"。**这俩永远会打架**,因为一把尺子从小时算、
30
+ * 一把从零点算。
31
+ *
32
+ * 所以这里只干一件事:把"一天"定死成**自然日**(人的直觉 ——
33
+ * 前天见的就是"认识 2 天"),所有需要日期的地方都从这儿取。
34
+ * ─────────────────────────────────────────────────────────────
35
+ */
36
+
37
+ /** 某时刻所在那一天的零点 */
38
+ export function startOfDay(ts) {
39
+ const d = new Date(ts);
40
+ d.setHours(0, 0, 0, 0);
41
+ return d.getTime();
42
+ }
43
+
44
+ /**
45
+ * 两个时刻相差几个**自然日**。
46
+ *
47
+ * 这是全项目唯一允许出现的"一天"算法。想加新的日期逻辑,用这个,
48
+ * 别再写第四种。
49
+ */
50
+ export function calendarDaysBetween(from, to) {
51
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return 0;
52
+ return Math.round((startOfDay(to) - startOfDay(from)) / 86400000);
53
+ }
54
+
55
+ /**
56
+ * 「最近的事」用的日期标签:今天 / 昨天 / 3 天前 / 10-05
57
+ *
58
+ * 语气偏准确 —— 这一栏是"我本来就知道的事",说清楚是哪天很正常。
59
+ */
60
+ export function dayLabel(createdAt, now = Date.now()) {
61
+ if (!Number.isFinite(createdAt)) return '';
62
+ const days = calendarDaysBetween(createdAt, now);
63
+ if (days <= 0) return '今天';
64
+ if (days === 1) return '昨天';
65
+ if (days < 7) return `${days} 天前`;
66
+ const d = new Date(createdAt);
67
+ return `${d.getMonth() + 1}-${String(d.getDate()).padStart(2, '0')}`;
68
+ }
69
+
70
+ /**
71
+ * 「突然想起来」用的时间词:前两天 / 前阵子 / 上个月前后 / 很久以前
72
+ *
73
+ * 语气故意含糊 —— 鱼不会说"根据 10 月 12 日的记录"。
74
+ * **但它和 dayLabel 用同一把尺子**(自然日),只是换了个说法。
75
+ * 词汇差异是有意的,算法差异是 bug。
76
+ */
77
+ export function softWhen(createdAt, now = Date.now()) {
78
+ if (!Number.isFinite(createdAt)) return '';
79
+ const days = calendarDaysBetween(createdAt, now);
80
+ if (days <= 0) return '';
81
+ if (days <= 3) return '前两天';
82
+ if (days <= 14) return '前阵子';
83
+ if (days <= 60) return '上个月前后';
84
+ return '很久以前';
85
+ }
86
+
87
+ /**
88
+ * 会跟前缀打架的时间词。
89
+ *
90
+ * 判据很具体:**这些词是在断言"相对于写下它的那一刻"是哪天**,
91
+ * 而前缀断言的又是"相对于现在"。两个断言放在一起就自相矛盾。
92
+ *
93
+ * 刻意**不在**这一列的:
94
+ * - "那天 / 这天" —— 叙述性的,读起来不跟前缀打架
95
+ * - "生日 / 春节" 之类的名词 —— 那是事件不是时点
96
+ */
97
+ const DEICTIC = /(今天|今日|今儿|昨天|昨日|昨儿|前天|前日|今早|今晨|今上午|今晚|昨早|昨晚|刚才|刚刚)/g;
98
+
99
+ /** 句首的时间词要连它后面的顿挫一起吃掉,免得留下一个光秃秃的逗号 */
100
+ const DEICTIC_LEADING =
101
+ /^\s*(?:今天|今日|今儿|昨天|昨日|昨儿|前天|前日|今早|今晨|今上午|今晚|昨早|昨晚|刚才|刚刚)\s*[,,、]*\s*/;
102
+
103
+ /**
104
+ * 把正文里"相对于写入时刻"的时间词摘掉。
105
+ *
106
+ * 为什么在**渲染时**摘、而不是只靠提示词约束:
107
+ *
108
+ * 1. 老档案里已经烤进去一堆"今天",不可能为此重写用户的记忆
109
+ * 2. 模型不可能 100% 听话,得有一道兜底
110
+ * 3. 摘掉之后正文变成"他让我写个东西……",而时间由前缀给 ——
111
+ * **时间只在一处生成**,这才是那个读者要的东西
112
+ *
113
+ * @param {string} text
114
+ * @returns {string} 摘干净之后仍然像人话的正文
115
+ */
116
+ export function stripTimeWords(text) {
117
+ if (typeof text !== 'string') return '';
118
+ const original = text.trim();
119
+ let s = original.replace(DEICTIC_LEADING, '');
120
+ s = s.replace(DEICTIC, '');
121
+ s = s.replace(/^\s*[,,、]+\s*/, '').replace(/\s{2,}/g, ' ').trim();
122
+
123
+ // 兜底:摘没了、或者摘得只剩几个字,就别动它。
124
+ // 宁可留一个多余的"今天",也不要把一条记忆变成空壳。
125
+ if (s.length < 4) return original;
126
+ return s;
127
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 极简 token 估算 —— 用于注入预算裁剪,不追求精确,只要求"不会严重低估"。
3
+ *
4
+ * 设计取舍:
5
+ * - 中日韩字符按 1 字符 ≈ 1 token 计(DeepSeek 系分词器对 CJK 大致如此,略保守)
6
+ * - 拉丁字母/数字按 4 字符 ≈ 1 token 计
7
+ * - 标点与空白按 3 字符 ≈ 1 token 计
8
+ * - 结果向上取整,宁可多算不可少算 —— 预算超了会挤掉系统提示,代价比浪费几十 token 大得多
9
+ *
10
+ * 为什么不用真分词器:会引入依赖,且插件每次注入都要算,纯启发式足够。
11
+ */
12
+
13
+ /** 判断是否 CJK / 全角标点(这些字符通常一个字符一个 token) */
14
+ function isWide(code) {
15
+ return (
16
+ (code >= 0x3000 && code <= 0x303f) || // CJK 标点
17
+ (code >= 0x3040 && code <= 0x30ff) || // 假名
18
+ (code >= 0x3400 && code <= 0x4dbf) || // CJK 扩展 A
19
+ (code >= 0x4e00 && code <= 0x9fff) || // CJK 基本区
20
+ (code >= 0xac00 && code <= 0xd7af) || // 谚文
21
+ (code >= 0xf900 && code <= 0xfaff) || // CJK 兼容
22
+ (code >= 0xff00 && code <= 0xffef) // 全角形式
23
+ );
24
+ }
25
+
26
+ /**
27
+ * 估算一段文本的 token 数。
28
+ * @param {string} text
29
+ * @returns {number} 非负整数
30
+ */
31
+ export function estimateTokens(text) {
32
+ if (!text) return 0;
33
+ let wide = 0;
34
+ let narrow = 0;
35
+ for (const ch of text) {
36
+ const code = ch.codePointAt(0);
37
+ if (isWide(code)) wide += 1;
38
+ else narrow += 1;
39
+ }
40
+ return wide + Math.ceil(narrow / 3.5);
41
+ }
42
+
43
+ /**
44
+ * 按 token 预算截断文本(在字符边界上截,不切断代理对)。
45
+ * 截断处补一个省略号,并保证结果不超过预算。
46
+ *
47
+ * @param {string} text
48
+ * @param {number} budget 最大 token 数
49
+ * @param {string} [ellipsis]
50
+ * @returns {string}
51
+ */
52
+ export function truncateToTokens(text, budget, ellipsis = '……') {
53
+ if (budget <= 0) return '';
54
+ if (estimateTokens(text) <= budget) return text;
55
+
56
+ const ellipsisCost = estimateTokens(ellipsis);
57
+ const room = budget - ellipsisCost;
58
+ if (room <= 0) return truncateToTokens(ellipsis, budget, '');
59
+
60
+ // 二分找最长前缀
61
+ const chars = [...text];
62
+ let lo = 0;
63
+ let hi = chars.length;
64
+ while (lo < hi) {
65
+ const mid = Math.ceil((lo + hi) / 2);
66
+ if (estimateTokens(chars.slice(0, mid).join('')) <= room) lo = mid;
67
+ else hi = mid - 1;
68
+ }
69
+ return chars.slice(0, lo).join('') + ellipsis;
70
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * 零依赖的极小 ZIP 读写 —— `.dshpack` 就是一个 zip。
3
+ *
4
+ * 为什么不装依赖:
5
+ * DSH 插件应该能被人一眼读完、离线审计。为了打个包引入几百 KB 的依赖不值得。
6
+ *
7
+ * 能力边界(刻意的):
8
+ * 写:只写 store(不压缩)。档案本体是文本,压缩收益小,而 store 的格式简单到不会出错。
9
+ * 读:store + deflate 都读(因为别人/别的工具打的包可能是 deflate)。
10
+ * 不支持 Zip64。档案是私人日记,几十 MB 已经是十年量级;超限时明确报错而不是静默截断。
11
+ *
12
+ * 所有 CRC 都实际计算 —— 一个损坏的档案必须能被发现,而不是解出一半乱码。
13
+ */
14
+ import { inflateRawSync, deflateRawSync } from 'node:zlib';
15
+
16
+ const SIG_LOCAL = 0x04034b50;
17
+ const SIG_CENTRAL = 0x02014b50;
18
+ const SIG_EOCD = 0x06054b50;
19
+ const FLAG_UTF8 = 0x0800;
20
+ const METHOD_STORE = 0;
21
+ const METHOD_DEFLATE = 8;
22
+
23
+ const MAX_UINT32 = 0xffffffff;
24
+
25
+ /** CRC-32(IEEE)查表 */
26
+ const CRC_TABLE = (() => {
27
+ const t = new Uint32Array(256);
28
+ for (let i = 0; i < 256; i++) {
29
+ let c = i;
30
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
31
+ t[i] = c >>> 0;
32
+ }
33
+ return t;
34
+ })();
35
+
36
+ /**
37
+ * @param {Uint8Array} bytes
38
+ * @returns {number} 无符号 32 位 CRC
39
+ */
40
+ export function crc32(bytes) {
41
+ let c = 0xffffffff;
42
+ for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
43
+ return (c ^ 0xffffffff) >>> 0;
44
+ }
45
+
46
+ /** 把 Date 编码成 DOS 时间/日期(本地时区,ZIP 传统) */
47
+ function dosDateTime(date) {
48
+ const y = Math.max(1980, date.getFullYear());
49
+ return {
50
+ time: (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1),
51
+ date: ((y - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
52
+ };
53
+ }
54
+
55
+ const encoder = new TextEncoder();
56
+ const decoder = new TextDecoder('utf-8');
57
+
58
+ /**
59
+ * 打一个 zip 包。
60
+ *
61
+ * @param {Array<{name: string, data: string | Uint8Array}>} entries 路径用正斜杠
62
+ * @param {{date?: Date, compress?: boolean}} [options]
63
+ * @returns {Uint8Array}
64
+ */
65
+ export function zipSync(entries, options = {}) {
66
+ const date = options.date ?? new Date();
67
+ const compress = options.compress === true;
68
+ const { time, date: dosDate } = dosDateTime(date);
69
+
70
+ /** @type {Uint8Array[]} */
71
+ const chunks = [];
72
+ /** @type {Array<{nameBytes: Uint8Array, method: number, crc: number, csize: number, usize: number, offset: number, time: number, date: number}>} */
73
+ const central = [];
74
+ let offset = 0;
75
+
76
+ for (const entry of entries) {
77
+ const nameBytes = encoder.encode(entry.name);
78
+ const raw = typeof entry.data === 'string' ? encoder.encode(entry.data) : entry.data;
79
+ const crc = crc32(raw);
80
+
81
+ let method = METHOD_STORE;
82
+ let payload = raw;
83
+ if (compress && raw.length > 256) {
84
+ const deflated = new Uint8Array(deflateRawSync(raw, { level: 6 }));
85
+ if (deflated.length < raw.length) {
86
+ method = METHOD_DEFLATE;
87
+ payload = deflated;
88
+ }
89
+ }
90
+
91
+ if (raw.length > MAX_UINT32 || payload.length > MAX_UINT32) {
92
+ throw new Error(`zipSync: 条目过大,不支持 Zip64:${entry.name}`);
93
+ }
94
+
95
+ const local = new Uint8Array(30 + nameBytes.length);
96
+ const lv = new DataView(local.buffer);
97
+ lv.setUint32(0, SIG_LOCAL, true);
98
+ lv.setUint16(4, 20, true); // version needed
99
+ lv.setUint16(6, FLAG_UTF8, true);
100
+ lv.setUint16(8, method, true);
101
+ lv.setUint16(10, time, true);
102
+ lv.setUint16(12, dosDate, true);
103
+ lv.setUint32(14, crc, true);
104
+ lv.setUint32(18, payload.length, true);
105
+ lv.setUint32(22, raw.length, true);
106
+ lv.setUint16(26, nameBytes.length, true);
107
+ lv.setUint16(28, 0, true); // extra len
108
+ local.set(nameBytes, 30);
109
+
110
+ chunks.push(local, payload);
111
+ central.push({
112
+ nameBytes,
113
+ method,
114
+ crc,
115
+ csize: payload.length,
116
+ usize: raw.length,
117
+ offset,
118
+ time,
119
+ date: dosDate,
120
+ });
121
+ offset += local.length + payload.length;
122
+ }
123
+
124
+ const cdStart = offset;
125
+ for (const e of central) {
126
+ const rec = new Uint8Array(46 + e.nameBytes.length);
127
+ const rv = new DataView(rec.buffer);
128
+ rv.setUint32(0, SIG_CENTRAL, true);
129
+ rv.setUint16(4, 20, true); // version made by
130
+ rv.setUint16(6, 20, true); // version needed
131
+ rv.setUint16(8, FLAG_UTF8, true);
132
+ rv.setUint16(10, e.method, true);
133
+ rv.setUint16(12, e.time, true);
134
+ rv.setUint16(14, e.date, true);
135
+ rv.setUint32(16, e.crc, true);
136
+ rv.setUint32(20, e.csize, true);
137
+ rv.setUint32(24, e.usize, true);
138
+ rv.setUint16(28, e.nameBytes.length, true);
139
+ rv.setUint16(30, 0, true); // extra
140
+ rv.setUint16(32, 0, true); // comment
141
+ rv.setUint16(34, 0, true); // disk start
142
+ rv.setUint16(36, 0, true); // internal attrs
143
+ rv.setUint32(38, 0, true); // external attrs
144
+ rv.setUint32(42, e.offset, true);
145
+ rec.set(e.nameBytes, 46);
146
+ chunks.push(rec);
147
+ offset += rec.length;
148
+ }
149
+
150
+ const cdSize = offset - cdStart;
151
+ const eocd = new Uint8Array(22);
152
+ const ev = new DataView(eocd.buffer);
153
+ ev.setUint32(0, SIG_EOCD, true);
154
+ ev.setUint16(4, 0, true);
155
+ ev.setUint16(6, 0, true);
156
+ ev.setUint16(8, central.length, true);
157
+ ev.setUint16(10, central.length, true);
158
+ ev.setUint32(12, cdSize, true);
159
+ ev.setUint32(16, cdStart, true);
160
+ ev.setUint16(20, 0, true);
161
+ chunks.push(eocd);
162
+
163
+ const total = chunks.reduce((s, c) => s + c.length, 0);
164
+ const out = new Uint8Array(total);
165
+ let p = 0;
166
+ for (const c of chunks) {
167
+ out.set(c, p);
168
+ p += c.length;
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /**
174
+ * 解一个 zip 包。返回值按需解压,条目解压失败会抛错(不静默返回坏数据)。
175
+ *
176
+ * @param {Uint8Array} bytes
177
+ * @returns {Array<{name: string, size: number, bytes: () => Uint8Array, text: () => string}>}
178
+ */
179
+ export function unzipSync(bytes) {
180
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
181
+
182
+ // 从尾部往前找 EOCD(注释最长 65535)
183
+ let eocd = -1;
184
+ const scanFrom = Math.max(0, bytes.length - 22 - 0xffff);
185
+ for (let i = bytes.length - 22; i >= scanFrom; i--) {
186
+ if (view.getUint32(i, true) === SIG_EOCD) {
187
+ eocd = i;
188
+ break;
189
+ }
190
+ }
191
+ if (eocd < 0) throw new Error('unzipSync: 找不到 EOCD,不是有效的 zip');
192
+
193
+ const count = view.getUint16(eocd + 10, true);
194
+ const cdSize = view.getUint32(eocd + 12, true);
195
+ const cdOffset = view.getUint32(eocd + 16, true);
196
+ if (cdOffset === MAX_UINT32 || cdSize === MAX_UINT32 || count === 0xffff) {
197
+ throw new Error('unzipSync: 疑似 Zip64,不支持');
198
+ }
199
+ if (cdOffset + cdSize > bytes.length) throw new Error('unzipSync: 中央目录越界,档案已损坏');
200
+
201
+ const out = [];
202
+ let p = cdOffset;
203
+ for (let i = 0; i < count; i++) {
204
+ if (view.getUint32(p, true) !== SIG_CENTRAL) throw new Error('unzipSync: 中央目录条目签名错误');
205
+ const method = view.getUint16(p + 10, true);
206
+ const crc = view.getUint32(p + 16, true);
207
+ const csize = view.getUint32(p + 20, true);
208
+ const usize = view.getUint32(p + 24, true);
209
+ const nameLen = view.getUint16(p + 28, true);
210
+ const extraLen = view.getUint16(p + 30, true);
211
+ const commentLen = view.getUint16(p + 32, true);
212
+ const localOffset = view.getUint32(p + 42, true);
213
+ const name = decoder.decode(bytes.subarray(p + 46, p + 46 + nameLen));
214
+ p += 46 + nameLen + extraLen + commentLen;
215
+
216
+ const readBytes = () => {
217
+ if (view.getUint32(localOffset, true) !== SIG_LOCAL) {
218
+ throw new Error(`unzipSync: 本地头签名错误:${name}`);
219
+ }
220
+ const lNameLen = view.getUint16(localOffset + 26, true);
221
+ const lExtraLen = view.getUint16(localOffset + 28, true);
222
+ const dataStart = localOffset + 30 + lNameLen + lExtraLen;
223
+ if (dataStart + csize > bytes.length) throw new Error(`unzipSync: 数据越界:${name}`);
224
+ const raw = bytes.subarray(dataStart, dataStart + csize);
225
+
226
+ let data;
227
+ if (method === METHOD_STORE) data = raw;
228
+ else if (method === METHOD_DEFLATE) data = new Uint8Array(inflateRawSync(raw));
229
+ else throw new Error(`unzipSync: 不支持的压缩方法 ${method}:${name}`);
230
+
231
+ if (data.length !== usize) throw new Error(`unzipSync: 长度不符:${name}`);
232
+ if (crc32(data) !== crc) throw new Error(`unzipSync: CRC 校验失败,档案已损坏:${name}`);
233
+ return data;
234
+ };
235
+
236
+ out.push({ name, size: usize, bytes: readBytes, text: () => decoder.decode(readBytes()) });
237
+ }
238
+ return out;
239
+ }