dsh-plugin-sessiongraph-tool 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The "this looks like a question about past conversations" reminder.
3
+ *
4
+ * The system prompt already says to search the session index instead of grepping `~/.dsh/sessions`.
5
+ * That is not enough in practice: a memory provider injects its own recall block on every step, so a
6
+ * model that has just been handed a summary of the past reaches for the summary and never opens the
7
+ * index. The summary is cheap and always present; the index is one tool call away. This module closes
8
+ * that gap at the one moment it matters — right before a model request whose user input asks about
9
+ * something that already happened.
10
+ *
11
+ * Three rules keep it from becoming noise:
12
+ *
13
+ * - **Only a genuine user input can trigger it.** Injected context, tool results and notices from
14
+ * other plugins are user-role messages too, and matching their text would let the reminder trigger
15
+ * itself. Only a message whose `source.kind` is `user` is read.
16
+ * - **Once per conversation, and never after the tool was used.** A conversation that already called
17
+ * `sessiongraph` has demonstrated it knows the tool; repeating the advice would spend tokens to say
18
+ * nothing new. The state is keyed by agent id, so one session cannot silence another.
19
+ * - **It rides the admission waterfall, never blocks it.** The listener answers only after `next()`
20
+ * resolved, appends to a batch that is already being sent, and returns a rejected decision
21
+ * untouched — the reminder can never veto a step or change what a user typed.
22
+ * @module dsh-plugin-sessiongraph-tool/history-hint
23
+ */
24
+ import type { Context } from '@deepseek-ai/cordis';
25
+ /** `source.kind` of the reminder message, so a later step can tell it apart from real user input. */
26
+ export declare const HISTORY_HINT_SOURCE_KIND = "sessiongraph-hint";
27
+ /**
28
+ * Words that make a question about the past. Matched as substrings against the user's own text.
29
+ *
30
+ * Deliberately broad: a false positive costs one short sentence once per conversation, while a miss
31
+ * sends the model back to grepping compressed logs and memory summaries. Terms that are equally at
32
+ * home in a question about the current task (`进度`, `之前`) are kept, because those are exactly the
33
+ * questions that were answered from memory before this reminder existed.
34
+ */
35
+ export declare const HISTORY_QUESTION_TERMS: readonly string[];
36
+ /**
37
+ * A message-shaped reminder, structurally identical to what `dsh-llm`'s `createUserMessage` builds.
38
+ *
39
+ * Built as a plain object rather than through `@deepseek-ai/dsh-llm`: that package moves exports
40
+ * between minor releases, and a static import of a symbol that later moves takes the whole plugin
41
+ * out of the host. Four lines of local typing do not.
42
+ */
43
+ export interface HistoryHintMessage {
44
+ readonly id: string;
45
+ readonly role: 'user';
46
+ readonly content: readonly {
47
+ readonly type: 'text';
48
+ readonly text: string;
49
+ }[];
50
+ readonly source: {
51
+ readonly kind: string;
52
+ readonly form: 'notice';
53
+ readonly summary: string;
54
+ };
55
+ }
56
+ /** The reminder text. One short paragraph: it is read by a model that already has the system prompt. */
57
+ export declare const HISTORY_HINT_TEXT: string;
58
+ /**
59
+ * The per-conversation bookkeeping a {@link registerHistoryHint} listener keeps.
60
+ *
61
+ * Shared between the pre-step hook and the tools on purpose: the tool reports that a conversation has
62
+ * already reached the index, which is what turns the reminder off for the rest of that conversation.
63
+ */
64
+ export interface HistoryHintState {
65
+ /** Agents that already called the `sessiongraph` tool. */
66
+ readonly usedBy: Set<string>;
67
+ /** Agents that already received the reminder. */
68
+ readonly hintedTo: Set<string>;
69
+ }
70
+ /** A fresh state object; one per plugin registration. */
71
+ export declare function createHistoryHintState(): HistoryHintState;
72
+ /**
73
+ * Record that a conversation already called the index, which disables the reminder for it.
74
+ *
75
+ * A conversation that has seen `sessiongraph` results does not need to be told the tool exists; the
76
+ * advice would be paid for in tokens and answer a question nobody asked.
77
+ * @param state - the per-registration bookkeeping.
78
+ * @param context - the tool execution (or any record carrying `agent.id`).
79
+ */
80
+ export declare function noteHistoryToolUse(state: HistoryHintState, context: unknown): void;
81
+ /**
82
+ * Whether one piece of user text reads as a question about the past.
83
+ * @param text - the user's own words (never injected context).
84
+ * @returns true when any {@link HISTORY_QUESTION_TERMS} appears in it.
85
+ */
86
+ export declare function looksLikeHistoryQuestion(text: string): boolean;
87
+ /**
88
+ * The text of the last message the user actually typed in this batch.
89
+ *
90
+ * Only `role: 'user'` with `source.kind: 'user'` counts. Everything else that can carry the user role
91
+ * — recall blocks, job notices, time context, this module's own reminder — is excluded, because
92
+ * matching against injected text is how a reminder ends up triggering itself.
93
+ * @param messages - the batch the host is about to send.
94
+ * @returns the typed text, or undefined when this batch has none (a tool-only step, for instance).
95
+ */
96
+ export declare function lastUserText(messages: readonly unknown[] | undefined): string | undefined;
97
+ /**
98
+ * Build the reminder message.
99
+ * @returns a frozen, message-shaped reminder with its own id.
100
+ */
101
+ export declare function buildHistoryHint(): HistoryHintMessage;
102
+ /**
103
+ * Append the history reminder to the next admitted step, once per conversation.
104
+ *
105
+ * Registered per agent rather than globally for the same reason the update notice is: the host's hook
106
+ * is agent-scoped, `next` only means something inside that scope, and one registration per
107
+ * conversation is what keeps a reminder out of a session that never asked a history question.
108
+ * @param ctx - the plugin context.
109
+ * @param state - the per-registration bookkeeping shared with the tools.
110
+ */
111
+ export declare function registerHistoryHint(ctx: Context, state: HistoryHintState): void;
112
+ //# sourceMappingURL=history-hint.d.ts.map
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The "this looks like a question about past conversations" reminder.
3
+ *
4
+ * The system prompt already says to search the session index instead of grepping `~/.dsh/sessions`.
5
+ * That is not enough in practice: a memory provider injects its own recall block on every step, so a
6
+ * model that has just been handed a summary of the past reaches for the summary and never opens the
7
+ * index. The summary is cheap and always present; the index is one tool call away. This module closes
8
+ * that gap at the one moment it matters — right before a model request whose user input asks about
9
+ * something that already happened.
10
+ *
11
+ * Three rules keep it from becoming noise:
12
+ *
13
+ * - **Only a genuine user input can trigger it.** Injected context, tool results and notices from
14
+ * other plugins are user-role messages too, and matching their text would let the reminder trigger
15
+ * itself. Only a message whose `source.kind` is `user` is read.
16
+ * - **Once per conversation, and never after the tool was used.** A conversation that already called
17
+ * `sessiongraph` has demonstrated it knows the tool; repeating the advice would spend tokens to say
18
+ * nothing new. The state is keyed by agent id, so one session cannot silence another.
19
+ * - **It rides the admission waterfall, never blocks it.** The listener answers only after `next()`
20
+ * resolved, appends to a batch that is already being sent, and returns a rejected decision
21
+ * untouched — the reminder can never veto a step or change what a user typed.
22
+ * @module dsh-plugin-sessiongraph-tool/history-hint
23
+ */
24
+ import { randomUUID } from 'node:crypto';
25
+ import { agentIdOf } from "./status-hint.js";
26
+ /** `source.kind` of the reminder message, so a later step can tell it apart from real user input. */
27
+ export const HISTORY_HINT_SOURCE_KIND = 'sessiongraph-hint';
28
+ /**
29
+ * Words that make a question about the past. Matched as substrings against the user's own text.
30
+ *
31
+ * Deliberately broad: a false positive costs one short sentence once per conversation, while a miss
32
+ * sends the model back to grepping compressed logs and memory summaries. Terms that are equally at
33
+ * home in a question about the current task (`进度`, `之前`) are kept, because those are exactly the
34
+ * questions that were answered from memory before this reminder existed.
35
+ */
36
+ export const HISTORY_QUESTION_TERMS = [
37
+ '进度',
38
+ '上次',
39
+ '之前',
40
+ '昨天',
41
+ '前天',
42
+ '上周',
43
+ '上个月',
44
+ '那个会话',
45
+ '哪个会话',
46
+ '哪个对话',
47
+ '说过',
48
+ '做过',
49
+ '干过',
50
+ '聊过',
51
+ '记得',
52
+ '当时',
53
+ '继续上次',
54
+ 'last time',
55
+ 'previously',
56
+ ];
57
+ /** The reminder text. One short paragraph: it is read by a model that already has the system prompt. */
58
+ export const HISTORY_HINT_TEXT = '[会话历史提醒] 这个问题像是在问本机过去会话里发生过什么。先用 sessiongraph 查全机会话原文'
59
+ + '(search 跨会话检索 / timeline 按时间 / session 打开某会话),再用记忆摘要与落盘文件补证;'
60
+ + '不要只凭记忆摘要作答,也不要 grep ~/.dsh/sessions(zstd 压缩,读不到正文)。';
61
+ /** A fresh state object; one per plugin registration. */
62
+ export function createHistoryHintState() {
63
+ return { usedBy: new Set(), hintedTo: new Set() };
64
+ }
65
+ /**
66
+ * Record that a conversation already called the index, which disables the reminder for it.
67
+ *
68
+ * A conversation that has seen `sessiongraph` results does not need to be told the tool exists; the
69
+ * advice would be paid for in tokens and answer a question nobody asked.
70
+ * @param state - the per-registration bookkeeping.
71
+ * @param context - the tool execution (or any record carrying `agent.id`).
72
+ */
73
+ export function noteHistoryToolUse(state, context) {
74
+ const agentId = agentIdOf(context);
75
+ if (agentId !== undefined)
76
+ state.usedBy.add(agentId);
77
+ }
78
+ /**
79
+ * Whether one piece of user text reads as a question about the past.
80
+ * @param text - the user's own words (never injected context).
81
+ * @returns true when any {@link HISTORY_QUESTION_TERMS} appears in it.
82
+ */
83
+ export function looksLikeHistoryQuestion(text) {
84
+ const haystack = text.toLowerCase();
85
+ return HISTORY_QUESTION_TERMS.some(term => haystack.includes(term.toLowerCase()));
86
+ }
87
+ /** Minimal structural read of a message the host put in the admission batch. */
88
+ function isRecord(value) {
89
+ return typeof value === 'object' && value !== null;
90
+ }
91
+ /**
92
+ * The text of the last message the user actually typed in this batch.
93
+ *
94
+ * Only `role: 'user'` with `source.kind: 'user'` counts. Everything else that can carry the user role
95
+ * — recall blocks, job notices, time context, this module's own reminder — is excluded, because
96
+ * matching against injected text is how a reminder ends up triggering itself.
97
+ * @param messages - the batch the host is about to send.
98
+ * @returns the typed text, or undefined when this batch has none (a tool-only step, for instance).
99
+ */
100
+ export function lastUserText(messages) {
101
+ if (!Array.isArray(messages))
102
+ return undefined;
103
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
104
+ const message = messages[index];
105
+ if (!isRecord(message) || message.role !== 'user')
106
+ continue;
107
+ const source = message.source;
108
+ if (!isRecord(source) || source.kind !== 'user')
109
+ continue;
110
+ const content = message.content;
111
+ if (!Array.isArray(content))
112
+ continue;
113
+ const text = content
114
+ .filter((part) => isRecord(part) && part.type === 'text' && typeof part.text === 'string')
115
+ .map(part => part.text)
116
+ .join('\n')
117
+ .trim();
118
+ if (text !== '')
119
+ return text;
120
+ }
121
+ return undefined;
122
+ }
123
+ /**
124
+ * Build the reminder message.
125
+ * @returns a frozen, message-shaped reminder with its own id.
126
+ */
127
+ export function buildHistoryHint() {
128
+ return {
129
+ id: randomUUID(),
130
+ role: 'user',
131
+ content: [{ type: 'text', text: HISTORY_HINT_TEXT }],
132
+ source: {
133
+ kind: HISTORY_HINT_SOURCE_KIND,
134
+ form: 'notice',
135
+ summary: 'sessiongraph: 历史问题先查会话索引',
136
+ },
137
+ };
138
+ }
139
+ /**
140
+ * Append the history reminder to the next admitted step, once per conversation.
141
+ *
142
+ * Registered per agent rather than globally for the same reason the update notice is: the host's hook
143
+ * is agent-scoped, `next` only means something inside that scope, and one registration per
144
+ * conversation is what keeps a reminder out of a session that never asked a history question.
145
+ * @param ctx - the plugin context.
146
+ * @param state - the per-registration bookkeeping shared with the tools.
147
+ */
148
+ export function registerHistoryHint(ctx, state) {
149
+ const events = ctx;
150
+ // Inside an effect so the subscription follows the plugin's own lifetime.
151
+ ctx.effect(() => events.on('agent/created', payload => {
152
+ const agentCtx = payload.agent?.ctx;
153
+ if (agentCtx === undefined)
154
+ return;
155
+ const agentId = agentIdOf(payload);
156
+ agentCtx.on('agent/pre-step', async (stepPayload, next) => {
157
+ const decision = await next();
158
+ // A rejected step sends nothing, so there is no batch to put a reminder into.
159
+ if (decision.kind !== 'enter')
160
+ return decision;
161
+ const text = lastUserText(stepPayload.messages);
162
+ if (text === undefined || !looksLikeHistoryQuestion(text))
163
+ return decision;
164
+ const key = agentId ?? 'unknown-agent';
165
+ if (state.usedBy.has(key) || state.hintedTo.has(key))
166
+ return decision;
167
+ state.hintedTo.add(key);
168
+ return { ...decision, messages: [...decision.messages, buildHistoryHint()] };
169
+ });
170
+ }));
171
+ }
172
+ //# sourceMappingURL=history-hint.js.map
package/lib/index.d.ts CHANGED
@@ -95,6 +95,14 @@ export interface Config {
95
95
  * registry GET, so the cost of a shorter interval is latency nobody waits on, not tokens.
96
96
  */
97
97
  updateCheckIntervalMs?: number;
98
+ /**
99
+ * Append one reminder to the model request whose user input looks like a question about past
100
+ * conversations, when that conversation has not used the index yet (default true). The system
101
+ * prompt already says to use the index; this is the moment-of-use nudge for a model that has just
102
+ * been handed a memory summary and would otherwise answer from it. Once per conversation, never
103
+ * after the tool itself ran.
104
+ */
105
+ historyHint?: boolean;
98
106
  }
99
107
  export declare const Config: z<Config>;
100
108
  /**
package/lib/index.js CHANGED
@@ -27,6 +27,7 @@ import { renderSessiongraph } from "./render.js";
27
27
  import { SESSIONGRAPH_DEFAULT_LIMITS, SESSIONGRAPH_INDEX_PARAMETERS, SESSIONGRAPH_OUTPUT_SCHEMA, SESSIONGRAPH_PARAMETERS, } from "./schema.js";
28
28
  import { agentIdOf, indexStatusHint } from "./status-hint.js";
29
29
  import { createUpdateChecker, defaultStateFile, DEFAULT_UPDATE_CHECK_INTERVAL_MS, readOwnVersion } from "./version-check.js";
30
+ import { createHistoryHintState, noteHistoryToolUse, registerHistoryHint } from "./history-hint.js";
30
31
  export { SESSIONGRAPH_DEFAULT_LIMITS, SESSIONGRAPH_ENTRY_KINDS, SESSIONGRAPH_INDEX_PARAMETERS, SESSIONGRAPH_OPERATIONS, SESSIONGRAPH_OUTPUT_SCHEMA, SESSIONGRAPH_PARAMETERS, SESSIONGRAPH_TOOL_OPERATIONS, } from "./schema.js";
31
32
  export { clipText, formatBytes, formatDuration, formatLiveReplay, formatLiveSessions, formatTime, renderSessiongraph, STALE_NOTE, } from "./render.js";
32
33
  export { LazyIndexRefresher } from "./refresh.js";
@@ -73,6 +74,7 @@ export const Config = z.object({
73
74
  indexTimeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SESSIONGRAPH_INDEX_TIMEOUT_MS),
74
75
  updateCheck: z.boolean().default(true),
75
76
  updateCheckIntervalMs: z.number().default(DEFAULT_UPDATE_CHECK_INTERVAL_MS),
77
+ historyHint: z.boolean().default(true),
76
78
  });
77
79
  /**
78
80
  * Register both tools and the system-prompt guidance.
@@ -110,6 +112,10 @@ export function apply(ctx, config) {
110
112
  freshnessThresholdMs: resolved.freshnessThresholdMs,
111
113
  index: (options, signal) => ctx.sessiongraph.index(indexRoot, options, signal),
112
114
  });
115
+ // Shared between the tools and the pre-step hook: a conversation that already reached the index
116
+ // must not be told the index exists. Kept even when the hint is off, so turning the reminder back
117
+ // on mid-process cannot re-nag a conversation that has been using the tool all along.
118
+ const historyHintState = createHistoryHintState();
113
119
  // A function, not a string: the host re-evaluates it on every assembly, which is what lets the model
114
120
  // learn whether an index exists for free instead of spending a `status` round-trip. It does one
115
121
  // synchronous stat and never opens the index — see `status-hint.ts`.
@@ -129,6 +135,7 @@ export function apply(ctx, config) {
129
135
  timeoutMs: resolved.timeoutMs,
130
136
  async execute(args, exec) {
131
137
  updateChecker?.noteToolUse(agentIdOf(exec));
138
+ noteHistoryToolUse(historyHintState, exec);
132
139
  return runQuery(ctx, refresher, indexRoot, resolved, args, exec);
133
140
  },
134
141
  presentCall: args => ({
@@ -149,6 +156,7 @@ export function apply(ctx, config) {
149
156
  timeoutMs: resolved.indexTimeoutMs,
150
157
  async execute(args, exec) {
151
158
  updateChecker?.noteToolUse(agentIdOf(exec));
159
+ noteHistoryToolUse(historyHintState, exec);
152
160
  return runIndex(ctx, indexRoot, args, exec);
153
161
  },
154
162
  presentCall: args => {
@@ -175,6 +183,12 @@ export function apply(ctx, config) {
175
183
  // the listener tied to this plugin instance's lifetime.
176
184
  if (updateChecker !== undefined)
177
185
  registerUpdateNotice(ctx, updateChecker);
186
+ // The history reminder rides the same waterfall: `agent/pre-step` is the one surface between the
187
+ // user's input and the next model request, which is exactly where "this looks like a question about
188
+ // the past, and no summary is allowed to be the evidence" belongs. Registered here for the same
189
+ // lifetime reason as the notice above.
190
+ if (resolved.historyHint)
191
+ registerHistoryHint(ctx, historyHintState);
178
192
  }
179
193
  /**
180
194
  * Append the "a newer release exists" notice to the next admitted step, once per release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-sessiongraph-tool",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Model-facing sessiongraph tools for DeepSeek Harness — a read-only sessiongraph tool that searches the whole local conversation history, plus a sessiongraph_index tool that builds or refreshes the index on its own, much larger timeout budget",
5
5
  "keywords": [
6
6
  "dsh",
@@ -50,8 +50,8 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@deepseek-ai/schemastery": "^3.18.1",
53
- "dsh-plugin-sessiongraph-service": "^0.1.19",
54
- "dsh-plugin-sessiongraph-sessions": "^0.1.19"
53
+ "dsh-plugin-sessiongraph-service": "^0.1.20",
54
+ "dsh-plugin-sessiongraph-sessions": "^0.1.20"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@deepseek-ai/cordis": ">=4.0.1",