dsh-lost-and-found 0.1.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/LICENSE +21 -0
- package/README.md +218 -0
- package/client.js +407 -0
- package/config.mjs +189 -0
- package/cordis.patch.yml +5 -0
- package/core/classify.mjs +102 -0
- package/core/format.mjs +40 -0
- package/core/run-scan.mjs +211 -0
- package/core/scan.mjs +208 -0
- package/core/schedule.mjs +71 -0
- package/core/search.mjs +217 -0
- package/db.mjs +336 -0
- package/index.mjs +577 -0
- package/package.json +82 -0
- package/screenshots.json +6 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件快速寻回(dsh-lost-and-found)· DSH 插件主入口
|
|
3
|
+
*
|
|
4
|
+
* 定位:帮用户记住电脑里「新出现」的文件(名称/类型/内容大意/位置/时间),
|
|
5
|
+
* 以后忘了东西存哪了,问一句就能找回来。
|
|
6
|
+
* 职责边界:插件负责「记得住 + 找得快」;语义判断交给会话里的模型。
|
|
7
|
+
* 只读承诺:不修改、不移动、不删除用户的任何文件。
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
13
|
+
import z from "@deepseek-ai/schemastery";
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_CONFIG, POLICY, normalizeRoots, resolveDbPath, buildExcludes,
|
|
16
|
+
writeRunSnapshot, dataDir,
|
|
17
|
+
} from "./config.mjs";
|
|
18
|
+
import { openDb, counts, lastScanMs, recentRuns, rootCounts, dbFileSize, forgetPaths, verifyBatch, getMeta } from "./db.mjs";
|
|
19
|
+
import { computeSinceMs, isDue, nextDueMs, rootSinceMs } from "./core/schedule.mjs";
|
|
20
|
+
import { searchFiles, liveSearch, describeQuery } from "./core/search.mjs";
|
|
21
|
+
import { categoryOf } from "./core/classify.mjs";
|
|
22
|
+
import { humanSize, relTime, absTime, shortPath } from "./core/format.mjs";
|
|
23
|
+
|
|
24
|
+
/** 把一条检索结果渲染成给人看的几行 */
|
|
25
|
+
function renderRow(r, idx) {
|
|
26
|
+
const mark = r.exists === false ? "❌ 已不在此处" : r.exists ? "✅ 还在" : "";
|
|
27
|
+
const lines = [`${idx}. 【${r.category || categoryOf(r.ext)}】${r.name}${mark ? ` ${mark}` : ""}`];
|
|
28
|
+
lines.push(` 位置:${shortPath(r.path, 96)}`);
|
|
29
|
+
lines.push(` 出现:${absTime(r.appeared_ms)}(${relTime(r.appeared_ms)})|${humanSize(r.size)}|来源:${r.origin || "未知"}${r._live ? "|现场找到" : ""}`);
|
|
30
|
+
if (r.summary) lines.push(` 内容:${r.summary}`);
|
|
31
|
+
if (r.image_desc) lines.push(` 图片:${r.image_desc}`);
|
|
32
|
+
if (r.tags) lines.push(` 关键词:${r.tags}`);
|
|
33
|
+
if (r._hitIn && r._hitIn.length) lines.push(` 命中:${r._hitIn.join(" / ")}`);
|
|
34
|
+
if (r.snippet) lines.push(` 片段:${r.snippet}`);
|
|
35
|
+
return lines;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const name = "dsh-lost-and-found";
|
|
39
|
+
export const inject = ["tools", "commands"];
|
|
40
|
+
|
|
41
|
+
const SETTINGS_NS = "dsh-lost-and-found";
|
|
42
|
+
const AUTO_CHECK_THROTTLE_MS = 15 * 60 * 1000;
|
|
43
|
+
|
|
44
|
+
const settingsSchema = z.object({
|
|
45
|
+
enabled: z.boolean().default(true),
|
|
46
|
+
dbPath: z.string().default(""),
|
|
47
|
+
intervalDays: z.number().min(0).max(365).default(1),
|
|
48
|
+
firstRunWindowDays: z.number().min(0).max(365).default(7),
|
|
49
|
+
/** 目录首次扫描收多久:full=全部历史(默认)/ window=最近 N 天 / none=只收今后新增 */
|
|
50
|
+
firstScanMode: z.string().default("full"),
|
|
51
|
+
maxFileMB: z.number().min(0).max(10240).default(50),
|
|
52
|
+
patrolEnabled: z.boolean().default(true),
|
|
53
|
+
imageQuotaPerRun: z.number().min(0).max(500).default(20),
|
|
54
|
+
backupKeep: z.number().min(0).max(60).default(7),
|
|
55
|
+
roots: z.array(z.object({ path: z.string(), policy: z.string().default("full") })).default([]),
|
|
56
|
+
extraExcludeDirs: z.array(z.string()).default([]),
|
|
57
|
+
extraExcludePatterns: z.array(z.string()).default([]),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function textTool(definition) {
|
|
61
|
+
return defineTool({
|
|
62
|
+
...definition,
|
|
63
|
+
output: {
|
|
64
|
+
schema: { type: "string" },
|
|
65
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
66
|
+
},
|
|
67
|
+
presentCall: (args) => ({ card: "generic", kind: "text", title: definition.name, rawInput: args }),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 把 "7d" / "24h" / "3w" / "2mo" / ISO 时间 / 毫秒时间戳 解析成 ms */
|
|
72
|
+
export function parseTime(input, now = Date.now()) {
|
|
73
|
+
if (input === undefined || input === null || input === "") return null;
|
|
74
|
+
if (typeof input === "number") return input > 1e12 ? input : now - input * 1000;
|
|
75
|
+
const s = String(input).trim();
|
|
76
|
+
const rel = s.match(/^(\d+(?:\.\d+)?)\s*(mo|[dhwmy])$/i);
|
|
77
|
+
if (rel) {
|
|
78
|
+
const n = Number(rel[1]);
|
|
79
|
+
const unit = rel[2].toLowerCase();
|
|
80
|
+
const table = { d: 86400000, h: 3600000, w: 7 * 86400000, mo: 30 * 86400000, m: 30 * 86400000, y: 365 * 86400000 };
|
|
81
|
+
return now - n * (table[unit] || 86400000);
|
|
82
|
+
}
|
|
83
|
+
const t = Date.parse(s);
|
|
84
|
+
return Number.isFinite(t) ? t : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function openLiveDb(config) {
|
|
88
|
+
const dbPath = resolveDbPath(config);
|
|
89
|
+
return { db: openDb(dbPath), dbPath };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseRunnerOutput(stdout) {
|
|
93
|
+
const marker = "===LAF_RUN_RESULT===";
|
|
94
|
+
const i = stdout.indexOf(marker);
|
|
95
|
+
if (i < 0) return null;
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(stdout.slice(i + marker.length));
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function apply(ctx, input = {}) {
|
|
104
|
+
let liveConfig = { ...structuredClone(DEFAULT_CONFIG), ...(input || {}) };
|
|
105
|
+
let lastAutoCheck = 0;
|
|
106
|
+
let childRunning = false;
|
|
107
|
+
|
|
108
|
+
// ---------- 设置命名空间(Web 设置页读写;用户配置的唯一真相来源) ----------
|
|
109
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
110
|
+
try {
|
|
111
|
+
const scope = settingsCtx.settings.register(SETTINGS_NS, settingsSchema, { base: liveConfig });
|
|
112
|
+
const resolved = scope.get();
|
|
113
|
+
if (resolved) liveConfig = { ...liveConfig, ...resolved, roots: normalizeRoots(resolved.roots) };
|
|
114
|
+
scope.watch((next) => {
|
|
115
|
+
if (!next) return;
|
|
116
|
+
liveConfig = { ...liveConfig, ...next, roots: normalizeRoots(next.roots) };
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
ctx.logger.warn(`dsh-lost-and-found: 设置命名空间注册失败:${err.message}`);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ---------- 派生扫描子进程 ----------
|
|
124
|
+
async function spawnScan({ trigger = "manual", wait = false, forceFull = false } = {}) {
|
|
125
|
+
if (childRunning && wait) throw new Error("已有扫描在进行中");
|
|
126
|
+
if (!liveConfig.enabled) throw new Error("插件已关闭(可在设置页打开)");
|
|
127
|
+
const roots = normalizeRoots(liveConfig.roots);
|
|
128
|
+
const excludes = buildExcludes(liveConfig);
|
|
129
|
+
const dbPath = resolveDbPath(liveConfig);
|
|
130
|
+
|
|
131
|
+
// 逐目录锚点:给每个目录算它自己的收录起点。
|
|
132
|
+
// 不能用单一全局窗口——那会让「后加入的目录」只收到全局锚点之后 6 小时的文件,
|
|
133
|
+
// 该目录更早的历史文件永久漏收(实测 D:\读书 570 个文件只进了 13 个)。
|
|
134
|
+
let rootsWithSince = roots.map((r) => ({ ...r, sinceMs: 0 }));
|
|
135
|
+
try {
|
|
136
|
+
const db = openDb(dbPath);
|
|
137
|
+
try {
|
|
138
|
+
rootsWithSince = roots.map((r) => ({
|
|
139
|
+
...r,
|
|
140
|
+
sinceMs: rootSinceMs({ db, root: r, config: liveConfig, forceFull }),
|
|
141
|
+
}));
|
|
142
|
+
} finally {
|
|
143
|
+
db.close();
|
|
144
|
+
}
|
|
145
|
+
} catch { /* 库还没建起来时按全量处理,与 run-scan 的兜底一致 */ }
|
|
146
|
+
const globalSince = rootsWithSince.length
|
|
147
|
+
? Math.min(...rootsWithSince.map((r) => Number(r.sinceMs) || 0))
|
|
148
|
+
: Date.now();
|
|
149
|
+
|
|
150
|
+
writeRunSnapshot({
|
|
151
|
+
dbPath,
|
|
152
|
+
config: {
|
|
153
|
+
firstRunWindowDays: liveConfig.firstRunWindowDays,
|
|
154
|
+
firstScanMode: liveConfig.firstScanMode,
|
|
155
|
+
intervalDays: liveConfig.intervalDays,
|
|
156
|
+
maxFileMB: liveConfig.maxFileMB,
|
|
157
|
+
patrolEnabled: liveConfig.patrolEnabled,
|
|
158
|
+
imageQuotaPerRun: liveConfig.imageQuotaPerRun,
|
|
159
|
+
backupKeep: liveConfig.backupKeep,
|
|
160
|
+
},
|
|
161
|
+
roots: rootsWithSince,
|
|
162
|
+
excludes: { dirs: [...excludes.dirs], patterns: excludes.patterns },
|
|
163
|
+
sinceMs: globalSince,
|
|
164
|
+
forceFull: !!forceFull,
|
|
165
|
+
patrolSinceMs: Date.now() - 86400000,
|
|
166
|
+
drives: ["C:", "D:", "E:"],
|
|
167
|
+
trigger,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const runner = fileURLToPath(new URL("./core/run-scan.mjs", import.meta.url));
|
|
171
|
+
const child = spawn(process.execPath, [runner, `--trigger=${trigger}`], {
|
|
172
|
+
detached: !wait,
|
|
173
|
+
stdio: wait ? ["ignore", "pipe", "pipe"] : "ignore",
|
|
174
|
+
windowsHide: true,
|
|
175
|
+
cwd: dataDir(),
|
|
176
|
+
env: { ...process.env, LAF_TRIGGER: trigger },
|
|
177
|
+
});
|
|
178
|
+
if (!wait) {
|
|
179
|
+
child.unref();
|
|
180
|
+
return { started: true, pid: child.pid };
|
|
181
|
+
}
|
|
182
|
+
childRunning = true;
|
|
183
|
+
let out = "";
|
|
184
|
+
let err = "";
|
|
185
|
+
child.stdout.on("data", (d) => { out += d.toString(); });
|
|
186
|
+
child.stderr.on("data", (d) => { err += d.toString(); });
|
|
187
|
+
const code = await new Promise((res) => child.on("exit", (c) => res(c)));
|
|
188
|
+
childRunning = false;
|
|
189
|
+
return { started: true, exitCode: code, stdout: out, stderr: err, result: parseRunnerOutput(out) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------- 到期自动扫描:会话一开就检查(用户选了「每天」) ----------
|
|
193
|
+
function maybeAutoScan(reason) {
|
|
194
|
+
try {
|
|
195
|
+
if (!liveConfig.enabled) return;
|
|
196
|
+
const days = Number(liveConfig.intervalDays);
|
|
197
|
+
if (!days || days <= 0) return;
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
if (now - lastAutoCheck < AUTO_CHECK_THROTTLE_MS) return;
|
|
200
|
+
lastAutoCheck = now;
|
|
201
|
+
const db = openDb(resolveDbPath(liveConfig));
|
|
202
|
+
const due = isDue(db, liveConfig, now);
|
|
203
|
+
db.close();
|
|
204
|
+
if (!due) return;
|
|
205
|
+
ctx.logger.info(`dsh-lost-and-found: ${reason},距上次扫描已超过 ${days} 天,后台开始补扫`);
|
|
206
|
+
spawnScan({ trigger: "auto", wait: false }).catch((e) => ctx.logger.warn(`自动扫描派发失败:${e.message}`));
|
|
207
|
+
} catch (e) {
|
|
208
|
+
ctx.logger.warn(`自动扫描检查失败:${e.message}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
ctx.on("agent/session-start", () => maybeAutoScan("新会话开始"));
|
|
214
|
+
ctx.on("agent/pre-step", async (payload, next) => {
|
|
215
|
+
maybeAutoScan("对话中");
|
|
216
|
+
return typeof next === "function" ? next() : undefined;
|
|
217
|
+
}, { prepend: true });
|
|
218
|
+
} catch (e) {
|
|
219
|
+
ctx.logger.warn(`dsh-lost-and-found: 会话事件注册失败(自动扫描将依赖手动触发):${e.message}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------- 工具 1:找文件(核心) ----------
|
|
223
|
+
ctx.tools.register(textTool({
|
|
224
|
+
name: "file_find",
|
|
225
|
+
description:
|
|
226
|
+
"在「文件快速寻回」的记忆库里按内容/名字/时间/类型/位置找文件。这是用户问「我那个文件放哪了」时的首选工具。" +
|
|
227
|
+
"keywords 支持多个词(空格分隔,全部命中才算);since/until 支持相对写法(7d=最近7天, 24h, 3w, 2mo)或 ISO 时间;" +
|
|
228
|
+
"ext 支持逗号分隔后缀(xlsx,pdf);folder 限定目录前缀;category 支持 文档/表格/演示/PDF/文本/图片/视频/音频/压缩包/代码等。" +
|
|
229
|
+
"索引没命中时会自动对扫描目录做一次实时兜底扫描(fallback=true 默认开)。",
|
|
230
|
+
parameters: {
|
|
231
|
+
keywords: { type: "string", description: "关键词,空格分隔;可空(空则只按时间/类型筛选)" },
|
|
232
|
+
since: { type: "string", description: "起始时间,如 7d / 24h / 3w / 2mo / 2026-09-01" },
|
|
233
|
+
until: { type: "string", description: "结束时间,同上" },
|
|
234
|
+
ext: { type: "string", description: "后缀过滤,逗号分隔,如 xlsx,pdf" },
|
|
235
|
+
folder: { type: "string", description: "限定目录(前缀匹配),如 D:\\工作" },
|
|
236
|
+
category: { type: "string", description: "类别过滤,逗号分隔,如 表格,文档" },
|
|
237
|
+
origin: { type: "string", description: "来源过滤:我下载/聊天收到/工具/AI 产出/桌面/本地创建/未知" },
|
|
238
|
+
limit: { type: "number", description: "返回条数,默认 15,最大 100" },
|
|
239
|
+
includeMissing: { type: "boolean", description: "是否包含已不在原处的记录,默认 false" },
|
|
240
|
+
includeNoise: { type: "boolean", description: "是否包含程序内部件/缓存件(默认 false,这些会淹没结果)" },
|
|
241
|
+
fallback: { type: "boolean", description: "索引没命中时是否实时扫描扫描目录兜底,默认 true" },
|
|
242
|
+
},
|
|
243
|
+
async execute(args = {}) {
|
|
244
|
+
const cfg = liveConfig;
|
|
245
|
+
const roots = normalizeRoots(cfg.roots);
|
|
246
|
+
const opts = {
|
|
247
|
+
keywords: args.keywords,
|
|
248
|
+
since: parseTime(args.since),
|
|
249
|
+
until: parseTime(args.until) ?? (args.until ? null : null),
|
|
250
|
+
ext: args.ext ? String(args.ext).split(/[,,\s]+/).filter(Boolean) : [],
|
|
251
|
+
folder: args.folder,
|
|
252
|
+
categories: args.category ? String(args.category).split(/[,,\s]+/).filter(Boolean) : [],
|
|
253
|
+
origins: args.origin ? String(args.origin).split(/[,,\s]+/).filter(Boolean) : [],
|
|
254
|
+
limit: args.limit,
|
|
255
|
+
includeMissing: !!args.includeMissing,
|
|
256
|
+
includeNoise: !!args.includeNoise,
|
|
257
|
+
};
|
|
258
|
+
const { db } = openLiveDb(cfg);
|
|
259
|
+
let res;
|
|
260
|
+
try {
|
|
261
|
+
res = await searchFiles(db, opts);
|
|
262
|
+
} finally {
|
|
263
|
+
db.close();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const lines = [];
|
|
267
|
+
const head = `搜的是:${describeQuery({ ...opts, keywords: res.query.keywords })}`;
|
|
268
|
+
|
|
269
|
+
if (res.rows.length) {
|
|
270
|
+
lines.push(`在记忆库里找到 ${res.rows.length} 个候选(按相关度排序,共匹配 ${res.candidates} 条):`, "");
|
|
271
|
+
res.rows.forEach((r, i) => lines.push(...renderRow(r, i + 1)));
|
|
272
|
+
lines.push("", head);
|
|
273
|
+
} else {
|
|
274
|
+
lines.push(`记忆库里没有匹配(${head})`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 兜底实时扫描
|
|
278
|
+
const wantFallback = args.fallback !== false && res.rows.length === 0 && roots.length > 0;
|
|
279
|
+
if (wantFallback) {
|
|
280
|
+
lines.push("", "记忆库里没有,正在扫描目录里现场找一遍(这些文件可能还没被收录)…");
|
|
281
|
+
const live = await liveSearch({
|
|
282
|
+
roots, excludes: buildExcludes(cfg),
|
|
283
|
+
keywords: opts.keywords, since: opts.since, until: opts.until, ext: opts.ext,
|
|
284
|
+
limit: opts.limit || 15, includeNoise: opts.includeNoise,
|
|
285
|
+
});
|
|
286
|
+
if (live.rows.length) {
|
|
287
|
+
lines.push(`现场找到 ${live.rows.length} 个(扫描了 ${live.scanned.total} 个文件):`, "");
|
|
288
|
+
live.rows.forEach((r, i) => lines.push(...renderRow(r, i + 1)));
|
|
289
|
+
lines.push("", "提示:这些文件还没进入记忆库,所以只能按名字/时间找;要按内容找需要等它被扫描收录。");
|
|
290
|
+
} else {
|
|
291
|
+
lines.push(`现场也没找到(扫描了 ${live.scanned.total} 个文件)。`);
|
|
292
|
+
}
|
|
293
|
+
} else if (res.rows.length === 0 && roots.length === 0) {
|
|
294
|
+
lines.push("", "提示:还没设置要扫描的文件夹,所以只能查已收录的内容。请在插件设置页里添加目录。");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 结构化尾巴:便于模型精确取用路径
|
|
298
|
+
lines.push("", "```json", JSON.stringify(
|
|
299
|
+
res.rows.map((r) => ({
|
|
300
|
+
path: r.path, name: r.name, category: r.category, origin: r.origin,
|
|
301
|
+
size: r.size, appeared: absTime(r.appeared_ms), exists: r.exists,
|
|
302
|
+
summary: r.summary || null, snippet: r.snippet || null, policy: r.policy,
|
|
303
|
+
})), null, 1), "```");
|
|
304
|
+
return lines.join("\n");
|
|
305
|
+
},
|
|
306
|
+
}));
|
|
307
|
+
|
|
308
|
+
// ---------- 工具 2:立刻扫描一次 ----------
|
|
309
|
+
ctx.tools.register(textTool({
|
|
310
|
+
name: "file_index_scan",
|
|
311
|
+
description:
|
|
312
|
+
"立刻对已设置的文件夹做一次扫描。每个文件夹有自己的增量锚点:从未扫过的文件夹会按首扫策略回填" +
|
|
313
|
+
"(默认收录该目录全部历史文件),扫过的只收上次扫描之后新出现的。默认后台跑、立即返回;" +
|
|
314
|
+
"wait=true 则等它跑完并返回结果(几十秒到几分钟);full=true 强制忽略锚点、全量重扫一遍。" +
|
|
315
|
+
"用户说「扫一下/更新一下记忆」时用它。",
|
|
316
|
+
parameters: {
|
|
317
|
+
wait: { type: "boolean", description: "是否等扫描跑完再返回,默认 false(后台跑)" },
|
|
318
|
+
full: { type: "boolean", description: "强制全量重扫(忽略已有锚点),默认 false" },
|
|
319
|
+
},
|
|
320
|
+
async execute(args = {}) {
|
|
321
|
+
const cfg = liveConfig;
|
|
322
|
+
if (!normalizeRoots(cfg.roots).length) {
|
|
323
|
+
return "还没设置要扫描的文件夹。请在插件设置页点「一键扫描」或手动添加目录后再试。";
|
|
324
|
+
}
|
|
325
|
+
if (args.wait) {
|
|
326
|
+
const r = await spawnScan({ trigger: "manual", wait: true, forceFull: !!args.full });
|
|
327
|
+
if (!r.result || r.result.ok === false) {
|
|
328
|
+
return `扫描失败:${r.result?.error || r.stderr || `退出码 ${r.exitCode}`}`;
|
|
329
|
+
}
|
|
330
|
+
const x = r.result;
|
|
331
|
+
const skipped = Array.isArray(x.rootsSkipped) && x.rootsSkipped.length
|
|
332
|
+
? `· 有 ${x.rootsSkipped.length} 个目录没扫完(已保留锚点,下次会补)`
|
|
333
|
+
: null;
|
|
334
|
+
return [
|
|
335
|
+
"扫描完成。",
|
|
336
|
+
`· 看了 ${x.scannedFiles} 个文件,其中 ${x.candidates} 个符合收录条件`,
|
|
337
|
+
`· 新增收录 ${x.added} 个,更新 ${x.updated} 个`,
|
|
338
|
+
`· 记忆库现在共 ${x.total} 个文件`,
|
|
339
|
+
skipped,
|
|
340
|
+
x.errors ? `· 有 ${x.errors} 个文件没能读取(已跳过)` : null,
|
|
341
|
+
].filter(Boolean).join("\n");
|
|
342
|
+
}
|
|
343
|
+
await spawnScan({ trigger: "manual", wait: false, forceFull: !!args.full });
|
|
344
|
+
return "已在后台开始扫描,跑完会自动写进记忆库。可以用 file_index_status 看进度。";
|
|
345
|
+
},
|
|
346
|
+
}));
|
|
347
|
+
|
|
348
|
+
// ---------- 工具 3:概况 ----------
|
|
349
|
+
ctx.tools.register(textTool({
|
|
350
|
+
name: "file_index_status",
|
|
351
|
+
description: "查看「文件快速寻回」的概况:上次扫描时间、库内文件数、扫描目录、待处理项、野文件提示、库位置与错误。",
|
|
352
|
+
parameters: {},
|
|
353
|
+
async execute() {
|
|
354
|
+
const cfg = liveConfig;
|
|
355
|
+
const dbPath = resolveDbPath(cfg);
|
|
356
|
+
const { db } = openLiveDb(cfg);
|
|
357
|
+
try {
|
|
358
|
+
const c = counts(db);
|
|
359
|
+
const last = lastScanMs(db);
|
|
360
|
+
const st = getMeta(db, "run_state");
|
|
361
|
+
const note = getMeta(db, "run_note");
|
|
362
|
+
const runs = recentRuns(db, 3);
|
|
363
|
+
const byRoot = rootCounts(db);
|
|
364
|
+
const patrol = db.prepare("SELECT dir, files, sample FROM patrol WHERE status = 'new' ORDER BY files DESC LIMIT 5").all();
|
|
365
|
+
const pendingImgs = db.prepare("SELECT COUNT(*) AS n FROM files WHERE category = '图片' AND policy = 'full' AND (image_desc IS NULL OR image_desc = '') AND state = 'ok'").get();
|
|
366
|
+
|
|
367
|
+
const lines = [];
|
|
368
|
+
lines.push(`状态:${cfg.enabled ? "已启用" : "已关闭"} | 扫描间隔:${Number(cfg.intervalDays) > 0 ? `每 ${cfg.intervalDays} 天` : "仅手动"}`);
|
|
369
|
+
lines.push(`上次扫描:${last ? `${absTime(last)}(${relTime(last)})` : "还没扫描"}`);
|
|
370
|
+
if (Number(cfg.intervalDays) > 0) {
|
|
371
|
+
const next = nextDueMs(db, cfg);
|
|
372
|
+
lines.push(`下次自动扫描:${st === "running" ? "正在扫描中…" : absTime(next)}`);
|
|
373
|
+
}
|
|
374
|
+
lines.push(`记忆库:${c.total} 个文件${c.missing ? `(其中 ${c.missing} 个已不在原处)` : ""}${c.noise ? ` | 程序内部件/缓存件 ${c.noise} 个(默认不参与检索)` : ""}`);
|
|
375
|
+
lines.push(`待处理:${c.enrichPending} 个待读内容${c.enrichFailed ? `,${c.enrichFailed} 个读取失败` : ""} | 待看图:${pendingImgs ? pendingImgs.n : 0} 张`);
|
|
376
|
+
lines.push(`库位置:${dbPath}(${humanSize(dbFileSize(dbPath))})`);
|
|
377
|
+
lines.push("");
|
|
378
|
+
|
|
379
|
+
const roots = normalizeRoots(cfg.roots);
|
|
380
|
+
if (!roots.length) {
|
|
381
|
+
lines.push("扫描目录:(还没设置,请在设置页点「一键扫描」或手动添加)");
|
|
382
|
+
} else {
|
|
383
|
+
lines.push(`扫描目录(${roots.length} 个):`);
|
|
384
|
+
for (const r of roots) {
|
|
385
|
+
const hit = byRoot.find((b) => b.root === r.path);
|
|
386
|
+
lines.push(` · ${shortPath(r.path, 60)} [${r.policy}] ${hit ? hit.n : 0} 个文件`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (patrol.length) {
|
|
391
|
+
lines.push("", "巡查提示:这些目录不在扫描范围里,但最近有新文件:");
|
|
392
|
+
for (const p of patrol) lines.push(` · ${shortPath(p.dir, 60)} ${p.files} 个新文件${p.sample ? `(如 ${p.sample})` : ""}`);
|
|
393
|
+
}
|
|
394
|
+
if (st === "running") lines.push("", `正在扫描:已收录 ${getMeta(db, "run_files") || 0} 个…`);
|
|
395
|
+
if (st === "error" && note) lines.push("", `上次扫描出错:${String(note).slice(0, 300)}`);
|
|
396
|
+
if (runs.length) {
|
|
397
|
+
lines.push("", "最近几轮:");
|
|
398
|
+
for (const r of runs) {
|
|
399
|
+
lines.push(` · ${absTime(r.started_ms)} [${r.trigger}] 新增 ${r.added} / 更新 ${r.updated}${r.note ? ` — ${r.note}` : ""}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return lines.join("\n");
|
|
403
|
+
} finally {
|
|
404
|
+
db.close();
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
}));
|
|
408
|
+
|
|
409
|
+
// ---------- 工具 4:打开所在文件夹 ----------
|
|
410
|
+
ctx.tools.register(textTool({
|
|
411
|
+
name: "file_index_open",
|
|
412
|
+
description: "在文件资源管理器里定位并选中某个文件(只开窗口,不修改文件)。path 传完整路径。",
|
|
413
|
+
parameters: { path: { type: "string", description: "文件完整路径" } },
|
|
414
|
+
async execute(args = {}) {
|
|
415
|
+
const p = String(args.path || "").trim();
|
|
416
|
+
if (!p) return "请提供文件路径";
|
|
417
|
+
if (!existsSync(p)) return `文件已不在此处:${p}(可能被移动或删除)`;
|
|
418
|
+
const child = spawn("explorer.exe", [`/select,${p}`], { detached: true, stdio: "ignore" });
|
|
419
|
+
child.unref();
|
|
420
|
+
return `已在资源管理器中定位:${p}`;
|
|
421
|
+
},
|
|
422
|
+
}));
|
|
423
|
+
|
|
424
|
+
// ---------- 工具 5:忘掉某些记录(只删索引,不删文件) ----------
|
|
425
|
+
ctx.tools.register(textTool({
|
|
426
|
+
name: "file_index_forget",
|
|
427
|
+
description: "把指定的记录从记忆库里删掉(只删记忆,绝不动用户文件)。paths 传路径数组。",
|
|
428
|
+
parameters: { paths: { type: "array", items: { type: "string" }, description: "要忘掉的完整路径数组" } },
|
|
429
|
+
async execute(args = {}) {
|
|
430
|
+
const paths = (args.paths || []).map((x) => (typeof x === "string" ? x : x && x.path)).filter(Boolean);
|
|
431
|
+
if (!paths.length) return "请提供要忘掉的路径";
|
|
432
|
+
const { db } = openLiveDb(liveConfig);
|
|
433
|
+
try {
|
|
434
|
+
const n = forgetPaths(db, paths);
|
|
435
|
+
return `已从记忆库删除 ${n} 条记录(用户文件未被改动)。`;
|
|
436
|
+
} finally {
|
|
437
|
+
db.close();
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
}));
|
|
441
|
+
|
|
442
|
+
// ---------- 工具 6:手动跑一次存在性校验 ----------
|
|
443
|
+
ctx.tools.register(textTool({
|
|
444
|
+
name: "file_index_verify",
|
|
445
|
+
description: "手动检查一批已记录文件是否还在原处(用于清理「已经不在了」的记录)。",
|
|
446
|
+
parameters: { limit: { type: "number", description: "本批检查条数,默认 500" } },
|
|
447
|
+
async execute(args = {}) {
|
|
448
|
+
const { db } = openLiveDb(liveConfig);
|
|
449
|
+
try {
|
|
450
|
+
const r = verifyBatch(db, Number(args.limit) || 500);
|
|
451
|
+
return `检查了 ${r.checked} 个文件,其中 ${r.missing} 个已不在原处(连续两次确认后才会标记)。`;
|
|
452
|
+
} finally {
|
|
453
|
+
db.close();
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
}));
|
|
457
|
+
|
|
458
|
+
// ---------- 工具 7/8:图片待描述队列(看图由会话里的模型完成) ----------
|
|
459
|
+
ctx.tools.register(textTool({
|
|
460
|
+
name: "file_index_pending_images",
|
|
461
|
+
description:
|
|
462
|
+
"取出「还没看过内容」的图片清单(供模型用 read_image 逐张看图并写描述)。返回路径+名字+时间。",
|
|
463
|
+
parameters: { limit: { type: "number", description: "本次取几张,默认用配置里的看图配额" } },
|
|
464
|
+
async execute(args = {}) {
|
|
465
|
+
const limit = Number(args.limit) || Number(liveConfig.imageQuotaPerRun) || 20;
|
|
466
|
+
const { db } = openLiveDb(liveConfig);
|
|
467
|
+
try {
|
|
468
|
+
const rows = db.prepare(`
|
|
469
|
+
SELECT path, name, ext, size, appeared_ms, origin FROM files
|
|
470
|
+
WHERE category = '图片' AND policy = 'full' AND state = 'ok' AND noise = 0
|
|
471
|
+
AND (image_desc IS NULL OR image_desc = '')
|
|
472
|
+
ORDER BY appeared_ms DESC LIMIT ?`).all(limit);
|
|
473
|
+
if (!rows.length) return "没有待看图的图片了。";
|
|
474
|
+
const lines = [`待看图 ${rows.length} 张(看完请用 file_index_set_image_desc 写回描述):`, ""];
|
|
475
|
+
rows.forEach((r, i) => {
|
|
476
|
+
lines.push(`${i + 1}. ${r.path}`);
|
|
477
|
+
lines.push(` ${absTime(r.appeared_ms)}(${relTime(r.appeared_ms)})|${humanSize(r.size)}|来源:${r.origin || "未知"}`);
|
|
478
|
+
});
|
|
479
|
+
return lines.join("\n");
|
|
480
|
+
} finally {
|
|
481
|
+
db.close();
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
}));
|
|
485
|
+
|
|
486
|
+
ctx.tools.register(textTool({
|
|
487
|
+
name: "file_index_set_image_desc",
|
|
488
|
+
description:
|
|
489
|
+
"把一张图片的描述与关键词写回记忆库(含图上读到的文字,如金额/姓名/单号,便于以后按内容搜到)。",
|
|
490
|
+
parameters: {
|
|
491
|
+
path: { type: "string", description: "图片完整路径" },
|
|
492
|
+
description: { type: "string", description: "一句话描述这张图是什么" },
|
|
493
|
+
tags: { type: "string", description: "关键词(空格或用逗号分隔),含图上读到的关键文字" },
|
|
494
|
+
},
|
|
495
|
+
async execute(args = {}) {
|
|
496
|
+
const p = String(args.path || "").trim();
|
|
497
|
+
if (!p) return "请提供图片路径";
|
|
498
|
+
const { db } = openLiveDb(liveConfig);
|
|
499
|
+
try {
|
|
500
|
+
const r = db.prepare("UPDATE files SET image_desc = ?, tags = ?, enrich = 'described', last_seen_ms = ? WHERE path = ?")
|
|
501
|
+
.run(String(args.description || ""), String(args.tags || ""), Date.now(), p);
|
|
502
|
+
return Number(r.changes) ? `已记录:${p}` : `记忆库里没有这条记录:${p}`;
|
|
503
|
+
} finally {
|
|
504
|
+
db.close();
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
}));
|
|
508
|
+
|
|
509
|
+
// ---------- 斜杠命令(供设置页按钮与用户直接输入) ----------
|
|
510
|
+
if (ctx.commands) {
|
|
511
|
+
ctx.commands.register({
|
|
512
|
+
name: "lostfound_scan",
|
|
513
|
+
description: "立刻扫描一次并返回结果(等同设置页的「立即扫描」按钮)。",
|
|
514
|
+
input: { hint: "扫描一次:新目录首次扫描会回填其历史文件,其余只收新增。", images: false },
|
|
515
|
+
handler: async () => {
|
|
516
|
+
try {
|
|
517
|
+
const roots = normalizeRoots(liveConfig.roots);
|
|
518
|
+
if (!roots.length) return { kind: "error", text: "还没设置要扫描的文件夹:请在插件设置页点「一键扫描」或手动添加目录。" };
|
|
519
|
+
const r = await spawnScan({ trigger: "settings", wait: true });
|
|
520
|
+
if (!r.result || r.result.ok === false) {
|
|
521
|
+
return { kind: "error", text: `扫描失败:${r.result?.error || r.stderr || `退出码 ${r.exitCode}`}` };
|
|
522
|
+
}
|
|
523
|
+
const x = r.result;
|
|
524
|
+
return {
|
|
525
|
+
kind: "success",
|
|
526
|
+
text: [
|
|
527
|
+
"**扫描完成**",
|
|
528
|
+
"",
|
|
529
|
+
`· 看了 ${x.scannedFiles} 个文件,其中 ${x.candidates} 个是新出现的`,
|
|
530
|
+
`· 新增收录 ${x.added} 个${x.updated ? `,更新 ${x.updated} 个` : ""}`,
|
|
531
|
+
`· 记忆库现在共 ${x.total} 个文件`,
|
|
532
|
+
x.errors ? `· ${x.errors} 个文件读取失败(已跳过,不影响其它文件)` : null,
|
|
533
|
+
].filter(Boolean).join("\n"),
|
|
534
|
+
};
|
|
535
|
+
} catch (e) {
|
|
536
|
+
return { kind: "error", text: `扫描失败:${e && e.message ? e.message : String(e)}` };
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
ctx.commands.register({
|
|
542
|
+
name: "lostfound_status",
|
|
543
|
+
description: "查看文件快速寻回的概况。",
|
|
544
|
+
input: { hint: "查看上次扫描时间、库内文件数、扫描目录。", images: false },
|
|
545
|
+
handler: async () => {
|
|
546
|
+
try {
|
|
547
|
+
const cfg = liveConfig;
|
|
548
|
+
const dbPath = resolveDbPath(cfg);
|
|
549
|
+
const { db } = openLiveDb(cfg);
|
|
550
|
+
try {
|
|
551
|
+
const c = counts(db);
|
|
552
|
+
const last = lastScanMs(db);
|
|
553
|
+
const roots = normalizeRoots(cfg.roots);
|
|
554
|
+
return {
|
|
555
|
+
kind: "success",
|
|
556
|
+
text: [
|
|
557
|
+
"**文件快速寻回 · 概况**",
|
|
558
|
+
"",
|
|
559
|
+
`· 状态:${cfg.enabled ? "已启用" : "已关闭"}|扫描间隔:${Number(cfg.intervalDays) > 0 ? `每 ${cfg.intervalDays} 天` : "仅手动"}`,
|
|
560
|
+
`· 上次扫描:${last ? `${absTime(last)}(${relTime(last)})` : "还没扫描"}`,
|
|
561
|
+
`· 记忆库:${c.total} 个文件`,
|
|
562
|
+
`· 扫描目录:${roots.length ? roots.length + " 个" : "还没设置"}`,
|
|
563
|
+
`· 库位置:${dbPath}(${humanSize(dbFileSize(dbPath))})`,
|
|
564
|
+
].join("\n"),
|
|
565
|
+
};
|
|
566
|
+
} finally {
|
|
567
|
+
db.close();
|
|
568
|
+
}
|
|
569
|
+
} catch (e) {
|
|
570
|
+
return { kind: "error", text: `读取失败:${e && e.message ? e.message : String(e)}` };
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
ctx.logger.info("dsh-lost-and-found: 文件快速寻回已加载");
|
|
577
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-lost-and-found",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Local file recall for DeepSeek Harness: it watches the folders you choose and keeps a SQLite index of what appeared — name, type, size, appeared-at, location and origin — so \"where did I put that file?\" becomes a single question. Every folder carries its own incremental anchor, and a folder added later is backfilled in full on its first scan. Search is SQL hard filtering plus multi-keyword hit weighting, with a live fallback scan when the index has no hit. Read-only: it never modifies, moves or deletes your files. 文件快速寻回:定期扫描你指定的文件夹,把新出现的文件(名字/类型/大小/出现时间/位置/来源)记进本地 SQLite 索引,忘了东西放哪问一句就能找回。每个文件夹各有自己的增量锚点,新加入的文件夹第一次会回填全部历史;检索用 SQL 硬过滤加多关键词加权,索引没命中时当场兜底实时扫描一遍。只读,绝不修改、移动或删除你的文件。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.mjs",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.mjs",
|
|
14
|
+
"client.js",
|
|
15
|
+
"config.mjs",
|
|
16
|
+
"db.mjs",
|
|
17
|
+
"core/",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"screenshots.json"
|
|
22
|
+
],
|
|
23
|
+
"dsh": {
|
|
24
|
+
"bundle": {
|
|
25
|
+
"patch": "./cordis.patch.yml"
|
|
26
|
+
},
|
|
27
|
+
"client": {
|
|
28
|
+
"inject": [
|
|
29
|
+
"@deepseek-ai/dsh-client-locale",
|
|
30
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
31
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
32
|
+
],
|
|
33
|
+
"platform": "web"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"check": "node --check index.mjs && node --check client.js && node --check config.mjs && node --check db.mjs && node --check core/scan.mjs && node --check core/schedule.mjs && node --check core/search.mjs && node --check core/classify.mjs && node --check core/format.mjs && node --check core/run-scan.mjs && node --check tools/selftest.mjs",
|
|
38
|
+
"test": "npm run check && node tools/selftest.mjs"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0",
|
|
42
|
+
"@deepseek-ai/schemastery": ">=3.18.0 <4.0.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@deepseek-ai/schemastery": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@deepseek-ai/dsh-tools": "0.1.5-rc.1",
|
|
51
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": "^22.0.0 || >=24"
|
|
55
|
+
},
|
|
56
|
+
"keywords": [
|
|
57
|
+
"deepseek-harness",
|
|
58
|
+
"dsh",
|
|
59
|
+
"dsh-plugin",
|
|
60
|
+
"plugin",
|
|
61
|
+
"file-index",
|
|
62
|
+
"file-search",
|
|
63
|
+
"lost-and-found",
|
|
64
|
+
"recall",
|
|
65
|
+
"find-my-file",
|
|
66
|
+
"sqlite",
|
|
67
|
+
"local-first",
|
|
68
|
+
"read-only",
|
|
69
|
+
"windows",
|
|
70
|
+
"productivity"
|
|
71
|
+
],
|
|
72
|
+
"author": "wangzhanchao883",
|
|
73
|
+
"license": "MIT",
|
|
74
|
+
"repository": {
|
|
75
|
+
"type": "git",
|
|
76
|
+
"url": "git+https://github.com/wangzhanchao883/dsh-lost-and-found.git"
|
|
77
|
+
},
|
|
78
|
+
"homepage": "https://github.com/wangzhanchao883/dsh-lost-and-found",
|
|
79
|
+
"bugs": {
|
|
80
|
+
"url": "https://github.com/wangzhanchao883/dsh-lost-and-found/issues"
|
|
81
|
+
}
|
|
82
|
+
}
|