dsh-task-ledger 0.1.12 → 0.1.14
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/dist/client.js +445 -556
- package/dist/client.js.map +1 -1
- package/dist/host/routes.js +40 -0
- package/dist/host/services/rules-injection.service.d.ts +32 -0
- package/dist/host/services/rules-injection.service.js +209 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/package.json +2 -2
package/dist/host/routes.js
CHANGED
|
@@ -9,6 +9,7 @@ import { getHistoryList, getHistoryTrend } from "./services/history.service.js";
|
|
|
9
9
|
import { actOnTask } from "./services/task.service.js";
|
|
10
10
|
import { continueTask, resolveWorkspace, } from "./services/continue.service.js";
|
|
11
11
|
import { resolveWorkspaces } from "./services/workspaces.service.js";
|
|
12
|
+
import { readSettings, writeSettings } from "./services/rules-injection.service.js";
|
|
12
13
|
/** API 前缀(唯一真源就在这一处)。 */
|
|
13
14
|
const API_PREFIX = "/api/task-ledger";
|
|
14
15
|
/** 核心 explorer 的只读文件路由(`serveFsRoute` 挂这里,client 的 `connectFs` 用同一个值)。 */
|
|
@@ -295,6 +296,45 @@ export function registerRoutes(webServer, deps) {
|
|
|
295
296
|
}
|
|
296
297
|
},
|
|
297
298
|
});
|
|
299
|
+
// 会话规则注入设置(插件自有文件,非宿主配置区):GET 读、POST 保存 / 恢复默认。
|
|
300
|
+
webServer.register({
|
|
301
|
+
kind: "exact",
|
|
302
|
+
path: `${API_PREFIX}/rules-injection`,
|
|
303
|
+
handler: async (req, res) => {
|
|
304
|
+
if (req.method !== "POST") {
|
|
305
|
+
try {
|
|
306
|
+
sendJson(res, 200, readSettings());
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
sendFailure(res, error);
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
let raw;
|
|
315
|
+
try {
|
|
316
|
+
raw = JSON.parse(await readBody(req));
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
throw new Error(`${BAD_REQUEST}请求体不是合法 JSON`);
|
|
320
|
+
}
|
|
321
|
+
const body = (raw ?? {});
|
|
322
|
+
const input = {};
|
|
323
|
+
if (typeof body.enabled === "boolean")
|
|
324
|
+
input.enabled = body.enabled;
|
|
325
|
+
if (body.lang === "zh" || body.lang === "en")
|
|
326
|
+
input.lang = body.lang;
|
|
327
|
+
if (typeof body.text === "string")
|
|
328
|
+
input.text = body.text;
|
|
329
|
+
if (body.action === "reset")
|
|
330
|
+
input.reset = true;
|
|
331
|
+
sendJson(res, 200, writeSettings(input));
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
sendWriteFailure(res, error);
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
});
|
|
298
338
|
// 任务动作(在办行内的按钮):认领 / 释放 / 取消 / 结案。前端只给工作区 id + 任务号,
|
|
299
339
|
// 台账根由 host 解析,改台账一律交给 skill(claim / set --release / close),插件不碰文件。
|
|
300
340
|
//
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type RulesLang = "zh" | "en";
|
|
2
|
+
/** 恢复默认的唯一参照:中英各一份写死常量;用户保存的值另存,写入不覆盖它们。 */
|
|
3
|
+
export declare const DEFAULT_RULES_TEXT_ZH: string;
|
|
4
|
+
/** 英文与中文条目对齐,格式同为 `- **关键词**:…`;最后那句问话按定稿保留中文原句。 */
|
|
5
|
+
export declare const DEFAULT_RULES_TEXT_EN: string;
|
|
6
|
+
export interface RulesInjectionView {
|
|
7
|
+
readonly enabled: boolean;
|
|
8
|
+
/** 落盘的语言:注入按它挑默认文案。 */
|
|
9
|
+
readonly lang: RulesLang;
|
|
10
|
+
/** 当前生效文案(用户值或该语言默认值)。 */
|
|
11
|
+
readonly text: string;
|
|
12
|
+
readonly defaultText: string;
|
|
13
|
+
/** 是否保存过用户自定义文案(设置页的小标识用它,不跟着草稿变)。 */
|
|
14
|
+
readonly customized: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface WriteSettingsInput {
|
|
17
|
+
readonly enabled?: boolean;
|
|
18
|
+
readonly lang?: RulesLang;
|
|
19
|
+
readonly text?: string;
|
|
20
|
+
readonly reset?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** GET 与注入都走这里:按文件里落盘的 lang 返回。 */
|
|
23
|
+
export declare function readSettings(): RulesInjectionView;
|
|
24
|
+
/** 原子替换写盘;恢复默认或「内容与默认一致」都只清用户值,不动写死的常量。 */
|
|
25
|
+
export declare function writeSettings(input: WriteSettingsInput): RulesInjectionView;
|
|
26
|
+
type Listener = (...args: never[]) => unknown;
|
|
27
|
+
export interface InjectionContext {
|
|
28
|
+
on(event: string, listener: Listener): unknown;
|
|
29
|
+
}
|
|
30
|
+
/** 注册 pre-step 监听;开关关时不注入,注入链路出错只记日志、不中断会话。 */
|
|
31
|
+
export declare function installRulesInjection(ctx: InjectionContext): void;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话规则注入:写死的中英默认文案、插件自有设置读写、host 半 pre-step 注入。
|
|
3
|
+
*
|
|
4
|
+
* 设置只落本机私有文件,不经宿主官方配置区;注入逻辑照同栈已实机跑通的 pre-step 实现,
|
|
5
|
+
* 不自加判据(用户 2026-11-10 定:不要过度设计)。
|
|
6
|
+
*
|
|
7
|
+
* 语言(用户 2026-11-10 定):注入在 host 半,拿不到宿主渲染器的 t 席位,所以由 client
|
|
8
|
+
* 在写请求里带当前界面语言、落盘为 lang;默认文案按 lang 挑,用户自定义文案只有一份、
|
|
9
|
+
* 不分语言。保存时按本次请求语言比对该语言默认值,相等就不落用户值、继续用默认。
|
|
10
|
+
*/
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
/** 恢复默认的唯一参照:中英各一份写死常量;用户保存的值另存,写入不覆盖它们。 */
|
|
16
|
+
export const DEFAULT_RULES_TEXT_ZH = [
|
|
17
|
+
"任务台账工具[task-ledger skill]使用规则:",
|
|
18
|
+
"",
|
|
19
|
+
"- **凡任务进度、工作事项与工作日志**:(新建 / 推进 / 查询 / 结案)一律调用 task-ledger skill 管理;进度 / 历史任务类问题一律走它作答,不凭对话记忆或自建清单顶替。",
|
|
20
|
+
"- **开工锚点**:仅当用户明确说要新建(如「开个新坑」「先记一下」「建个任务」)才登记;提问 / 查询 / 讨论 / 顺手小改不算,拿不准先问,未经明确指令不得擅自新建任务。",
|
|
21
|
+
"- **怎么建、按什么顺序**:命令的授权与确认口径,一律以 task-ledger skill 的说明为准。",
|
|
22
|
+
"- **顺手问一句**:用户在推进一件跨轮的事(这轮答完、手上还有下一步动作没做完)、却从没提过记账时,答完问一次「你现在这个任务是需要帮你建一个台账吗?(如果无需,忽略这句话,也不用去回复。)」;本会话问过一次就别再问,用户不接就当他不需要。",
|
|
23
|
+
].join("\n");
|
|
24
|
+
/** 英文与中文条目对齐,格式同为 `- **关键词**:…`;最后那句问话按定稿保留中文原句。 */
|
|
25
|
+
export const DEFAULT_RULES_TEXT_EN = [
|
|
26
|
+
"Usage rules for the task-ledger tool [task-ledger skill]:",
|
|
27
|
+
"",
|
|
28
|
+
"- **Task progress, work items and work logs**: (create / advance / query / close) go through the task-ledger skill; answer questions about progress and task history from it, never from conversation memory or a hand-rolled list.",
|
|
29
|
+
'- **Creation anchor**: register a task only when the user explicitly asks for a new one ("open a task", "log this first", "create a task"); questions, queries, discussion and small incidental edits do not count — ask when unsure, and never create a task without an explicit instruction.',
|
|
30
|
+
"- **How to create and in what order**: the authorization and confirmation rules all follow the task-ledger skill's own instructions.",
|
|
31
|
+
'- **Ask in passing**: when the user is pushing forward work that spans turns (this turn ends with actions still on your plate) and has never mentioned tracking it, ask once after answering: "你现在这个任务是需要帮你建一个台账吗?(如果无需,忽略这句话,也不用去回复。)"; once asked in a session, do not ask again; no reply means ignore it.',
|
|
32
|
+
].join("\n");
|
|
33
|
+
const DEFAULTS = {
|
|
34
|
+
zh: DEFAULT_RULES_TEXT_ZH,
|
|
35
|
+
en: DEFAULT_RULES_TEXT_EN,
|
|
36
|
+
};
|
|
37
|
+
/** 注入块标记:去重与压缩判定靠它,照同栈实现。 */
|
|
38
|
+
const MANAGED_HEADER = "<!-- dsh-task-ledger:managed -->";
|
|
39
|
+
function isRulesLang(value) {
|
|
40
|
+
return value === "zh" || value === "en";
|
|
41
|
+
}
|
|
42
|
+
/** 比对归一化:只用来判断「用户有没有改」,不改落盘值。 */
|
|
43
|
+
function normalizeForCompare(value) {
|
|
44
|
+
return value.replace(/\r\n/g, "\n").trim();
|
|
45
|
+
}
|
|
46
|
+
function settingsFile() {
|
|
47
|
+
const home = process.env.TASK_LEDGER_HOME ?? join(homedir(), ".taskledger");
|
|
48
|
+
return join(home, "rules-injection.json");
|
|
49
|
+
}
|
|
50
|
+
/** 文件不存在或坏掉都按默认处理,不覆盖磁盘。 */
|
|
51
|
+
function readStored() {
|
|
52
|
+
const fallback = { enabled: true, lang: "zh", text: null };
|
|
53
|
+
try {
|
|
54
|
+
const raw = JSON.parse(readFileSync(settingsFile(), "utf8"));
|
|
55
|
+
if (raw === null || typeof raw !== "object")
|
|
56
|
+
return fallback;
|
|
57
|
+
const saved = raw;
|
|
58
|
+
return {
|
|
59
|
+
enabled: typeof saved.enabled === "boolean" ? saved.enabled : true,
|
|
60
|
+
lang: isRulesLang(saved.lang) ? saved.lang : "zh",
|
|
61
|
+
text: typeof saved.text === "string" ? saved.text : null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function toView(stored) {
|
|
69
|
+
return {
|
|
70
|
+
enabled: stored.enabled,
|
|
71
|
+
lang: stored.lang,
|
|
72
|
+
text: stored.text ?? DEFAULTS[stored.lang],
|
|
73
|
+
defaultText: DEFAULTS[stored.lang],
|
|
74
|
+
customized: stored.text !== null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** GET 与注入都走这里:按文件里落盘的 lang 返回。 */
|
|
78
|
+
export function readSettings() {
|
|
79
|
+
return toView(readStored());
|
|
80
|
+
}
|
|
81
|
+
/** 原子替换写盘;恢复默认或「内容与默认一致」都只清用户值,不动写死的常量。 */
|
|
82
|
+
export function writeSettings(input) {
|
|
83
|
+
const current = readStored();
|
|
84
|
+
const lang = isRulesLang(input.lang) ? input.lang : current.lang;
|
|
85
|
+
let text;
|
|
86
|
+
if (input.reset === true)
|
|
87
|
+
text = null;
|
|
88
|
+
else if (typeof input.text === "string")
|
|
89
|
+
text =
|
|
90
|
+
normalizeForCompare(input.text) === normalizeForCompare(DEFAULTS[lang])
|
|
91
|
+
? null
|
|
92
|
+
: input.text;
|
|
93
|
+
else
|
|
94
|
+
text = current.text;
|
|
95
|
+
const stored = {
|
|
96
|
+
enabled: typeof input.enabled === "boolean" ? input.enabled : current.enabled,
|
|
97
|
+
lang,
|
|
98
|
+
text,
|
|
99
|
+
};
|
|
100
|
+
const file = settingsFile();
|
|
101
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
102
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
103
|
+
writeFileSync(tmp, JSON.stringify(stored, null, 2), "utf8");
|
|
104
|
+
renameSync(tmp, file);
|
|
105
|
+
return toView(stored);
|
|
106
|
+
}
|
|
107
|
+
function hasUserTurn(messages) {
|
|
108
|
+
return messages.some((message) => {
|
|
109
|
+
if (message === null || typeof message !== "object")
|
|
110
|
+
return false;
|
|
111
|
+
const record = message;
|
|
112
|
+
const source = record.source;
|
|
113
|
+
const isUser = source === "user" ||
|
|
114
|
+
(source !== null &&
|
|
115
|
+
typeof source === "object" &&
|
|
116
|
+
source.kind === "user");
|
|
117
|
+
if (!isUser)
|
|
118
|
+
return false;
|
|
119
|
+
const content = record.content;
|
|
120
|
+
if (typeof content === "string")
|
|
121
|
+
return content.trim().length > 0;
|
|
122
|
+
if (Array.isArray(content))
|
|
123
|
+
return content.some((part) => {
|
|
124
|
+
if (part === null || typeof part !== "object")
|
|
125
|
+
return false;
|
|
126
|
+
const text = part.text;
|
|
127
|
+
return typeof text === "string" && text.trim().length > 0;
|
|
128
|
+
});
|
|
129
|
+
return false;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** 照同栈实现:只扫最近 30 条事件,按标记判存在;读不到日志一律 unknown(视为仍在)。 */
|
|
133
|
+
function stillInContext(session) {
|
|
134
|
+
const snap = session?.snapshotEvents;
|
|
135
|
+
const read = typeof snap === "function"
|
|
136
|
+
? () => snap.call(session) ?? []
|
|
137
|
+
: Array.isArray(session?.events)
|
|
138
|
+
? () => session?.events ?? []
|
|
139
|
+
: undefined;
|
|
140
|
+
if (read === undefined)
|
|
141
|
+
return "unknown";
|
|
142
|
+
try {
|
|
143
|
+
const events = read();
|
|
144
|
+
for (let i = events.length - 1; i >= Math.max(0, events.length - 30); i--) {
|
|
145
|
+
if (JSON.stringify(events[i]).includes(MANAGED_HEADER))
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return "unknown";
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** 注册 pre-step 监听;开关关时不注入,注入链路出错只记日志、不中断会话。 */
|
|
155
|
+
export function installRulesInjection(ctx) {
|
|
156
|
+
const states = new Map();
|
|
157
|
+
const onPreStep = async (payload, next) => {
|
|
158
|
+
const decision = await next();
|
|
159
|
+
try {
|
|
160
|
+
const settings = readSettings();
|
|
161
|
+
if (!settings.enabled)
|
|
162
|
+
return decision;
|
|
163
|
+
if (payload?.signal?.aborted === true)
|
|
164
|
+
return decision;
|
|
165
|
+
if (decision.kind !== undefined && decision.kind !== "enter")
|
|
166
|
+
return decision;
|
|
167
|
+
const messages = Array.isArray(decision.messages) ? decision.messages : [];
|
|
168
|
+
if (!hasUserTurn(messages))
|
|
169
|
+
return decision;
|
|
170
|
+
const session = payload?.agent?.session;
|
|
171
|
+
if (session?.header?.origin === "subagent")
|
|
172
|
+
return decision;
|
|
173
|
+
const id = session?.id ?? "default";
|
|
174
|
+
const state = states.get(id) ?? { injected: false, observed: false };
|
|
175
|
+
const present = stillInContext(session);
|
|
176
|
+
if (state.injected) {
|
|
177
|
+
if (present === "unknown")
|
|
178
|
+
return decision;
|
|
179
|
+
if (present === true) {
|
|
180
|
+
state.observed = true;
|
|
181
|
+
states.set(id, state);
|
|
182
|
+
return decision;
|
|
183
|
+
}
|
|
184
|
+
if (!state.observed)
|
|
185
|
+
return decision;
|
|
186
|
+
state.injected = false;
|
|
187
|
+
}
|
|
188
|
+
const message = {
|
|
189
|
+
id: randomUUID(),
|
|
190
|
+
role: "user",
|
|
191
|
+
content: [{ type: "text", text: `${MANAGED_HEADER}\n${settings.text}` }],
|
|
192
|
+
source: { kind: "plugin:dsh-task-ledger", form: "recall" },
|
|
193
|
+
};
|
|
194
|
+
state.injected = true;
|
|
195
|
+
states.set(id, state);
|
|
196
|
+
return { ...decision, messages: [...messages, message] };
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
console.warn("[task-ledger] 会话规则注入失败,本轮跳过", error);
|
|
200
|
+
return decision;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
ctx.on("agent/pre-step", onPreStep);
|
|
204
|
+
ctx.on("agent/disposed", ((payload) => {
|
|
205
|
+
const id = payload?.session?.id;
|
|
206
|
+
if (typeof id === "string")
|
|
207
|
+
states.delete(id);
|
|
208
|
+
}));
|
|
209
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ interface PluginContext {
|
|
|
21
21
|
effect(setup: () => (() => void) | undefined): void;
|
|
22
22
|
/** 按名取服务(取不到给 undefined)。**读属性**在未 inject 的模块上会直接 throw ⇒ 见 serviceOf。 */
|
|
23
23
|
get(name: string): unknown;
|
|
24
|
+
/** 组合内事件监听(agent/pre-step 等);签名按宿主 cordis 面。 */
|
|
25
|
+
on(event: string, listener: (...args: never[]) => unknown): unknown;
|
|
24
26
|
}
|
|
25
27
|
export declare function apply(ctx: PluginContext): void;
|
|
26
28
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { serveFsRoute } from "dsh-plugin-core/host";
|
|
14
14
|
import { FS_ROUTE, registerRoutes } from "./host/routes.js";
|
|
15
|
+
import { installRulesInjection } from "./host/services/rules-injection.service.js";
|
|
15
16
|
import { buildSkillRegistration, } from "./host/skill/registration.js";
|
|
16
17
|
export const name = "dsh-task-ledger";
|
|
17
18
|
/**
|
|
@@ -102,4 +103,6 @@ export function apply(ctx) {
|
|
|
102
103
|
return dispose;
|
|
103
104
|
});
|
|
104
105
|
});
|
|
106
|
+
// 会话规则注入:监听 pre-step,开关由插件自有设置文件控制(无设置文件时默认开)
|
|
107
|
+
installRulesInjection(ctx);
|
|
105
108
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-task-ledger",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Tasks are created, claimed, advanced and closed by the agent and written straight into a plain-text ledger in the workspace, so the owner, the current state and the next step are always on record. It is not limited to development work — research, documentation, presentations and video production all fit. No cloud service is involved: everything is managed locally. Single-user and multi-user both work, with Git synchronisation, and every action is on record. The control panel summarises in-flight, overdue and stalled work with weekly trends.",
|
|
5
5
|
"icon": "./assets/icon.svg",
|
|
6
6
|
"keywords": [
|
|
@@ -65,6 +65,6 @@
|
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"@deepseek-ai/schemastery": "^3.18.5",
|
|
68
|
-
"dsh-plugin-core": "^0.35.
|
|
68
|
+
"dsh-plugin-core": "^0.35.21"
|
|
69
69
|
}
|
|
70
70
|
}
|