dsh-palimpsest 0.1.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/LICENSE +21 -0
- package/README.en.md +250 -0
- package/README.md +239 -0
- package/cordis.patch.yml +9 -0
- package/lib/core/concurrency.js +53 -0
- package/lib/core/filter.js +21 -0
- package/lib/core/limit.js +45 -0
- package/lib/core/messages.js +32 -0
- package/lib/core/privacy.js +52 -0
- package/lib/core/redact.js +88 -0
- package/lib/core/render.js +139 -0
- package/lib/core/scan.js +43 -0
- package/lib/core/scope.js +48 -0
- package/lib/core/snippet.js +43 -0
- package/lib/core/text.js +41 -0
- package/lib/core/transcript.js +35 -0
- package/lib/core/window.js +18 -0
- package/lib/index.js +24 -0
- package/lib/queries.js +131 -0
- package/lib/search.js +138 -0
- package/lib/tools/context.js +35 -0
- package/lib/tools/list.js +70 -0
- package/lib/tools/output.js +16 -0
- package/lib/tools/publish.js +16 -0
- package/lib/tools/read.js +117 -0
- package/lib/tools/search.js +98 -0
- package/package.json +52 -0
package/lib/queries.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// 与 ctx.sessionQuery 的数据装配层。
|
|
2
|
+
// 职责:把 DSH 的会话记录/事件折成插件自己的摘要与对话条目,
|
|
3
|
+
// 并把单点失败隔离成「这一条没有」,而不是让整个工具调用失败。
|
|
4
|
+
|
|
5
|
+
import { toTranscript } from './core/transcript.js';
|
|
6
|
+
import { inScope } from './core/scope.js';
|
|
7
|
+
import { byCreatedDesc } from './core/scan.js';
|
|
8
|
+
import { isAbortError, mapWithConcurrency, throwIfAborted } from './core/concurrency.js';
|
|
9
|
+
|
|
10
|
+
/** 同时读取的会话日志上限,避免一次打开过多解码器。 */
|
|
11
|
+
const SUMMARY_CONCURRENCY = 6;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 值得读日志的会话数下限与上限。
|
|
15
|
+
* 读日志要解压整个会话文件,所以候选窗口必须有界:否则 limit=1 也会把
|
|
16
|
+
* 工作目录下所有会话各读一遍,目录里会话一多就会拖到工具超时。
|
|
17
|
+
*/
|
|
18
|
+
const INSPECT_MIN = 30;
|
|
19
|
+
const INSPECT_MAX = 120;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 执行一次可能失败的数据读取,失败时返回兜底值。
|
|
23
|
+
* 取消错误例外:它必须向外传播,否则「用户已取消」会被伪装成「没有数据」。
|
|
24
|
+
* @param {Function} run 异步读取。
|
|
25
|
+
* @param {unknown} fallback 失败时的返回值。
|
|
26
|
+
* @returns {Promise<unknown>} 读取结果或兜底值。
|
|
27
|
+
*/
|
|
28
|
+
export async function safe(run, fallback) {
|
|
29
|
+
try {
|
|
30
|
+
return await run();
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (isAbortError(error)) throw error;
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 统计一个会话的事件数与活跃时间;读不出事件时活跃时间为未知而不是 0。 */
|
|
38
|
+
function countEvents(events) {
|
|
39
|
+
let lastTime;
|
|
40
|
+
let userMessages = 0;
|
|
41
|
+
for (const event of events) {
|
|
42
|
+
if (Number.isFinite(event?.time)) lastTime = Math.max(lastTime ?? 0, event.time);
|
|
43
|
+
if (event?.type === 'user/message') userMessages += 1;
|
|
44
|
+
}
|
|
45
|
+
return { lastTime, eventCount: Array.isArray(events) ? events.length : 0, userMessages };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 由调用方要的数量推出候选窗口大小。 */
|
|
49
|
+
function inspectionLimit(limit) {
|
|
50
|
+
const wanted = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : INSPECT_MIN;
|
|
51
|
+
return Math.min(INSPECT_MAX, Math.max(INSPECT_MIN, wanted * 3));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 按最后活跃时间倒序。 */
|
|
55
|
+
function byRecency(left, right) {
|
|
56
|
+
return (right.lastTime ?? 0) - (left.lastTime ?? 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 批量取标题;个别失败只影响该条。 */
|
|
60
|
+
async function titleMap(sessionQuery, ids) {
|
|
61
|
+
if (!ids.length) return new Map();
|
|
62
|
+
const results = await safe(() => sessionQuery.readTitleSnapshots(ids), []);
|
|
63
|
+
const map = new Map();
|
|
64
|
+
for (const result of results) {
|
|
65
|
+
if (result?.status === 'fulfilled') map.set(result.sessionId, result.value?.title?.title ?? '');
|
|
66
|
+
}
|
|
67
|
+
return map;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 把一个会话记录折成摘要;标题来自批量结果,日志读失败则统计为空。 */
|
|
71
|
+
async function summarize(sessionQuery, record, context) {
|
|
72
|
+
throwIfAborted(context.signal);
|
|
73
|
+
const id = record.header.id;
|
|
74
|
+
const events = await safe(() => sessionQuery.listEvents(id), []);
|
|
75
|
+
return {
|
|
76
|
+
id,
|
|
77
|
+
title: context.titles.get(id) ?? '',
|
|
78
|
+
createdAt: record.header.createdAt,
|
|
79
|
+
...countEvents(events),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 列出与给定工作目录相同的历史会话。
|
|
85
|
+
*
|
|
86
|
+
* 先按创建时间取一个有限候选窗口,再在窗口内按真实活跃时间排序:
|
|
87
|
+
* 读日志是有成本的,不能为了排序把范围内所有会话都读一遍。
|
|
88
|
+
* @param {object} sessionQuery ctx.sessionQuery 服务。
|
|
89
|
+
* @param {object} options 含 `cwd`、`limit`、`includeSubagents`、`signal`。
|
|
90
|
+
* @returns {Promise<{entries: Array<object>, total: number, inspected: number}>} 摘要、范围内总数与被检查数。
|
|
91
|
+
*/
|
|
92
|
+
export async function listSessionsForCwd(sessionQuery, options) {
|
|
93
|
+
const { cwd, limit, includeSubagents = false, signal } = options;
|
|
94
|
+
const records = await sessionQuery.listSessions(signal);
|
|
95
|
+
const scoped = records.filter((record) => inScope(record.header, { cwd, includeSubagents }));
|
|
96
|
+
const candidates = scoped
|
|
97
|
+
.toSorted(byCreatedDesc)
|
|
98
|
+
.slice(0, inspectionLimit(limit));
|
|
99
|
+
const titles = await titleMap(sessionQuery, candidates.map((record) => record.header.id));
|
|
100
|
+
const summaries = await mapWithConcurrency(candidates, SUMMARY_CONCURRENCY, (record) =>
|
|
101
|
+
summarize(sessionQuery, record, { titles, signal }),
|
|
102
|
+
);
|
|
103
|
+
return {
|
|
104
|
+
entries: summaries.toSorted(byRecency).slice(0, limit),
|
|
105
|
+
total: scoped.length,
|
|
106
|
+
inspected: candidates.length,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 读取一个会话的完整对话条目,连同它的会话头。
|
|
112
|
+
* 会话头要一并返回:调用方得靠它校验目标会话是否属于当前工作目录。
|
|
113
|
+
* @param {object} sessionQuery ctx.sessionQuery 服务。
|
|
114
|
+
* @param {string} sessionId 会话 id。
|
|
115
|
+
* @returns {Promise<{session: object|undefined, items: Array<object>}>} 会话头与对话条目。
|
|
116
|
+
*/
|
|
117
|
+
export async function transcriptOf(sessionQuery, sessionId) {
|
|
118
|
+
const surface = await sessionQuery.readSurface(sessionId);
|
|
119
|
+
return { session: surface?.session, items: toTranscript(surface?.events) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 批量补会话标题,个别失败只影响该条。
|
|
124
|
+
* @param {object} sessionQuery ctx.sessionQuery 服务。
|
|
125
|
+
* @param {Array<object>} entries 待补标题的条目,含 `id`。
|
|
126
|
+
* @returns {Promise<Array<object>>} 补好标题的新数组。
|
|
127
|
+
*/
|
|
128
|
+
export async function attachTitles(sessionQuery, entries) {
|
|
129
|
+
const titles = await titleMap(sessionQuery, entries.map((entry) => entry.id));
|
|
130
|
+
return entries.map((entry) => ({ ...entry, title: titles.get(entry.id) ?? entry.title ?? '' }));
|
|
131
|
+
}
|
package/lib/search.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// 工作目录范围的关键词检索:优先走 DSH 全文索引,
|
|
2
|
+
// 索引未启用时回退到「逐会话字面扫描」,因此插件开箱即用、不依赖全局配置改动。
|
|
3
|
+
|
|
4
|
+
import { safe, attachTitles } from './queries.js';
|
|
5
|
+
import { rankSessionHits, byCreatedDesc } from './core/scan.js';
|
|
6
|
+
import { snippetAround } from './core/snippet.js';
|
|
7
|
+
import { inScope, normalizeCwd } from './core/scope.js';
|
|
8
|
+
import { isAbortError, mapWithConcurrency, throwIfAborted } from './core/concurrency.js';
|
|
9
|
+
|
|
10
|
+
/** 字面回退时最多检查的会话数,避免一次搜索读爆整个历史。 */
|
|
11
|
+
const SCAN_SESSION_LIMIT = 40;
|
|
12
|
+
|
|
13
|
+
/** 同时读取的会话日志上限。 */
|
|
14
|
+
const SCAN_CONCURRENCY = 4;
|
|
15
|
+
|
|
16
|
+
/** 每个会话最多带回的命中片段数;命中总数另计。 */
|
|
17
|
+
const MATCHES_PER_SESSION = 3;
|
|
18
|
+
|
|
19
|
+
/** DSH 明确表示「全文检索未启用」,这是正常状态而不是故障。 */
|
|
20
|
+
const SEARCH_DISABLED = 'SESSION_QUERY_SEARCH_DISABLED';
|
|
21
|
+
|
|
22
|
+
/** 组装全文索引请求;工作目录缺失时退回全局检索。 */
|
|
23
|
+
function indexRequest(request) {
|
|
24
|
+
const cwd = normalizeCwd(request.cwd);
|
|
25
|
+
const base = { query: request.query, limit: request.limit };
|
|
26
|
+
return cwd ? { ...base, sessionFilters: [{ kind: 'cwd', values: [cwd] }] } : base;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 清点入参必须把取消信号一起交给 DSH,否则按了停止它也不会停。 */
|
|
30
|
+
function execContext(request) {
|
|
31
|
+
return request.signal ? { signal: request.signal } : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 尝试走全文索引通道。
|
|
36
|
+
* 取消要向外抛;其余失败降级为扫描,并把失败原因带回给调用方,
|
|
37
|
+
* 不能把「索引坏了」说成「索引本来就没开」。
|
|
38
|
+
*/
|
|
39
|
+
async function attemptIndex(sessionQuery, request) {
|
|
40
|
+
try {
|
|
41
|
+
return { page: await sessionQuery.searchSessions(indexRequest(request), execContext(request)) };
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (isAbortError(error)) throw error;
|
|
44
|
+
return { error };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 索引未启用是正常状态;其他失败才算降级,需要如实告知。 */
|
|
49
|
+
function degradationOf(error) {
|
|
50
|
+
if (!error || error.code === SEARCH_DISABLED) return undefined;
|
|
51
|
+
return error.code || error.message || '全文索引调用失败';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 全文索引命中 → 统一条目形状。 */
|
|
55
|
+
function toIndexEntry(hit) {
|
|
56
|
+
const best = hit.bestMatch ?? {};
|
|
57
|
+
return {
|
|
58
|
+
id: hit.header.id,
|
|
59
|
+
title: '',
|
|
60
|
+
lastTime: Number.isFinite(best.time) ? best.time : hit.header.createdAt,
|
|
61
|
+
matches: [{ seq: best.seq, time: best.time, snippet: best.snippet ?? '' }],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 索引结果按与其他通道相同的范围规则过滤;分页内相对次序是相关度,必须原样保留。 */
|
|
66
|
+
function indexEntries(page, request) {
|
|
67
|
+
return page.items.filter((hit) => inScope(hit.header, request)).map(toIndexEntry);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 取一个会话内命中该关键词的全部事件。 */
|
|
71
|
+
async function matchingDocuments(sessionQuery, sessionId, query) {
|
|
72
|
+
return safe(() => sessionQuery.filterEvents(sessionId, [{ kind: 'text', text: query }]), []);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 最近一次命中的时间;用归约而非展开,避免超长数组撑爆调用栈。 */
|
|
76
|
+
function lastHitTime(documents) {
|
|
77
|
+
return documents.reduce((latest, doc) => (Number.isFinite(doc.time) && doc.time > latest ? doc.time : latest), 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 扫描命中 → 统一条目形状,只展示前几处片段,另记命中总数供排序与展示。 */
|
|
81
|
+
function toScanEntry(record, documents, request) {
|
|
82
|
+
return {
|
|
83
|
+
id: record.header.id,
|
|
84
|
+
title: '',
|
|
85
|
+
lastTime: Math.max(record.header.createdAt, lastHitTime(documents)),
|
|
86
|
+
matchCount: documents.length,
|
|
87
|
+
matches: documents.slice(0, MATCHES_PER_SESSION).map((doc) => ({
|
|
88
|
+
seq: doc.seq,
|
|
89
|
+
time: doc.time,
|
|
90
|
+
snippet: snippetAround(doc.text, request.query, request.snippetChars),
|
|
91
|
+
})),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 逐会话字面扫描;每个候选只读一次会话日志,并发受限。
|
|
97
|
+
* 候选按创建时间截断,所以要如实回报「扫了几个 / 范围内共几个」。
|
|
98
|
+
*/
|
|
99
|
+
async function scanSessions(sessionQuery, request) {
|
|
100
|
+
const records = await sessionQuery.listSessions(request.signal);
|
|
101
|
+
const scoped = records.filter((record) => inScope(record.header, request));
|
|
102
|
+
const candidates = scoped.toSorted(byCreatedDesc).slice(0, SCAN_SESSION_LIMIT);
|
|
103
|
+
const found = await mapWithConcurrency(candidates, SCAN_CONCURRENCY, async (record) => {
|
|
104
|
+
throwIfAborted(request.signal);
|
|
105
|
+
const documents = await matchingDocuments(sessionQuery, record.header.id, request.query);
|
|
106
|
+
return documents.length ? toScanEntry(record, documents, request) : undefined;
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
entries: rankSessionHits(found.filter(Boolean), request.limit),
|
|
110
|
+
scanned: candidates.length,
|
|
111
|
+
total: scoped.length,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 在工作目录范围内按关键词检索历史会话。
|
|
117
|
+
* 两条通道的结果统一补标题,模型才能靠标题认出「是哪次对话」。
|
|
118
|
+
* @param {object} sessionQuery ctx.sessionQuery 服务。
|
|
119
|
+
* @param {object} request 含 `query`、`cwd`、`limit`、`snippetChars`、`excludeSessionId`、`signal`。
|
|
120
|
+
* @returns {Promise<object>} 检索通道、命中、扫描规模与降级原因。
|
|
121
|
+
*/
|
|
122
|
+
export async function searchSessions(sessionQuery, request) {
|
|
123
|
+
const attempt = await attemptIndex(sessionQuery, request);
|
|
124
|
+
if (attempt.page) {
|
|
125
|
+
return {
|
|
126
|
+
engine: 'index',
|
|
127
|
+
entries: await attachTitles(sessionQuery, indexEntries(attempt.page, request)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const { entries, scanned, total } = await scanSessions(sessionQuery, request);
|
|
131
|
+
return {
|
|
132
|
+
engine: 'scan',
|
|
133
|
+
degraded: degradationOf(attempt.error),
|
|
134
|
+
scanned,
|
|
135
|
+
total,
|
|
136
|
+
entries: await attachTitles(sessionQuery, entries),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// 调用现场的环境信息。记忆范围锚定在「发起调用的会话所在的工作目录」,
|
|
2
|
+
// 而不是插件进程的启动目录。
|
|
3
|
+
|
|
4
|
+
import { normalizeCwd } from '../core/scope.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 取当前工具调用所在的工作目录。
|
|
8
|
+
*
|
|
9
|
+
* 只认调用方会话头里的 cwd,**故意不回退到 `process.cwd()`**:
|
|
10
|
+
* 那是 dsh 服务的启动目录,不是本会话的工作目录,回退会悄悄把检索范围
|
|
11
|
+
* 放大到另一个项目,与本插件「只读同一工作目录」的承诺相矛盾。
|
|
12
|
+
* @param {object} exec 工具执行上下文,含调用方 agent。
|
|
13
|
+
* @returns {string} 归一化的绝对路径;取不到时为空串,调用方应据此拒绝检索。
|
|
14
|
+
*/
|
|
15
|
+
export function currentCwd(exec) {
|
|
16
|
+
return normalizeCwd(exec?.agent?.session?.header?.cwd) ?? '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 无法确定工作目录时的统一说明。
|
|
21
|
+
* 宁可什么都不返回,也不猜一个目录去读别人的会话。
|
|
22
|
+
*/
|
|
23
|
+
export const UNKNOWN_CWD_NOTICE =
|
|
24
|
+
'无法确定当前会话的工作目录,已跳过检索:宁可什么都不返回,也不猜一个目录去读别的项目的会话。';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 取当前工具调用所在的会话 id。
|
|
28
|
+
* 检索时要把它排除:调用方自己的历史已经在上下文里,当成「记忆命中」纯属噪音。
|
|
29
|
+
* @param {object} exec 工具执行上下文,含调用方 agent。
|
|
30
|
+
* @returns {string|undefined} 会话 id;取不到时 undefined。
|
|
31
|
+
*/
|
|
32
|
+
export function currentSessionId(exec) {
|
|
33
|
+
const id = exec?.agent?.session?.header?.id;
|
|
34
|
+
return typeof id === 'string' && id ? id : undefined;
|
|
35
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
|
+
import { OUTPUT } from './output.js';
|
|
3
|
+
import { currentCwd, UNKNOWN_CWD_NOTICE } from './context.js';
|
|
4
|
+
import { publish } from './publish.js';
|
|
5
|
+
import { hidePrivate } from '../core/privacy.js';
|
|
6
|
+
import { listSessionsForCwd } from '../queries.js';
|
|
7
|
+
import { renderSessionList } from '../core/render.js';
|
|
8
|
+
import { clampCount } from '../core/limit.js';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_LIMIT = 20;
|
|
11
|
+
const MAX_LIMIT = 100;
|
|
12
|
+
const MAX_CHARS = 8000;
|
|
13
|
+
|
|
14
|
+
const DESCRIPTION = [
|
|
15
|
+
'列出与**当前工作目录相同**的历史会话(最近活跃的在前,含标题、最后活跃时间与规模)。',
|
|
16
|
+
'当用户说「继续上次」「之前我们聊过」「你忘了吗」,或你需要回忆此前的决定、进度、约定时,',
|
|
17
|
+
'先用本工具看清有哪些历史会话,再用 palimpsest_read 读取具体对话。当前会话自身也会出现在列表里。',
|
|
18
|
+
].join('');
|
|
19
|
+
|
|
20
|
+
const PARAMETERS = {
|
|
21
|
+
limit: {
|
|
22
|
+
type: 'integer',
|
|
23
|
+
description: `最多返回多少个会话,默认 ${DEFAULT_LIMIT},最大 ${MAX_LIMIT}。`,
|
|
24
|
+
},
|
|
25
|
+
includeSubagents: {
|
|
26
|
+
type: 'boolean',
|
|
27
|
+
description: '是否包含子代理会话,默认 false(只看你亲自参与的对话)。',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** 生成列表头部说明;空结果给出提示,候选被截断时如实说明。 */
|
|
32
|
+
function headingFor(cwd, result, includeSubagents) {
|
|
33
|
+
if (!result.entries.length) {
|
|
34
|
+
const note = includeSubagents ? '' : '(子代理会话未计)';
|
|
35
|
+
return `当前工作目录 \`${cwd}\` 下没有可读的历史会话${note}。`;
|
|
36
|
+
}
|
|
37
|
+
const head = `当前工作目录 \`${cwd}\` 下最近活跃的 ${result.entries.length} 个历史会话。用 palimpsest_read 读取某个会话的完整对话。`;
|
|
38
|
+
if (result.inspected < result.total) {
|
|
39
|
+
return `${head}注意:仅按最近创建检查了 ${result.inspected} 个会话(范围内共 ${result.total} 个),更早的未参与排序。`;
|
|
40
|
+
}
|
|
41
|
+
return head;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 创建 palimpsest_list 工具。
|
|
46
|
+
* @param {object} ctx 宿主上下文,需带 sessionQuery 服务。
|
|
47
|
+
* @returns {object} 注册用的工具定义。
|
|
48
|
+
*/
|
|
49
|
+
export function createListTool(ctx) {
|
|
50
|
+
return defineTool({
|
|
51
|
+
name: 'palimpsest_list',
|
|
52
|
+
description: DESCRIPTION,
|
|
53
|
+
parameters: PARAMETERS,
|
|
54
|
+
output: OUTPUT,
|
|
55
|
+
async execute(args, exec) {
|
|
56
|
+
const cwd = currentCwd(exec);
|
|
57
|
+
if (!cwd) return { content: UNKNOWN_CWD_NOTICE };
|
|
58
|
+
const includeSubagents = args.includeSubagents === true;
|
|
59
|
+
const result = await listSessionsForCwd(ctx.sessionQuery, {
|
|
60
|
+
cwd,
|
|
61
|
+
limit: clampCount(args.limit, DEFAULT_LIMIT, MAX_LIMIT),
|
|
62
|
+
includeSubagents,
|
|
63
|
+
signal: exec?.signal,
|
|
64
|
+
});
|
|
65
|
+
const { visible, hidden } = hidePrivate(result.entries);
|
|
66
|
+
const heading = headingFor(cwd, { ...result, entries: visible }, includeSubagents);
|
|
67
|
+
return { content: publish(renderSessionList(visible, { heading, maxChars: MAX_CHARS }), hidden) };
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// 三个工具共用的输出契约:一段给模型直接阅读的文本。
|
|
2
|
+
// 结构化字段留给 presentationMeta,不进入模型上下文。
|
|
3
|
+
//
|
|
4
|
+
// 注意输出侧 DSL:必填写在属性内的 `required: true`,不是顶层 `required` 数组。
|
|
5
|
+
|
|
6
|
+
/** 工具输出 schema 与渲染器。 */
|
|
7
|
+
export const OUTPUT = {
|
|
8
|
+
schema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
properties: {
|
|
12
|
+
content: { type: 'string', required: true },
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
render: (_args, value) => [{ type: 'text', text: String(value?.content ?? '') }],
|
|
16
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// 工具统一出口。取回的历史文本在交给模型之前要过两道:
|
|
2
|
+
// 打码已知形态的凭据,并声明它是不可信数据(不是指令)。
|
|
3
|
+
|
|
4
|
+
import { redact } from '../core/redact.js';
|
|
5
|
+
import { preface } from '../core/privacy.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 给渲染好的输出打码并加上前置声明。
|
|
9
|
+
* @param {string} rendered 已渲染好的输出文本。
|
|
10
|
+
* @param {number} hiddenCount 因私密标记被整段排除的会话数。
|
|
11
|
+
* @returns {string} 可直接交给模型的文本。
|
|
12
|
+
*/
|
|
13
|
+
export function publish(rendered, hiddenCount = 0) {
|
|
14
|
+
const { text, count } = redact(rendered);
|
|
15
|
+
return `${preface(count, hiddenCount)}\n\n${text}`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
|
+
import { OUTPUT } from './output.js';
|
|
3
|
+
import { currentCwd, UNKNOWN_CWD_NOTICE } from './context.js';
|
|
4
|
+
import { transcriptOf, safe } from '../queries.js';
|
|
5
|
+
import { selectMessages } from '../core/filter.js';
|
|
6
|
+
import { sliceMessages } from '../core/window.js';
|
|
7
|
+
import { renderTranscript, renderForward } from '../core/render.js';
|
|
8
|
+
import { clampCount } from '../core/limit.js';
|
|
9
|
+
import { matchesCwd } from '../core/scope.js';
|
|
10
|
+
import { throwIfAborted } from '../core/concurrency.js';
|
|
11
|
+
import { isPrivateTitle } from '../core/privacy.js';
|
|
12
|
+
import { publish } from './publish.js';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_LAST = 30;
|
|
15
|
+
const MAX_LAST = 500;
|
|
16
|
+
const DEFAULT_MAX_CHARS = 12000;
|
|
17
|
+
const MAX_MAX_CHARS = 60000;
|
|
18
|
+
|
|
19
|
+
const DESCRIPTION = [
|
|
20
|
+
'读取一个历史会话的对话内容(用户与助手的文本;默认不含工具结果与系统提示)。',
|
|
21
|
+
'先用 palimpsest_list 或 palimpsest_search 取得 sessionId。',
|
|
22
|
+
`默认只返回最后 ${DEFAULT_LAST} 条消息;内容超长时保留最近的并提示省略量,`,
|
|
23
|
+
'需要更早的内容时用 fromSeq 从指定事件序号往前读。',
|
|
24
|
+
].join('');
|
|
25
|
+
|
|
26
|
+
const PARAMETERS = {
|
|
27
|
+
sessionId: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
required: true,
|
|
30
|
+
description: '要读取的会话 id,例如 session-09a739ce-…。',
|
|
31
|
+
},
|
|
32
|
+
last: {
|
|
33
|
+
type: 'integer',
|
|
34
|
+
description: `只返回最后 N 条消息,默认 ${DEFAULT_LAST};传 0 表示全部。`,
|
|
35
|
+
},
|
|
36
|
+
fromSeq: {
|
|
37
|
+
type: 'integer',
|
|
38
|
+
description: '从该事件序号(含)开始向后读,用于分段读取超长会话;达到输出上限时会给出下一段的 fromSeq。',
|
|
39
|
+
},
|
|
40
|
+
includeTools: {
|
|
41
|
+
type: 'boolean',
|
|
42
|
+
description: '是否包含工具结果,默认 false。',
|
|
43
|
+
},
|
|
44
|
+
maxChars: {
|
|
45
|
+
type: 'integer',
|
|
46
|
+
description: `输出字符上限,默认 ${DEFAULT_MAX_CHARS}。`,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** `last` 参数语义:0 表示不限条数,其余收窄到 [1, MAX_LAST]。 */
|
|
51
|
+
function resolveLast(value) {
|
|
52
|
+
return value === 0 ? 0 : clampCount(value, DEFAULT_LAST, MAX_LAST);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 描述当前视图排除了什么,避免头部说明与实际内容不符。 */
|
|
56
|
+
function describeScope(includeTools) {
|
|
57
|
+
return includeTools ? '(含工具结果,已排除系统提示)' : '(已排除系统提示、工具结果与纯工具轨迹)';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 生成读取头部说明。 */
|
|
61
|
+
function headingFor({ sessionId, title, shown, total, includeTools }) {
|
|
62
|
+
const name = title ? `会话 ${sessionId}「${title}」` : `会话 ${sessionId}`;
|
|
63
|
+
return `${name}:共 ${total} 条消息${describeScope(includeTools)},以下为 ${shown} 条。`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 目标会话不属于当前工作目录时的拒绝说明。 */
|
|
67
|
+
function outOfScopeNotice(sessionId) {
|
|
68
|
+
return `会话 ${sessionId} 不在当前工作目录下,已拒绝读取:本工具只读同一工作目录的历史会话。`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 目标会话带私密标记时的拒绝说明。 */
|
|
72
|
+
function privateNotice(sessionId) {
|
|
73
|
+
return `会话 ${sessionId} 带私密标记,已拒绝读取:把标题里的标记去掉才能被回忆。`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 创建 palimpsest_read 工具。
|
|
78
|
+
* @param {object} ctx 宿主上下文,需带 sessionQuery 服务。
|
|
79
|
+
* @returns {object} 注册用的工具定义。
|
|
80
|
+
*/
|
|
81
|
+
export function createReadTool(ctx) {
|
|
82
|
+
return defineTool({
|
|
83
|
+
name: 'palimpsest_read',
|
|
84
|
+
description: DESCRIPTION,
|
|
85
|
+
parameters: PARAMETERS,
|
|
86
|
+
output: OUTPUT,
|
|
87
|
+
async execute(args, exec) {
|
|
88
|
+
const sessionId = args.sessionId;
|
|
89
|
+
const cwd = currentCwd(exec);
|
|
90
|
+
if (!cwd) return { content: UNKNOWN_CWD_NOTICE };
|
|
91
|
+
throwIfAborted(exec?.signal);
|
|
92
|
+
const includeTools = args.includeTools === true;
|
|
93
|
+
const title = await safe(() => ctx.sessionQuery.readTitle(sessionId), undefined);
|
|
94
|
+
// 私密标记要在解出正文**之前**判定:不该先把整段对话读出来,再决定给不给
|
|
95
|
+
if (isPrivateTitle(title?.title)) return { content: privateNotice(sessionId) };
|
|
96
|
+
const { session, items } = await transcriptOf(ctx.sessionQuery, sessionId);
|
|
97
|
+
// id 会随对话文本在会话之间流转,所以不能只凭 id 就交出内容
|
|
98
|
+
if (!matchesCwd(session, cwd)) return { content: outOfScopeNotice(sessionId) };
|
|
99
|
+
const selected = selectMessages(items, { includeTools });
|
|
100
|
+
const shown = sliceMessages(selected, { fromSeq: args.fromSeq, last: resolveLast(args.last) });
|
|
101
|
+
const heading = headingFor({
|
|
102
|
+
sessionId,
|
|
103
|
+
title: title?.title ?? '',
|
|
104
|
+
shown: shown.length,
|
|
105
|
+
total: selected.length,
|
|
106
|
+
includeTools,
|
|
107
|
+
});
|
|
108
|
+
const maxChars = clampCount(args.maxChars, DEFAULT_MAX_CHARS, MAX_MAX_CHARS);
|
|
109
|
+
// 指定了 fromSeq 就是「从这里往后读」,超限要保留头部并给出下一段起点;
|
|
110
|
+
// 否则是「读最近的」,超限保留尾部。
|
|
111
|
+
const content = Number.isFinite(args.fromSeq)
|
|
112
|
+
? renderForward(shown, { heading, maxChars })
|
|
113
|
+
: renderTranscript(shown, { heading, maxChars });
|
|
114
|
+
return { content: publish(content) };
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
|
+
import { OUTPUT } from './output.js';
|
|
3
|
+
import { currentCwd, currentSessionId, UNKNOWN_CWD_NOTICE } from './context.js';
|
|
4
|
+
import { publish } from './publish.js';
|
|
5
|
+
import { hidePrivate } from '../core/privacy.js';
|
|
6
|
+
import { searchSessions } from '../search.js';
|
|
7
|
+
import { renderHitList } from '../core/render.js';
|
|
8
|
+
import { clampCount } from '../core/limit.js';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_LIMIT = 10;
|
|
11
|
+
const MAX_LIMIT = 50;
|
|
12
|
+
const DEFAULT_SNIPPET = 200;
|
|
13
|
+
const MAX_SNIPPET = 500;
|
|
14
|
+
const MAX_CHARS = 8000;
|
|
15
|
+
|
|
16
|
+
const DESCRIPTION = [
|
|
17
|
+
'在**与当前工作目录相同**的历史会话里按关键词检索,返回命中的会话与上下文片段。',
|
|
18
|
+
'已知文件名、函数名、报错文本、某个决定或专有名词时,用它直接定位记忆,比逐个 palimpsest_read 快得多。',
|
|
19
|
+
'命中后用 palimpsest_read 读取该会话的完整上下文。检索按字面匹配,大小写不敏感。',
|
|
20
|
+
].join('');
|
|
21
|
+
|
|
22
|
+
const PARAMETERS = {
|
|
23
|
+
query: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
required: true,
|
|
26
|
+
description: '要检索的关键词或短语,按字面匹配。',
|
|
27
|
+
},
|
|
28
|
+
limit: {
|
|
29
|
+
type: 'integer',
|
|
30
|
+
description: `最多返回多少个会话,默认 ${DEFAULT_LIMIT},最大 ${MAX_LIMIT}。`,
|
|
31
|
+
},
|
|
32
|
+
snippetChars: {
|
|
33
|
+
type: 'integer',
|
|
34
|
+
description: `每条命中片段的字符数,默认 ${DEFAULT_SNIPPET},最大 ${MAX_SNIPPET}。`,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** 通道名称:索引坏了要说坏了,不能说成「本来就没启用」。 */
|
|
39
|
+
function channelOf(result) {
|
|
40
|
+
if (result.engine === 'index') return '全文索引';
|
|
41
|
+
return result.degraded ? '逐会话扫描(索引调用失败)' : '逐会话扫描(全文索引未启用)';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 生成检索头部说明。
|
|
46
|
+
* 降级原因与扫描截断都必须写出来:否则「没命中」会被读成「整个工作目录都没有」。
|
|
47
|
+
*/
|
|
48
|
+
function headingFor(cwd, query, result) {
|
|
49
|
+
const notes = [];
|
|
50
|
+
if (result.degraded) notes.push(`全文索引调用失败(${result.degraded}),已降级为逐会话扫描。`);
|
|
51
|
+
if (Number.isFinite(result.scanned) && result.scanned < result.total) {
|
|
52
|
+
notes.push(`仅扫描了最近创建的 ${result.scanned} 个会话(范围内共 ${result.total} 个),更早的未参与匹配。`);
|
|
53
|
+
}
|
|
54
|
+
const tail = notes.length ? `\n${notes.join('\n')}` : '';
|
|
55
|
+
const scope = `当前工作目录 \`${cwd}\``;
|
|
56
|
+
if (!result.entries.length) {
|
|
57
|
+
return `${scope} 下没有命中「${query}」的历史会话。可换个更短的关键词,或先用 palimpsest_list 看看有哪些会话。${tail}`;
|
|
58
|
+
}
|
|
59
|
+
return `${scope} 下命中「${query}」的会话(${result.entries.length} 个,经${channelOf(result)}):${tail}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 归一化调用参数。 */
|
|
63
|
+
function requestOf(args, scope) {
|
|
64
|
+
return {
|
|
65
|
+
query: scope.query,
|
|
66
|
+
cwd: scope.cwd,
|
|
67
|
+
excludeSessionId: scope.excludeSessionId,
|
|
68
|
+
signal: scope.signal,
|
|
69
|
+
limit: clampCount(args.limit, DEFAULT_LIMIT, MAX_LIMIT),
|
|
70
|
+
snippetChars: clampCount(args.snippetChars, DEFAULT_SNIPPET, MAX_SNIPPET),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 创建 palimpsest_search 工具。
|
|
76
|
+
* @param {object} ctx 宿主上下文,需带 sessionQuery 服务。
|
|
77
|
+
* @returns {object} 注册用的工具定义。
|
|
78
|
+
*/
|
|
79
|
+
export function createSearchTool(ctx) {
|
|
80
|
+
return defineTool({
|
|
81
|
+
name: 'palimpsest_search',
|
|
82
|
+
description: DESCRIPTION,
|
|
83
|
+
parameters: PARAMETERS,
|
|
84
|
+
output: OUTPUT,
|
|
85
|
+
async execute(args, exec) {
|
|
86
|
+
const cwd = currentCwd(exec);
|
|
87
|
+
if (!cwd) return { content: UNKNOWN_CWD_NOTICE };
|
|
88
|
+
const query = typeof args.query === 'string' ? args.query.trim() : '';
|
|
89
|
+
// 空查询会被 DSH 判为非法;直接说清楚,别让它降级成「历史里没有」
|
|
90
|
+
if (!query) return { content: '请提供要检索的关键词:query 不能为空或只有空白。' };
|
|
91
|
+
const scope = { cwd, query, excludeSessionId: currentSessionId(exec), signal: exec?.signal };
|
|
92
|
+
const result = await searchSessions(ctx.sessionQuery, requestOf(args, scope));
|
|
93
|
+
const { visible, hidden } = hidePrivate(result.entries);
|
|
94
|
+
const heading = headingFor(cwd, query, { ...result, entries: visible });
|
|
95
|
+
return { content: publish(renderHitList(visible, { heading, maxChars: MAX_CHARS }), hidden) };
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-palimpsest",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "让 DSH 智能体在新会话里按需检索同一工作目录下历史会话的记忆",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/cnkids/dsh-palimpsest.git"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/cnkids/dsh-palimpsest#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/cnkids/dsh-palimpsest/issues"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"registry": "https://registry.npmjs.org/"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"lib",
|
|
24
|
+
"cordis.patch.yml",
|
|
25
|
+
"README.md",
|
|
26
|
+
"README.en.md"
|
|
27
|
+
],
|
|
28
|
+
"dsh": {
|
|
29
|
+
"bundle": {
|
|
30
|
+
"patch": "./cordis.patch.yml"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@deepseek-ai/dsh-tools": "^0.1.5-rc.1"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "node --test 'test/*.test.mjs'",
|
|
41
|
+
"coverage": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info 'test/*.test.mjs'",
|
|
42
|
+
"sonar": "./scripts/sonar-check.sh"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"dsh",
|
|
46
|
+
"deepseek-harness",
|
|
47
|
+
"plugin",
|
|
48
|
+
"session",
|
|
49
|
+
"memory",
|
|
50
|
+
"dsh-plugin"
|
|
51
|
+
]
|
|
52
|
+
}
|