@kira905/dsh-memory-toolkit 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +130 -1
- package/README.en.md +225 -2
- package/README.md +208 -2
- package/cordis.patch.yml +32 -0
- package/lib/deploy-guard.mjs +463 -275
- package/lib/guard-http.mjs +59 -62
- package/lib/pitfall-form-map.mjs +94 -84
- package/lib/pitfall-score.mjs +262 -185
- package/lib/session-file-name.mjs +116 -100
- package/lib/zstd-frames.mjs +88 -67
- package/memory-db-tools/README.md +29 -1
- package/memory-db-tools/audit-analyze.mjs +1 -1
- package/memory-db-tools/audit-export.mjs +60 -16
- package/memory-db-tools/audit-struct-probe.mjs +29 -8
- package/memory-db-tools/lib/known-devices.mjs +137 -0
- package/memory-engine/README.md +58 -4
- package/memory-engine/bench/bench.mjs +1 -1
- package/memory-engine/cli/band-sentinel.mjs +164 -0
- package/memory-engine/cli/distill-rollback.mjs +112 -44
- package/memory-engine/cli/distill-sessions.mjs +237 -0
- package/memory-engine/cli/distill.mjs +196 -95
- package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
- package/memory-engine/cli/doc-index.mjs +47 -38
- package/memory-engine/cli/domain-tags.mjs +246 -162
- package/memory-engine/cli/layer-migrate.mjs +176 -0
- package/memory-engine/cli/layer-move.mjs +189 -0
- package/memory-engine/cli/pitfall-hook.mjs +136 -70
- package/memory-engine/cli/recall.mjs +243 -110
- package/memory-engine/cli/selfcheck.mjs +93 -44
- package/memory-engine/cli/serve-start.mjs +221 -140
- package/memory-engine/cli/sweep.mjs +83 -44
- package/memory-engine/cli/sync.mjs +93 -57
- package/memory-engine/config/audit-devices.json +6 -0
- package/memory-engine/config/bands.json +7 -0
- package/memory-engine/config/layers.json +7 -0
- package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
- package/memory-engine/dict/lexicon-config.json +11 -0
- package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
- package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
- package/memory-engine/lib/band-sentinel.mjs +417 -0
- package/memory-engine/lib/cli-strict.mjs +128 -0
- package/memory-engine/lib/cli-util.mjs +147 -125
- package/memory-engine/lib/config.mjs +83 -96
- package/memory-engine/lib/dirty-ledger.mjs +127 -94
- package/memory-engine/lib/distill-rollback.mjs +173 -133
- package/memory-engine/lib/distill.mjs +878 -585
- package/memory-engine/lib/doc-index-store.mjs +208 -235
- package/memory-engine/lib/doc-index.mjs +351 -350
- package/memory-engine/lib/doc-scan.mjs +178 -156
- package/memory-engine/lib/doc-summarize.mjs +220 -205
- package/memory-engine/lib/domain-tags.mjs +358 -277
- package/memory-engine/lib/embedder.mjs +175 -194
- package/memory-engine/lib/hashes.mjs +22 -7
- package/memory-engine/lib/layer-migrate.mjs +330 -0
- package/memory-engine/lib/layer-move.mjs +307 -0
- package/memory-engine/lib/layers.mjs +191 -0
- package/memory-engine/lib/lexicon-recall.mjs +473 -0
- package/memory-engine/lib/memory-source.mjs +78 -80
- package/memory-engine/lib/pitfall-source.mjs +115 -114
- package/memory-engine/lib/policy-source.mjs +279 -261
- package/memory-engine/lib/recall-log.mjs +285 -0
- package/memory-engine/lib/recall.mjs +565 -240
- package/memory-engine/lib/selfcheck.mjs +142 -106
- package/memory-engine/lib/session-distill.mjs +603 -0
- package/memory-engine/lib/session-source.mjs +421 -0
- package/memory-engine/lib/sweep.mjs +107 -130
- package/memory-engine/lib/sync.mjs +110 -140
- package/memory-engine/lib/tick-hook.mjs +123 -148
- package/memory-engine/lib/vectors-store.mjs +213 -188
- package/memory-engine/test/12-layering.test.mjs +539 -0
- package/memory-engine/test/13-session-distill.test.mjs +616 -0
- package/memory-engine/tools/bridge-smoke.mjs +94 -55
- package/memory-engine/tools/fetch-model.mjs +167 -87
- package/memory-engine/tools/smoke-recall.mjs +94 -38
- package/memory-engine/tools/verify-recall-chain.mjs +413 -0
- package/package.json +11 -1
|
@@ -1,21 +1,20 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 域标签补齐 —— 给存量记忆条追加「家庭 / 工作 / 健康 / 技术 / DSH生态 / 梗」这几类域标签
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// ·
|
|
5
|
-
// · 幂等键 =
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// ·
|
|
9
|
-
// ·
|
|
10
|
-
// ·
|
|
11
|
-
// ·
|
|
12
|
-
//
|
|
13
|
-
// 判不出归属的条 **跳过并记账**(宁可漏,不可错送)
|
|
3
|
+
// 口径(每条都有出处,动之前先看这里):
|
|
4
|
+
// · 域标签是**追加**到 tags 尾部,绝不覆盖原有标签;
|
|
5
|
+
// · 幂等键 = (条 id, 域标签集哈希):写之前逐个 token 判重,已含就跳过。
|
|
6
|
+
// 别拿「批次游标」当幂等键 —— 那样重复执行会把同一批标签一遍遍往 tags 上堆;
|
|
7
|
+
// · 动手前先 VACUUM INTO 快照,写坏了可以整表还原;
|
|
8
|
+
// · 并发:busy_timeout + 最多 3 次重试,退避累计封顶 60 秒;
|
|
9
|
+
// · 成本闸:每日 ≤20 次调用、≤150K token,触顶即停(不重试)并记账;
|
|
10
|
+
// · 分批跑、可中断续跑(台账落 state 文件,逐条幂等);
|
|
11
|
+
// · 从严口径:只有 owner_user == 本机 owner 的条才送分类模型,判不出归属的跳过并记账 ——
|
|
12
|
+
// 宁可漏标,不可错送。
|
|
14
13
|
//
|
|
15
|
-
//
|
|
16
|
-
// ①
|
|
17
|
-
// ②
|
|
18
|
-
//
|
|
14
|
+
// 两个刻意的决定(防止将来「照字面改」踩坑):
|
|
15
|
+
// ① 模型输出只用来追加标签、不产生别的写入 ⇒ 半完成态无害;
|
|
16
|
+
// ② 追加标签不动 updated_at —— 否则这批老条会被顶到召回段最前,
|
|
17
|
+
// 提炼窗口也会把它们误判成「新内容」。
|
|
19
18
|
import fs from 'node:fs';
|
|
20
19
|
import path from 'node:path';
|
|
21
20
|
import os from 'node:os';
|
|
@@ -23,30 +22,30 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
23
22
|
|
|
24
23
|
// ── 常量 ────────────────────────────────────────────────────────────────────
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
// 域标签集(显示名;落库前统一小写)
|
|
27
26
|
export const DOMAIN_TAGS = Object.freeze(['家庭', '工作', '健康', '技术', 'DSH生态', '梗']);
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
export
|
|
31
|
-
return String(s ?? '').trim().toLowerCase().replace(/\s+/g, '-');
|
|
32
|
-
}
|
|
28
|
+
// 显示名 → 落库 token;tags 一律按插件那条 normalizeTags 的口径小写化(DSH生态 → dsh生态)
|
|
29
|
+
export const normalizeTag = (s) => String(s ?? '').trim().toLowerCase().replace(/\s+/g, '-');
|
|
33
30
|
|
|
34
|
-
|
|
31
|
+
// 显示名 → 规范 token 的查表(兼容大小写与多余空白的输入写法)
|
|
35
32
|
export const DOMAIN_TOKEN_BY_LABEL = Object.freeze(
|
|
36
33
|
Object.fromEntries(DOMAIN_TAGS.map((label) => [label, normalizeTag(label)])),
|
|
37
34
|
);
|
|
38
|
-
export const DOMAIN_TOKENS = Object.freeze(DOMAIN_TAGS.map((
|
|
35
|
+
export const DOMAIN_TOKENS = Object.freeze(DOMAIN_TAGS.map((label) => normalizeTag(label)));
|
|
39
36
|
|
|
40
|
-
|
|
37
|
+
// 成本闸默认值(与提炼那条链同一档)
|
|
41
38
|
export const COST_LIMITS = Object.freeze({ callsPerDay: 20, tokensPerDay: 150000 });
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
export const RETRY = Object.freeze({
|
|
40
|
+
// 并发保护默认值
|
|
41
|
+
export const RETRY = Object.freeze({
|
|
42
|
+
busyTimeoutMs: 5000,
|
|
43
|
+
maxAttempts: 3,
|
|
44
|
+
backoffMs: [1000, 4000, 16000],
|
|
45
|
+
totalCapMs: 60000,
|
|
46
|
+
});
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
* 分类模型默认档(**2026-09-21 主人拍板:记忆线一律走本鱼链**;真源 = 方案 §3.5 订正注 2)。
|
|
48
|
-
* 不用 `deepseek-v4-pro`(贵 4.4 倍,分类任务不需要强推理);不许改回第三方(D2 延长效:新增外发面须先拍)。
|
|
49
|
-
*/
|
|
48
|
+
// 分类模型默认档:记忆线统一走本鱼链,不用更贵的强推理档,也不许改回第三方
|
|
50
49
|
export const DEFAULT_LLM = Object.freeze({
|
|
51
50
|
provider: 'deepseek',
|
|
52
51
|
baseUrl: 'https://api.deepseek.com',
|
|
@@ -60,256 +59,311 @@ export const STATE_SCHEMA = 1;
|
|
|
60
59
|
|
|
61
60
|
// ── 小工具 ──────────────────────────────────────────────────────────────────
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
const FNV_OFFSET = 0x811c9dc5;
|
|
63
|
+
const FNV_PRIME = 0x01000193;
|
|
64
|
+
const FIELD_SEP = 0x2c; // 分隔符:防 "ab"+"c" 与 "a"+"bc" 撞成同一个哈希
|
|
65
|
+
|
|
66
|
+
// 域标签集 → 短哈希(幂等键的右半)。轻量 FNV-1a:只用来比「集合变没变」,不需要密码学强度。
|
|
67
|
+
export const tagSetHash = (tokens) => {
|
|
65
68
|
const sorted = [...new Set(tokens.map(normalizeTag).filter(Boolean))].sort();
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
h ^= t.charCodeAt(i);
|
|
71
|
-
h = Math.imul(h, 0x01000193) >>> 0;
|
|
69
|
+
let h = FNV_OFFSET;
|
|
70
|
+
for (const token of sorted) {
|
|
71
|
+
for (let i = 0; i < token.length; i += 1) {
|
|
72
|
+
h = Math.imul(h ^ token.charCodeAt(i), FNV_PRIME) >>> 0;
|
|
72
73
|
}
|
|
73
|
-
h
|
|
74
|
-
h = Math.imul(h, 0x01000193) >>> 0;
|
|
74
|
+
h = Math.imul(h ^ FIELD_SEP, FNV_PRIME) >>> 0;
|
|
75
75
|
}
|
|
76
76
|
return `fnv1a:${h.toString(16).padStart(8, '0')}`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
export
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// tags 文本 → token 数组(空白分隔,与插件落库形态一致)
|
|
80
|
+
export const tokensOf = (tags) => String(tags ?? '').split(/\s+/).map((t) => t.trim()).filter(Boolean);
|
|
81
|
+
|
|
82
|
+
// 该条已经带了哪些域标签:token 级精确比较(`工作记录` 不算带了 `工作`)
|
|
83
|
+
export const domainTokensIn = (tags) => {
|
|
84
|
+
const present = new Set(tokensOf(tags));
|
|
85
|
+
return DOMAIN_TOKENS.filter((token) => present.has(token));
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// 输入写法 → 规范 token;不在固定集合里的一律丢(模型自创词不落库)
|
|
89
|
+
const resolveDomainToken = (raw) => {
|
|
90
|
+
const label = String(raw ?? '').trim();
|
|
91
|
+
const direct = DOMAIN_TOKEN_BY_LABEL[label];
|
|
92
|
+
if (direct) return direct;
|
|
93
|
+
const normalized = normalizeTag(label);
|
|
94
|
+
return DOMAIN_TOKENS.find((token) => token === normalized) ?? null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// 追加域标签(幂等):只往尾部补缺席的那些,返回新文本 + 实际新增 / 已存在的清单
|
|
98
|
+
export const appendDomainTags = (existingTags, domainLabelsOrTokens) => {
|
|
95
99
|
const tokens = tokensOf(existingTags);
|
|
96
100
|
const present = new Set(tokens);
|
|
97
101
|
const wanted = [];
|
|
102
|
+
const picked = new Set();
|
|
98
103
|
for (const raw of domainLabelsOrTokens) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
104
|
+
const token = resolveDomainToken(raw);
|
|
105
|
+
if (!token || picked.has(token)) continue;
|
|
106
|
+
picked.add(token);
|
|
107
|
+
wanted.push(token);
|
|
103
108
|
}
|
|
104
|
-
const added = wanted.filter((
|
|
105
|
-
const
|
|
106
|
-
return { tags:
|
|
107
|
-
}
|
|
109
|
+
const added = wanted.filter((token) => !present.has(token));
|
|
110
|
+
const alreadyPresent = wanted.filter((token) => present.has(token));
|
|
111
|
+
return { tags: tokens.concat(added).join(' '), added, alreadyPresent, wanted };
|
|
112
|
+
};
|
|
108
113
|
|
|
109
|
-
// ──
|
|
114
|
+
// ── 库操作 ──────────────────────────────────────────────────────────────────
|
|
110
115
|
|
|
111
|
-
|
|
112
|
-
export
|
|
113
|
-
// ⚠️ node:sqlite 的第二参**不接受 undefined**("options argument must be an object")⇒ 分支显式构造
|
|
116
|
+
// 打开库:默认只读;write:true 时带 busy_timeout。node:sqlite 的第二参不吃 undefined ⇒ 分支显式构造
|
|
117
|
+
export const openDb = (dbPath, { write = false } = {}) => {
|
|
114
118
|
const db = write ? new DatabaseSync(dbPath) : new DatabaseSync(dbPath, { readOnly: true });
|
|
115
119
|
if (write) db.exec(`PRAGMA busy_timeout = ${RETRY.busyTimeoutMs}`);
|
|
116
120
|
return db;
|
|
117
|
-
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// 一次事务尝试:BEGIN → 体 → COMMIT;体里或提交时出错都回滚,并把原始错误往外抛
|
|
124
|
+
const attemptTransaction = (db, fn) => {
|
|
125
|
+
db.exec('BEGIN IMMEDIATE');
|
|
126
|
+
try {
|
|
127
|
+
const value = fn();
|
|
128
|
+
db.exec('COMMIT');
|
|
129
|
+
return value;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
try {
|
|
132
|
+
db.exec('ROLLBACK');
|
|
133
|
+
} catch {
|
|
134
|
+
// 回滚也失败就保持原始错误(第二个错误没有诊断价值)
|
|
135
|
+
}
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
118
139
|
|
|
119
140
|
/**
|
|
120
|
-
*
|
|
141
|
+
* 带重试的写事务执行器:busy 才等,其它错误立刻收手;超限不抛错,返回 {ok:false, error}。
|
|
121
142
|
* @param {object} db - 写连接
|
|
122
143
|
* @param {() => any} fn - 事务体
|
|
123
|
-
* @param {{sleep?:Function, now?:Function}} [deps] -
|
|
124
|
-
* @returns {{ok:boolean, value?:any, attempts:number, error?:string, waitedMs:number}}
|
|
144
|
+
* @param {{sleep?:Function, now?:Function}} [deps] - 单测注入用(不真等)
|
|
125
145
|
*/
|
|
126
|
-
export async
|
|
127
|
-
const
|
|
128
|
-
const
|
|
146
|
+
export const withBusyRetry = async (db, fn, deps = {}) => {
|
|
147
|
+
const nap = deps.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
148
|
+
const t0 = Date.now();
|
|
129
149
|
let waited = 0;
|
|
130
|
-
let
|
|
150
|
+
let lastError = null;
|
|
151
|
+
|
|
131
152
|
for (let attempt = 1; attempt <= RETRY.maxAttempts; attempt += 1) {
|
|
132
153
|
try {
|
|
133
|
-
db
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚失败 */ }
|
|
140
|
-
throw e;
|
|
141
|
-
}
|
|
142
|
-
} catch (e) {
|
|
143
|
-
lastErr = e;
|
|
144
|
-
const msg = String(e && e.message ? e.message : e);
|
|
145
|
-
const busy = /busy|locked/i.test(msg);
|
|
146
|
-
if (!busy || attempt === RETRY.maxAttempts) break;
|
|
154
|
+
return { ok: true, value: attemptTransaction(db, fn), attempts: attempt, waitedMs: waited };
|
|
155
|
+
} catch (err) {
|
|
156
|
+
lastError = err;
|
|
157
|
+
const text = String(err && err.message ? err.message : err);
|
|
158
|
+
if (!/busy|locked/i.test(text) || attempt === RETRY.maxAttempts) break;
|
|
159
|
+
|
|
147
160
|
const backoff = RETRY.backoffMs[Math.min(attempt - 1, RETRY.backoffMs.length - 1)];
|
|
148
|
-
|
|
149
|
-
|
|
161
|
+
const wouldOverrun = waited + backoff > RETRY.totalCapMs || Date.now() - t0 + backoff > RETRY.totalCapMs;
|
|
162
|
+
if (wouldOverrun) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
attempts: attempt,
|
|
166
|
+
error: `${text}(退避将超 ${RETRY.totalCapMs}ms 上限,停止重试)`,
|
|
167
|
+
waitedMs: waited,
|
|
168
|
+
};
|
|
150
169
|
}
|
|
151
|
-
await
|
|
170
|
+
await nap(backoff);
|
|
152
171
|
waited += backoff;
|
|
153
172
|
}
|
|
154
173
|
}
|
|
155
|
-
return { ok: false, attempts: RETRY.maxAttempts, error: String(lastErr && lastErr.message ? lastErr.message : lastErr), waitedMs: waited };
|
|
156
|
-
}
|
|
157
174
|
|
|
158
|
-
|
|
159
|
-
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
attempts: RETRY.maxAttempts,
|
|
178
|
+
error: String(lastError && lastError.message ? lastError.message : lastError),
|
|
179
|
+
waitedMs: waited,
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// 读待处理条目(只读连接):老库没有 owner_user 列时如实标记,由调用方按本机 owner 处理
|
|
184
|
+
export const readCandidates = (dbPath, { limit = 0, maxCharsPerItem = 240 } = {}) => {
|
|
160
185
|
const db = openDb(dbPath);
|
|
161
186
|
try {
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
const rows = db.prepare(`SELECT ${
|
|
166
|
-
const items = rows.map((
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
187
|
+
const columns = db.prepare('PRAGMA table_info(memories)').all().map((row) => row.name);
|
|
188
|
+
const withOwner = columns.includes('owner_user');
|
|
189
|
+
const projection = withOwner ? 'id, text, tags, owner_user, visibility' : 'id, text, tags';
|
|
190
|
+
const rows = db.prepare(`SELECT ${projection} FROM memories ORDER BY id`).all();
|
|
191
|
+
const items = rows.map((row) => {
|
|
192
|
+
const text = String(row.text ?? '');
|
|
193
|
+
return {
|
|
194
|
+
id: row.id,
|
|
195
|
+
text,
|
|
196
|
+
tags: String(row.tags ?? ''),
|
|
197
|
+
ownerUser: withOwner ? row.owner_user : null,
|
|
198
|
+
visibility: withOwner ? row.visibility : null,
|
|
199
|
+
textTruncated: text.length > maxCharsPerItem,
|
|
200
|
+
textForModel: text.slice(0, maxCharsPerItem),
|
|
201
|
+
existingDomains: domainTokensIn(row.tags),
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
return { items: limit > 0 ? items.slice(0, limit) : items, ownerColumn: withOwner, columns };
|
|
177
205
|
} finally {
|
|
178
206
|
db.close();
|
|
179
207
|
}
|
|
180
|
-
}
|
|
208
|
+
};
|
|
181
209
|
|
|
182
|
-
// ──
|
|
210
|
+
// ── 凭证与模型调用 ──────────────────────────────────────────────────────────
|
|
183
211
|
|
|
184
|
-
|
|
185
|
-
export
|
|
212
|
+
// 从 .credentials.yaml 读一个键:去引号、去行尾注释与 flow-map 逗号
|
|
213
|
+
export const readCredential = (keyName, credFile = path.join(os.homedir(), '.dsh', '.credentials.yaml')) => {
|
|
214
|
+
let text;
|
|
186
215
|
try {
|
|
187
|
-
|
|
188
|
-
const m = text.match(new RegExp(`^\\s*${keyName}\\s*:\\s*(.+?)\\s*$`, 'm'));
|
|
189
|
-
if (!m) return null;
|
|
190
|
-
let v = m[1].trim();
|
|
191
|
-
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
192
|
-
v = v.replace(/\s+#.*$/, '').replace(/,\s*$/, '').trim();
|
|
193
|
-
return v.length > 0 ? v : null;
|
|
216
|
+
text = fs.readFileSync(credFile, 'utf8');
|
|
194
217
|
} catch {
|
|
195
218
|
return null;
|
|
196
219
|
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
220
|
+
const hit = text.match(new RegExp(`^\\s*${keyName}\\s*:\\s*(.+?)\\s*$`, 'm'));
|
|
221
|
+
if (!hit) return null;
|
|
222
|
+
|
|
223
|
+
let value = hit[1].trim();
|
|
224
|
+
const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"));
|
|
225
|
+
if (quoted) value = value.slice(1, -1);
|
|
226
|
+
value = value.replace(/\s+#.*$/, '').replace(/,\s*$/, '').trim();
|
|
227
|
+
return value.length > 0 ? value : null;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// 分类提示词(system):只许从固定集合里挑,只许输出 JSON
|
|
231
|
+
export const buildSystemPrompt = () => [
|
|
232
|
+
'你是一个中文记忆条目的域分类器。给定若干条记忆(编号 + 正文),为每条从下列固定集合里选出适用的域标签:',
|
|
233
|
+
`集合:${DOMAIN_TAGS.join(' / ')}`,
|
|
234
|
+
'规则:',
|
|
235
|
+
'1. 每条可给 0~3 个标签;不适用就返回空数组,不要硬凑。',
|
|
236
|
+
'2. 只允许上面集合里的标签,逐字照抄(含大小写),不得自创、不得翻译、不得加解释。',
|
|
237
|
+
'3. 判据看正文主旨:家人/家庭事务→家庭;工作/职场/公司/项目交付→工作;身体/睡眠/就医/健康习惯→健康;',
|
|
238
|
+
' 编程/系统/工具/踩坑/技术方案→技术;DSH 生态自身的机制/插件/大管家/同步/生图栈→DSH生态;',
|
|
239
|
+
' 段子/梗/玩笑/表情包/口癖→梗。一条可同时属技术与DSH生态。',
|
|
240
|
+
`4. 输出**严格 JSON**,形如 {"items":[{"id":6,"domains":["技术"]}, {"id":7,"domains":[]}]};`,
|
|
241
|
+
' 不要输出 markdown 代码围栏、不要输出解释文字、不要遗漏输入的每一条。',
|
|
242
|
+
].join('\n');
|
|
243
|
+
|
|
244
|
+
// 分类提示词(user 侧):批量正文,一行一条
|
|
245
|
+
export const buildUserPrompt = (items) => {
|
|
246
|
+
const lines = items.map((item) => `[${item.id}] ${item.textForModel.replace(/\s+/g, ' ').trim()}`);
|
|
218
247
|
return `共 ${items.length} 条,请逐条分类并按要求输出 JSON:\n\n${lines.join('\n')}`;
|
|
219
|
-
}
|
|
248
|
+
};
|
|
220
249
|
|
|
221
|
-
|
|
222
|
-
export
|
|
250
|
+
// 从模型回复里抠 JSON:容忍代码围栏与前后废话;抠不出来就 null(不抛)
|
|
251
|
+
export const extractJson = (content) => {
|
|
223
252
|
const text = String(content ?? '').trim();
|
|
224
253
|
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
const
|
|
228
|
-
if (
|
|
254
|
+
const payload = fenced ? fenced[1] : text;
|
|
255
|
+
const head = payload.indexOf('{');
|
|
256
|
+
const tail = payload.lastIndexOf('}');
|
|
257
|
+
if (head < 0 || tail <= head) return null;
|
|
229
258
|
try {
|
|
230
|
-
return JSON.parse(
|
|
259
|
+
return JSON.parse(payload.slice(head, tail + 1));
|
|
231
260
|
} catch {
|
|
232
261
|
return null;
|
|
233
262
|
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// 呼叫体:分类任务不需要思考链,显式关掉省 token
|
|
266
|
+
const classifierRequestBody = ({ model, items, maxTokens }) => ({
|
|
267
|
+
model,
|
|
268
|
+
messages: [
|
|
269
|
+
{ role: 'system', content: buildSystemPrompt() },
|
|
270
|
+
{ role: 'user', content: buildUserPrompt(items) },
|
|
271
|
+
],
|
|
272
|
+
thinking: { type: 'disabled' },
|
|
273
|
+
temperature: 0.1,
|
|
274
|
+
max_tokens: maxTokens,
|
|
275
|
+
stream: false,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/** 调一次分类模型。`fetchImpl` 可注入 ⇒ 单测不真发网络。 */
|
|
279
|
+
export const callClassifier = async ({
|
|
280
|
+
items,
|
|
281
|
+
apiKey,
|
|
282
|
+
baseUrl = DEFAULT_LLM.baseUrl,
|
|
283
|
+
model = DEFAULT_LLM.model,
|
|
284
|
+
fetchImpl = globalThis.fetch,
|
|
285
|
+
timeoutMs = DEFAULT_LLM.timeoutMs,
|
|
286
|
+
maxTokens = DEFAULT_LLM.maxTokens,
|
|
287
|
+
}) => {
|
|
288
|
+
const abort = new AbortController();
|
|
289
|
+
const timer = setTimeout(() => abort.abort(), timeoutMs);
|
|
290
|
+
|
|
252
291
|
try {
|
|
253
292
|
const res = await fetchImpl(`${baseUrl}/chat/completions`, {
|
|
254
293
|
method: 'POST',
|
|
255
294
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
256
|
-
body: JSON.stringify(
|
|
257
|
-
signal:
|
|
295
|
+
body: JSON.stringify(classifierRequestBody({ model, items, maxTokens })),
|
|
296
|
+
signal: abort.signal,
|
|
258
297
|
});
|
|
298
|
+
|
|
259
299
|
const text = await res.text();
|
|
260
300
|
if (!res.ok) return { ok: false, error: `HTTP ${res.status}: ${text.slice(0, 300)}` };
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
301
|
+
|
|
302
|
+
let envelope;
|
|
303
|
+
try {
|
|
304
|
+
envelope = JSON.parse(text);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
return { ok: false, error: `响应不是 JSON:${err.message}`, raw: text.slice(0, 300) };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const content = envelope?.choices?.[0]?.message?.content ?? '';
|
|
310
|
+
const usage = envelope?.usage ?? {};
|
|
265
311
|
const parsed = extractJson(content);
|
|
266
312
|
if (!parsed) return { ok: false, error: '模型输出里找不到可解析的 JSON', raw: String(content).slice(0, 300), usage };
|
|
267
|
-
|
|
313
|
+
|
|
314
|
+
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.items) ? parsed.items : null;
|
|
268
315
|
if (!list) return { ok: false, error: 'JSON 结构不含 items 数组', raw: String(content).slice(0, 300), usage };
|
|
269
316
|
return { ok: true, items: list, usage, content };
|
|
270
|
-
} catch (
|
|
271
|
-
return { ok: false, error: `请求失败:${
|
|
317
|
+
} catch (err) {
|
|
318
|
+
return { ok: false, error: `请求失败:${err && err.message ? err.message : err}` };
|
|
272
319
|
} finally {
|
|
273
320
|
clearTimeout(timer);
|
|
274
321
|
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
export
|
|
279
|
-
const
|
|
280
|
-
return Math.ceil(
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// ── state
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// 保守估算一次批量调用的 token(中文约 1.4 字/token,另留输出余量),调用前用它预判成本闸
|
|
325
|
+
export const estimateTokens = (items) => {
|
|
326
|
+
const bodyChars = items.reduce((sum, item) => sum + item.textForModel.length + 8, 0);
|
|
327
|
+
return Math.ceil((bodyChars + buildSystemPrompt().length) / 1.4) + 300;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// ── state:可中断续跑 + 记账 ────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
const zeroCost = (day) => ({ date: day, calls: 0, promptTokens: 0, completionTokens: 0 });
|
|
333
|
+
|
|
334
|
+
export const defaultState = (dbPath, nowIso) => ({
|
|
335
|
+
schemaVersion: STATE_SCHEMA,
|
|
336
|
+
dbPath,
|
|
337
|
+
updatedAt: nowIso,
|
|
338
|
+
cost: zeroCost(String(nowIso).slice(0, 10)),
|
|
339
|
+
processed: {},
|
|
340
|
+
skipped: {},
|
|
341
|
+
stats: { batches: 0, tagged: 0, noTag: 0, skippedOwner: 0, skippedUnknownDomain: 0, runs: 0 },
|
|
342
|
+
lastDailyLine: null,
|
|
343
|
+
});
|
|
297
344
|
|
|
298
|
-
export
|
|
345
|
+
export const readState = (stateFile, dbPath, nowIso) => {
|
|
346
|
+
const day = String(nowIso).slice(0, 10);
|
|
347
|
+
const fresh = defaultState(dbPath, nowIso);
|
|
348
|
+
let stored;
|
|
299
349
|
try {
|
|
300
|
-
if (!fs.existsSync(stateFile)) return
|
|
301
|
-
|
|
302
|
-
const base = defaultState(dbPath, nowIso);
|
|
303
|
-
const merged = { ...base, ...s, cost: { ...base.cost, ...(s.cost ?? {}) }, stats: { ...base.stats, ...(s.stats ?? {}) } };
|
|
304
|
-
// 跨日 ⇒ 当日成本归零(成本闸是"每日"口径)
|
|
305
|
-
if (merged.cost.date !== String(nowIso).slice(0, 10)) merged.cost = { date: String(nowIso).slice(0, 10), calls: 0, promptTokens: 0, completionTokens: 0 };
|
|
306
|
-
return merged;
|
|
350
|
+
if (!fs.existsSync(stateFile)) return fresh;
|
|
351
|
+
stored = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
307
352
|
} catch {
|
|
308
|
-
return
|
|
353
|
+
return fresh; // 坏文件回落默认,绝不抛
|
|
309
354
|
}
|
|
310
|
-
|
|
355
|
+
const merged = {
|
|
356
|
+
...fresh,
|
|
357
|
+
...stored,
|
|
358
|
+
cost: { ...fresh.cost, ...(stored.cost ?? {}) },
|
|
359
|
+
stats: { ...fresh.stats, ...(stored.stats ?? {}) },
|
|
360
|
+
};
|
|
361
|
+
// 跨日 ⇒ 当日成本归零(成本闸是「每日」口径)
|
|
362
|
+
if (merged.cost.date !== day) merged.cost = zeroCost(day);
|
|
363
|
+
return merged;
|
|
364
|
+
};
|
|
311
365
|
|
|
312
|
-
export
|
|
366
|
+
export const writeState = (stateFile, state) => {
|
|
313
367
|
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
314
368
|
const tmp = `${stateFile}.tmp-${process.pid}`;
|
|
315
369
|
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
@@ -317,27 +371,31 @@ export function writeState(stateFile, state) {
|
|
|
317
371
|
fs.renameSync(tmp, stateFile);
|
|
318
372
|
} catch {
|
|
319
373
|
fs.copyFileSync(tmp, stateFile);
|
|
320
|
-
try {
|
|
374
|
+
try {
|
|
375
|
+
fs.unlinkSync(tmp);
|
|
376
|
+
} catch {
|
|
377
|
+
// 临时件删不掉也无害
|
|
378
|
+
}
|
|
321
379
|
}
|
|
322
380
|
return stateFile;
|
|
323
|
-
}
|
|
381
|
+
};
|
|
324
382
|
|
|
325
|
-
|
|
326
|
-
export
|
|
327
|
-
const usedTokens = Number(state.cost.promptTokens ?? 0) + Number(state.cost.completionTokens ?? 0);
|
|
383
|
+
// 成本闸:还能不能再调一次(次数 / 已用 token / 本批估算三关)
|
|
384
|
+
export const costGate = (state, estimatedTokens, limits = COST_LIMITS) => {
|
|
328
385
|
const calls = Number(state.cost.calls ?? 0);
|
|
386
|
+
const spent = Number(state.cost.promptTokens ?? 0) + Number(state.cost.completionTokens ?? 0);
|
|
329
387
|
if (calls >= limits.callsPerDay) return { ok: false, reason: `当日调用数已达上限 ${limits.callsPerDay} 次` };
|
|
330
|
-
if (
|
|
331
|
-
if (
|
|
332
|
-
return { ok: false, reason: `本批估算 ${estimatedTokens} token 会超过当日上限(已用 ${
|
|
388
|
+
if (spent >= limits.tokensPerDay) return { ok: false, reason: `当日 token 已达上限 ${limits.tokensPerDay}` };
|
|
389
|
+
if (spent + estimatedTokens > limits.tokensPerDay) {
|
|
390
|
+
return { ok: false, reason: `本批估算 ${estimatedTokens} token 会超过当日上限(已用 ${spent}/${limits.tokensPerDay})` };
|
|
333
391
|
}
|
|
334
392
|
return { ok: true };
|
|
335
|
-
}
|
|
393
|
+
};
|
|
336
394
|
|
|
337
395
|
// ── 快照 ────────────────────────────────────────────────────────────────────
|
|
338
396
|
|
|
339
|
-
|
|
340
|
-
export
|
|
397
|
+
// VACUUM INTO 快照(只读连接打开源库;目标存在就先删掉)
|
|
398
|
+
export const vacuumInto = (srcDbPath, destFile) => {
|
|
341
399
|
fs.mkdirSync(path.dirname(destFile), { recursive: true });
|
|
342
400
|
if (fs.existsSync(destFile)) fs.rmSync(destFile);
|
|
343
401
|
const db = openDb(srcDbPath);
|
|
@@ -348,82 +406,105 @@ export function vacuumInto(srcDbPath, destFile) {
|
|
|
348
406
|
}
|
|
349
407
|
if (!fs.existsSync(destFile)) throw new Error(`VACUUM INTO 未产出快照:${destFile}`);
|
|
350
408
|
return destFile;
|
|
351
|
-
}
|
|
409
|
+
};
|
|
352
410
|
|
|
353
|
-
// ──
|
|
411
|
+
// ── 写回 ────────────────────────────────────────────────────────────────────
|
|
354
412
|
|
|
355
413
|
/**
|
|
356
|
-
*
|
|
414
|
+
* 把一批分类结果写回(幂等 + 事务 + 重试)。只改 tags,不动 updated_at / created_at。
|
|
357
415
|
* @returns {{written:number, unchanged:number, addedTokens:string[], details:Array}}
|
|
358
416
|
*/
|
|
359
|
-
export async
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
417
|
+
export const applyResults = async (db, results, deps = {}) => {
|
|
418
|
+
const outcome = await withBusyRetry(db, () => {
|
|
419
|
+
const details = [];
|
|
420
|
+
const addedTokens = [];
|
|
421
|
+
let written = 0;
|
|
422
|
+
let unchanged = 0;
|
|
423
|
+
const readTags = db.prepare('SELECT tags FROM memories WHERE id = ?');
|
|
424
|
+
const writeTags = db.prepare('UPDATE memories SET tags = ? WHERE id = ?');
|
|
425
|
+
|
|
426
|
+
for (const result of results) {
|
|
427
|
+
const row = readTags.get(result.id);
|
|
428
|
+
if (!row) {
|
|
429
|
+
details.push({ id: result.id, action: 'missing' });
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const merged = appendDomainTags(row.tags, result.domains);
|
|
433
|
+
if (!merged.added.length) {
|
|
372
434
|
unchanged += 1;
|
|
373
|
-
details.push({
|
|
435
|
+
details.push({
|
|
436
|
+
id: result.id,
|
|
437
|
+
action: merged.wanted.length ? 'already' : 'no-tag',
|
|
438
|
+
tags: merged.tags,
|
|
439
|
+
alreadyPresent: merged.alreadyPresent,
|
|
440
|
+
});
|
|
374
441
|
continue;
|
|
375
442
|
}
|
|
376
|
-
|
|
443
|
+
writeTags.run(merged.tags, result.id);
|
|
377
444
|
written += 1;
|
|
378
|
-
addedTokens.push(...added);
|
|
379
|
-
details.push({ id:
|
|
445
|
+
addedTokens.push(...merged.added);
|
|
446
|
+
details.push({ id: result.id, action: 'tagged', tags: merged.tags, added: merged.added });
|
|
380
447
|
}
|
|
381
|
-
return { written, unchanged };
|
|
448
|
+
return { written, unchanged, details, addedTokens };
|
|
382
449
|
}, deps);
|
|
383
|
-
return { ...r, written: r.value?.written ?? 0, unchanged: r.value?.unchanged ?? 0, addedTokens, details };
|
|
384
|
-
}
|
|
385
450
|
|
|
386
|
-
|
|
451
|
+
const done = outcome.value;
|
|
452
|
+
return {
|
|
453
|
+
...outcome,
|
|
454
|
+
written: done?.written ?? 0,
|
|
455
|
+
unchanged: done?.unchanged ?? 0,
|
|
456
|
+
addedTokens: done?.addedTokens ?? [],
|
|
457
|
+
details: done?.details ?? [],
|
|
458
|
+
};
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// ── 日报一行(幂等段替换,口径同日报其它段) ─────────────────────────────────
|
|
387
462
|
|
|
388
463
|
export const DAILY_SECTION_TITLE = '## 🏷 记忆域标签(memory-domain-tags)';
|
|
389
464
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
465
|
+
// 一行摘要:模式 / 本轮批次 / 标签统计 / 跳过统计 / 当日成本,必要时补停机原因与台账条数
|
|
466
|
+
export const renderDailyLine = ({ mode, stats, cost, state, limits = COST_LIMITS, stoppedReason = null }) => {
|
|
467
|
+
const spent = Number(cost.promptTokens ?? 0) + Number(cost.completionTokens ?? 0);
|
|
468
|
+
const fields = [
|
|
469
|
+
['模式', mode],
|
|
470
|
+
['本轮批次', stats.batches ?? 0],
|
|
471
|
+
['新增标签条数', stats.tagged ?? 0],
|
|
472
|
+
['无标签', stats.noTag ?? 0],
|
|
473
|
+
['跳过(非本机owner)', stats.skippedOwner ?? 0],
|
|
474
|
+
['当日调用', `${cost.calls ?? 0}/${limits.callsPerDay}`],
|
|
475
|
+
['当日 token', `${spent}/${limits.tokensPerDay}`],
|
|
400
476
|
];
|
|
477
|
+
const parts = fields.map(([label, value]) => `${label} ${value}`);
|
|
401
478
|
if (stoppedReason) parts.push(`停机原因:${stoppedReason}`);
|
|
402
479
|
if (state?.processedCount !== undefined) parts.push(`幂等台账 ${state.processedCount} 条`);
|
|
403
480
|
return parts.join(' · ');
|
|
404
|
-
}
|
|
481
|
+
};
|
|
405
482
|
|
|
406
|
-
/**
|
|
407
|
-
export
|
|
483
|
+
/** 日报里做「段级替换」:有同名段就换掉段体,没有就追加到文件尾;文件不存在则不新建。 */
|
|
484
|
+
export const updateDailySection = ({ file, line, dryRun = false }) => {
|
|
408
485
|
if (!fs.existsSync(file)) return { ok: false, reason: '日报不存在(不新建)', file };
|
|
409
|
-
const
|
|
410
|
-
const
|
|
411
|
-
const idx = lines.findIndex((l) => l.trim() === DAILY_SECTION_TITLE);
|
|
486
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
487
|
+
const at = lines.findIndex((row) => row.trim() === DAILY_SECTION_TITLE);
|
|
412
488
|
const block = [DAILY_SECTION_TITLE, '', `> ${line}`, ''];
|
|
489
|
+
|
|
413
490
|
let next;
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
while (end < lines.length && !(lines[end].startsWith('## ') && end > idx + 1)) end += 1;
|
|
417
|
-
next = [...lines.slice(0, idx), ...block, ...lines.slice(end)];
|
|
491
|
+
if (at < 0) {
|
|
492
|
+
next = lines.concat(block);
|
|
418
493
|
} else {
|
|
419
|
-
|
|
494
|
+
// 段体 = 标题之后到下一个二级标题之前(紧跟标题的空行不算结束)
|
|
495
|
+
let stop = at + 1;
|
|
496
|
+
while (stop < lines.length && !(lines[stop].startsWith('## ') && stop > at + 1)) stop += 1;
|
|
497
|
+
next = [...lines.slice(0, at), ...block, ...lines.slice(stop)];
|
|
420
498
|
}
|
|
421
|
-
if (!dryRun) fs.writeFileSync(file, next.join('\n'), 'utf8');
|
|
422
|
-
return { ok: true, replaced: idx >= 0, file };
|
|
423
|
-
}
|
|
424
499
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
500
|
+
if (!dryRun) fs.writeFileSync(file, next.join('\n'), 'utf8');
|
|
501
|
+
return { ok: true, replaced: at >= 0, file };
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// 当日日报路径:<docsDir>/管家报告/<YYYY-MM-DD>-<host>.md(与日报其它段同口径)
|
|
505
|
+
export const dailyReportPath = ({ docsDir = path.join(process.cwd(), 'docs'), host = null, now = Date.now() } = {}) => {
|
|
506
|
+
const when = new Date(now);
|
|
507
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
508
|
+
const date = `${when.getFullYear()}-${pad(when.getMonth() + 1)}-${pad(when.getDate())}`;
|
|
428
509
|
return path.join(docsDir, '管家报告', `${date}-${host || os.hostname()}.md`);
|
|
429
|
-
}
|
|
510
|
+
};
|