@ai-agent-forge/plugin-session-history 0.0.0-stage → 0.88.15

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/MANUAL.md ADDED
@@ -0,0 +1,24 @@
1
+ # session-history 能力手册(AI 用)
2
+
3
+ 分段淘汰后的历史检索:上下文只保留最近若干段的小结索引,更早的原文留在会话日志里。
4
+ 需要细节时用 `history` 工具按段回读。
5
+
6
+ ## 一个工具,三种 type
7
+
8
+ | type | 用途 | 必填 |
9
+ | --- | --- | --- |
10
+ | `list` | 列出段落索引(id、时间、总结、token 数) | 无(`scope` 可选 active/archive) |
11
+ | `read` | 读回某段原文(JSONL 行) | `segmentId`(`offset`/`limit` 分页,默认 200 行) |
12
+ | `search` | 在段内检索文本 | `query` |
13
+
14
+ ## 何时用
15
+
16
+ - 上下文里看到的段落索引不够,需要原始细节(某次工具输出、某段讨论)→ `read`。
17
+ - 记得某个文件名/报错/结论出现过,但不确定在哪一段 → `search` 拿到段 id 再 `read`。
18
+ - `scope: "archive"` 才能看到很早、已移出索道的段。
19
+
20
+ ## 约束
21
+
22
+ - 单次 `read` 有行数与字符预算(超出标记 truncated,用 `offset` 续读)。
23
+ - 只读:不修改会话;压紧/归档由压缩策略负责,本工具只查不改。
24
+ - 段原文取自会话日志;日志被压缩为 `.jsonl.br` 时工具自动流式解压。
@@ -0,0 +1,19 @@
1
+ {
2
+ "packageFormatVersion": 1,
3
+ "id": "agent-forge.plugin.session-history",
4
+ "pluginVersion": "0.88.15",
5
+ "apiVersion": "1",
6
+ "minHostVersion": "0.88.0",
7
+ "platform": {
8
+ "os": [
9
+ "windows",
10
+ "macos",
11
+ "linux"
12
+ ],
13
+ "runtime": [
14
+ {
15
+ "name": "node"
16
+ }
17
+ ]
18
+ }
19
+ }
@@ -0,0 +1,14 @@
1
+ import type { CapabilityAPI } from "@agent-forge/plugin-sdk";
2
+ /**
3
+ * Locate this session's log under the agent dir.
4
+ *
5
+ * Searches `<agentDir>/sessions/**` for a file ending in `_<sessionId>.jsonl`.
6
+ * The scan is shallow-but-recursive across session directories (one level), which
7
+ * matches the host layout and keeps the plugin independent of the cwd encoding
8
+ * rule — that rule is the host's private detail and may change.
9
+ */
10
+ export declare function findSessionFile(agentDir: string, sessionId: string): string | undefined;
11
+ export default function createSessionHistoryPluginEntry(api: CapabilityAPI): Promise<{
12
+ dispose: () => void;
13
+ }>;
14
+ //# sourceMappingURL=entry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entry.d.ts","sourceRoot":"","sources":["../src/entry.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAM7D;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAkBvF;AAED,wBAA8B,+BAA+B,CAAC,GAAG,EAAE,aAAa,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CAAC,CAelH","sourcesContent":["/**\n * session-history 插件入口:注册 `history` 工具(单工具、type 区分 list/read/search)。\n *\n * 只读能力:工具声明 `readonlySafe`,readonly 会话同样可用——回读历史不改变状态。\n *\n * 会话文件定位走公共面:`api.session.getSessionId()` + 宿主 `agentDir`\n * (`HostCapabilities.agentDir`),按宿主既有命名约定 `<dir>/sessions/<encoded\n * cwd>/<timestamp>_<sessionId>.jsonl` 查找。找不到时工具 fail-closed 并说明原因,\n * 不猜测路径、不回退到别的会话文件。\n */\nimport { existsSync, readdirSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { CapabilityAPI } from \"@agent-forge/plugin-sdk\";\nimport { historyToolDefinition } from \"./history-tool.ts\";\n\n/** Session log files are named `<timestamp>_<sessionId>.jsonl` by the host. */\nconst SESSION_FILE_SUFFIX = \".jsonl\";\n\n/**\n * Locate this session's log under the agent dir.\n *\n * Searches `<agentDir>/sessions/**` for a file ending in `_<sessionId>.jsonl`.\n * The scan is shallow-but-recursive across session directories (one level), which\n * matches the host layout and keeps the plugin independent of the cwd encoding\n * rule — that rule is the host's private detail and may change.\n */\nexport function findSessionFile(agentDir: string, sessionId: string): string | undefined {\n\tconst sessionsRoot = join(agentDir, \"sessions\");\n\tif (!existsSync(sessionsRoot)) return undefined;\n\tconst wanted = `_${sessionId}${SESSION_FILE_SUFFIX}`;\n\tfor (const entry of readdirSync(sessionsRoot, { withFileTypes: true })) {\n\t\tif (!entry.isDirectory()) continue;\n\t\tconst dir = join(sessionsRoot, entry.name);\n\t\tlet names: string[];\n\t\ttry {\n\t\t\tnames = readdirSync(dir);\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\t\tfor (const name of names) {\n\t\t\tif (name.endsWith(wanted)) return join(dir, name);\n\t\t}\n\t}\n\treturn undefined;\n}\n\nexport default async function createSessionHistoryPluginEntry(api: CapabilityAPI): Promise<{ dispose: () => void }> {\n\tconst agentDir = api.host?.agentDir;\n\tconst sessionId = api.session?.getSessionId?.();\n\n\t// The tool resolves lazily so a session file created after plugin load (first\n\t// compaction writes it) is still found.\n\tconst resolveSessionFile = (): string | undefined =>\n\t\tagentDir === undefined || sessionId === undefined ? undefined : findSessionFile(agentDir, sessionId);\n\n\tconst tool = api.registerTool(historyToolDefinition(resolveSessionFile));\n\treturn {\n\t\tdispose: () => {\n\t\t\tvoid tool.dispose();\n\t\t},\n\t};\n}\n"]}
package/dist/entry.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * session-history 插件入口:注册 `history` 工具(单工具、type 区分 list/read/search)。
3
+ *
4
+ * 只读能力:工具声明 `readonlySafe`,readonly 会话同样可用——回读历史不改变状态。
5
+ *
6
+ * 会话文件定位走公共面:`api.session.getSessionId()` + 宿主 `agentDir`
7
+ * (`HostCapabilities.agentDir`),按宿主既有命名约定 `<dir>/sessions/<encoded
8
+ * cwd>/<timestamp>_<sessionId>.jsonl` 查找。找不到时工具 fail-closed 并说明原因,
9
+ * 不猜测路径、不回退到别的会话文件。
10
+ */
11
+ import { existsSync, readdirSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { historyToolDefinition } from "./history-tool.js";
14
+ /** Session log files are named `<timestamp>_<sessionId>.jsonl` by the host. */
15
+ const SESSION_FILE_SUFFIX = ".jsonl";
16
+ /**
17
+ * Locate this session's log under the agent dir.
18
+ *
19
+ * Searches `<agentDir>/sessions/**` for a file ending in `_<sessionId>.jsonl`.
20
+ * The scan is shallow-but-recursive across session directories (one level), which
21
+ * matches the host layout and keeps the plugin independent of the cwd encoding
22
+ * rule — that rule is the host's private detail and may change.
23
+ */
24
+ export function findSessionFile(agentDir, sessionId) {
25
+ const sessionsRoot = join(agentDir, "sessions");
26
+ if (!existsSync(sessionsRoot))
27
+ return undefined;
28
+ const wanted = `_${sessionId}${SESSION_FILE_SUFFIX}`;
29
+ for (const entry of readdirSync(sessionsRoot, { withFileTypes: true })) {
30
+ if (!entry.isDirectory())
31
+ continue;
32
+ const dir = join(sessionsRoot, entry.name);
33
+ let names;
34
+ try {
35
+ names = readdirSync(dir);
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ for (const name of names) {
41
+ if (name.endsWith(wanted))
42
+ return join(dir, name);
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+ export default async function createSessionHistoryPluginEntry(api) {
48
+ const agentDir = api.host?.agentDir;
49
+ const sessionId = api.session?.getSessionId?.();
50
+ // The tool resolves lazily so a session file created after plugin load (first
51
+ // compaction writes it) is still found.
52
+ const resolveSessionFile = () => agentDir === undefined || sessionId === undefined ? undefined : findSessionFile(agentDir, sessionId);
53
+ const tool = api.registerTool(historyToolDefinition(resolveSessionFile));
54
+ return {
55
+ dispose: () => {
56
+ void tool.dispose();
57
+ },
58
+ };
59
+ }
60
+ //# sourceMappingURL=entry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entry.js","sourceRoot":"","sources":["../src/entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAE1D,+EAA+E;AAC/E,MAAM,mBAAmB,GAAG,QAAQ,CAAC;AAErC;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,QAAgB,EAAE,SAAiB,EAAsB;IACxF,MAAM,YAAY,GAAG,IAAI,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;IAChD,IAAI,CAAC,UAAU,CAAC,YAAY,CAAC;QAAE,OAAO,SAAS,CAAC;IAChD,MAAM,MAAM,GAAG,IAAI,SAAS,GAAG,mBAAmB,EAAE,CAAC;IACrD,KAAK,MAAM,KAAK,IAAI,WAAW,CAAC,YAAY,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;QACxE,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE;YAAE,SAAS;QACnC,MAAM,GAAG,GAAG,IAAI,CAAC,YAAY,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;QAC3C,IAAI,KAAe,CAAC;QACpB,IAAI,CAAC;YACJ,KAAK,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;QAC1B,CAAC;QAAC,MAAM,CAAC;YACR,SAAS;QACV,CAAC;QACD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YAC1B,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC;gBAAE,OAAO,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QACnD,CAAC;IACF,CAAC;IACD,OAAO,SAAS,CAAC;AAAA,CACjB;AAED,MAAM,CAAC,OAAO,CAAC,KAAK,UAAU,+BAA+B,CAAC,GAAkB,EAAoC;IACnH,MAAM,QAAQ,GAAG,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC;IACpC,MAAM,SAAS,GAAG,GAAG,CAAC,OAAO,EAAE,YAAY,EAAE,EAAE,CAAC;IAEhD,8EAA8E;IAC9E,wCAAwC;IACxC,MAAM,kBAAkB,GAAG,GAAuB,EAAE,CACnD,QAAQ,KAAK,SAAS,IAAI,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,eAAe,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC;IAEtG,MAAM,IAAI,GAAG,GAAG,CAAC,YAAY,CAAC,qBAAqB,CAAC,kBAAkB,CAAC,CAAC,CAAC;IACzE,OAAO;QACN,OAAO,EAAE,GAAG,EAAE,CAAC;YACd,KAAK,IAAI,CAAC,OAAO,EAAE,CAAC;QAAA,CACpB;KACD,CAAC;AAAA,CACF","sourcesContent":["/**\n * session-history 插件入口:注册 `history` 工具(单工具、type 区分 list/read/search)。\n *\n * 只读能力:工具声明 `readonlySafe`,readonly 会话同样可用——回读历史不改变状态。\n *\n * 会话文件定位走公共面:`api.session.getSessionId()` + 宿主 `agentDir`\n * (`HostCapabilities.agentDir`),按宿主既有命名约定 `<dir>/sessions/<encoded\n * cwd>/<timestamp>_<sessionId>.jsonl` 查找。找不到时工具 fail-closed 并说明原因,\n * 不猜测路径、不回退到别的会话文件。\n */\nimport { existsSync, readdirSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { CapabilityAPI } from \"@agent-forge/plugin-sdk\";\nimport { historyToolDefinition } from \"./history-tool.ts\";\n\n/** Session log files are named `<timestamp>_<sessionId>.jsonl` by the host. */\nconst SESSION_FILE_SUFFIX = \".jsonl\";\n\n/**\n * Locate this session's log under the agent dir.\n *\n * Searches `<agentDir>/sessions/**` for a file ending in `_<sessionId>.jsonl`.\n * The scan is shallow-but-recursive across session directories (one level), which\n * matches the host layout and keeps the plugin independent of the cwd encoding\n * rule — that rule is the host's private detail and may change.\n */\nexport function findSessionFile(agentDir: string, sessionId: string): string | undefined {\n\tconst sessionsRoot = join(agentDir, \"sessions\");\n\tif (!existsSync(sessionsRoot)) return undefined;\n\tconst wanted = `_${sessionId}${SESSION_FILE_SUFFIX}`;\n\tfor (const entry of readdirSync(sessionsRoot, { withFileTypes: true })) {\n\t\tif (!entry.isDirectory()) continue;\n\t\tconst dir = join(sessionsRoot, entry.name);\n\t\tlet names: string[];\n\t\ttry {\n\t\t\tnames = readdirSync(dir);\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\t\tfor (const name of names) {\n\t\t\tif (name.endsWith(wanted)) return join(dir, name);\n\t\t}\n\t}\n\treturn undefined;\n}\n\nexport default async function createSessionHistoryPluginEntry(api: CapabilityAPI): Promise<{ dispose: () => void }> {\n\tconst agentDir = api.host?.agentDir;\n\tconst sessionId = api.session?.getSessionId?.();\n\n\t// The tool resolves lazily so a session file created after plugin load (first\n\t// compaction writes it) is still found.\n\tconst resolveSessionFile = (): string | undefined =>\n\t\tagentDir === undefined || sessionId === undefined ? undefined : findSessionFile(agentDir, sessionId);\n\n\tconst tool = api.registerTool(historyToolDefinition(resolveSessionFile));\n\treturn {\n\t\tdispose: () => {\n\t\t\tvoid tool.dispose();\n\t\t},\n\t};\n}\n"]}
@@ -0,0 +1,59 @@
1
+ /** Custom entry type carrying one closed segment (shared with S1). */
2
+ export declare const SEGMENT_CUSTOM_TYPE = "session-history.segment.v1";
3
+ /** Default and maximum chunk sizes for `read` (lines per call). */
4
+ export declare const HISTORY_READ_DEFAULT_LIMIT = 200;
5
+ export declare const HISTORY_READ_MAX_LIMIT = 2000;
6
+ /** Hard cap on characters returned by one `read`/`search` call (≈20k tokens). */
7
+ export declare const HISTORY_MAX_OUTPUT_CHARS = 70000;
8
+ /** Wall-clock budget for streaming one compressed segment. */
9
+ export declare const HISTORY_STREAM_TIMEOUT_MS = 10000;
10
+ /** A segment as recorded in the session log. */
11
+ export interface SegmentRecordV1 {
12
+ readonly id: string;
13
+ /**
14
+ * Physical session log path, or a bare session id when the writer (the
15
+ * compaction policy) has no path access. Consumers resolve the id form
16
+ * through the same discovery the entry uses for its own session file.
17
+ */
18
+ readonly sessionFile: string;
19
+ readonly firstEntryId: string;
20
+ readonly lastEntryId: string;
21
+ readonly summary: string;
22
+ readonly keywords: readonly string[];
23
+ readonly closedAt: number;
24
+ /** Set when the segment's source log was compressed after the range closed. */
25
+ readonly compressed?: boolean;
26
+ }
27
+ /** Read every segment record from one session log, oldest first. */
28
+ export declare function readSegmentsFromLog(sessionFile: string): Promise<SegmentRecordV1[]>;
29
+ /** Outcome of reading a segment slice. */
30
+ export interface SegmentReadResult {
31
+ readonly lines: readonly string[];
32
+ readonly truncated: boolean;
33
+ readonly totalLines: number;
34
+ }
35
+ /**
36
+ * Read a slice of one segment's original entries.
37
+ *
38
+ * Lines are the raw JSONL of the range; callers render them. The slice is
39
+ * bounded twice: by `limit` lines and by HISTORY_MAX_OUTPUT_CHARS characters,
40
+ * so a segment with huge tool outputs cannot flood the context in one call.
41
+ */
42
+ export declare function readSegmentSlice(segment: SegmentRecordV1, options?: {
43
+ readonly offset?: number;
44
+ readonly limit?: number;
45
+ readonly currentSessionFile?: string;
46
+ }): Promise<SegmentReadResult>;
47
+ /** One search hit inside a segment. */
48
+ export interface SegmentSearchHit {
49
+ readonly line: number;
50
+ readonly text: string;
51
+ }
52
+ /** Search within one segment's range, bounded by hits and characters. */
53
+ export declare function searchSegment(segment: SegmentRecordV1, query: string, options?: {
54
+ readonly maxHits?: number;
55
+ readonly currentSessionFile?: string;
56
+ }): Promise<readonly SegmentSearchHit[]>;
57
+ /** Filter segments by scope: the index splits active/archived by recency. */
58
+ export declare function filterSegmentsByScope(segments: readonly SegmentRecordV1[], scope: "active" | "archive", activeCount: number): readonly SegmentRecordV1[];
59
+ //# sourceMappingURL=history-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"history-store.d.ts","sourceRoot":"","sources":["../src/history-store.ts"],"names":[],"mappings":"AAmBA,sEAAsE;AACtE,eAAO,MAAM,mBAAmB,+BAA+B,CAAC;AAEhE,mEAAmE;AACnE,eAAO,MAAM,0BAA0B,MAAM,CAAC;AAC9C,eAAO,MAAM,sBAAsB,OAAQ,CAAC;AAE5C,mFAAiF;AACjF,eAAO,MAAM,wBAAwB,QAAS,CAAC;AAE/C,8DAA8D;AAC9D,eAAO,MAAM,yBAAyB,QAAS,CAAC;AAEhD,gDAAgD;AAChD,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,+EAA+E;IAC/E,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;CAC9B;AAiHD,oEAAoE;AACpE,wBAAsB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAWzF;AAED,0CAA0C;AAC1C,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;GAMG;AACH,wBAAsB,gBAAgB,CACrC,OAAO,EAAE,eAAe,EACxB,OAAO,GAAE;IAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAAO,GACvG,OAAO,CAAC,iBAAiB,CAAC,CA0C5B;AAED,uCAAuC;AACvC,MAAM,WAAW,gBAAgB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAED,yEAAyE;AACzE,wBAAsB,aAAa,CAClC,OAAO,EAAE,eAAe,EACxB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE;IAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAAO,GAC/E,OAAO,CAAC,SAAS,gBAAgB,EAAE,CAAC,CA4BtC;AAED,6EAA6E;AAC7E,wBAAgB,qBAAqB,CACpC,QAAQ,EAAE,SAAS,eAAe,EAAE,EACpC,KAAK,EAAE,QAAQ,GAAG,SAAS,EAC3B,WAAW,EAAE,MAAM,GACjB,SAAS,eAAe,EAAE,CAK5B","sourcesContent":["/**\n * 会话历史检索的存储层:读索引、按段回读、段内搜索。\n *\n * 权威数据是会话日志(append-only JSONL),本模块只做**只读**访问:\n *\n * - 段记录以 `custom` 条目落在会话日志里(customType 见\n * `SEGMENT_CUSTOM_TYPE`),带 `{sessionFile, firstEntryId, lastEntryId,\n * summary, keywords}`;本模块扫描这些条目得到索引。\n * - 段原文来自会话日志里 `firstEntryId..lastEntryId` 的条目;日志未压缩时直接\n * 流式读,压缩后(`.jsonl.br`)用 brotli 流式解压再读——不整段载入内存。\n * - 全文搜索只在候选段内做(先按关键词/时间缩小范围),并有输出上限与超时,\n * 防解压炸弹与超大段拖垮会话。\n *\n * 纯只读:不写任何文件,不修改日志。\n */\nimport { createReadStream, existsSync } from \"node:fs\";\nimport { createInterface } from \"node:readline\";\nimport { createBrotliDecompress } from \"node:zlib\";\n\n/** Custom entry type carrying one closed segment (shared with S1). */\nexport const SEGMENT_CUSTOM_TYPE = \"session-history.segment.v1\";\n\n/** Default and maximum chunk sizes for `read` (lines per call). */\nexport const HISTORY_READ_DEFAULT_LIMIT = 200;\nexport const HISTORY_READ_MAX_LIMIT = 2_000;\n\n/** Hard cap on characters returned by one `read`/`search` call (≈20k tokens). */\nexport const HISTORY_MAX_OUTPUT_CHARS = 70_000;\n\n/** Wall-clock budget for streaming one compressed segment. */\nexport const HISTORY_STREAM_TIMEOUT_MS = 10_000;\n\n/** A segment as recorded in the session log. */\nexport interface SegmentRecordV1 {\n\treadonly id: string;\n\t/**\n\t * Physical session log path, or a bare session id when the writer (the\n\t * compaction policy) has no path access. Consumers resolve the id form\n\t * through the same discovery the entry uses for its own session file.\n\t */\n\treadonly sessionFile: string;\n\treadonly firstEntryId: string;\n\treadonly lastEntryId: string;\n\treadonly summary: string;\n\treadonly keywords: readonly string[];\n\treadonly closedAt: number;\n\t/** Set when the segment's source log was compressed after the range closed. */\n\treadonly compressed?: boolean;\n}\n\n/** One session-log line, parsed as far as this module needs it. */\ninterface LogEntry {\n\treadonly id?: unknown;\n\treadonly type?: unknown;\n\treadonly message?: unknown;\n\treadonly summary?: unknown;\n\treadonly customType?: unknown;\n\treadonly data?: unknown;\n\treadonly details?: unknown;\n}\n\n/** Opens a session log, decompressing transparently when it is brotli-compressed. */\nfunction openLogLines(path: string): ReturnType<typeof createInterface> {\n\tconst raw = createReadStream(path);\n\tconst stream = path.endsWith(\".br\") ? raw.pipe(createBrotliDecompress()) : raw;\n\treturn createInterface({ input: stream, crlfDelay: Infinity });\n}\n\n/** Return the compressed sibling path for a log, or undefined when absent. */\nfunction compressedSibling(path: string): string | undefined {\n\tconst candidate = `${path}.br`;\n\treturn existsSync(candidate) ? candidate : undefined;\n}\n\n/** Resolve which physical file to read for a logical session log path. */\nfunction physicalLogPath(sessionFile: string): string {\n\tif (existsSync(sessionFile)) return sessionFile;\n\tconst compressed = compressedSibling(sessionFile);\n\tif (compressed !== undefined) return compressed;\n\treturn sessionFile;\n}\n\n/**\n * Resolve a segment's source file. A segment written by the compaction policy\n * carries the session id instead of a path (the policy has no path access), so\n * the caller's own session file stands in for it — segments are always ranges of\n * the session that produced them.\n */\nfunction resolveSegmentFile(segment: SegmentRecordV1, currentSessionFile: string | undefined): string {\n\tif (existsSync(segment.sessionFile) || compressedSibling(segment.sessionFile) !== undefined)\n\t\treturn segment.sessionFile;\n\treturn currentSessionFile ?? segment.sessionFile;\n}\n\n/** Defensive parse: session files are appended by trusted hosts but parsed leniently. */\nfunction parseLine(line: string): LogEntry | undefined {\n\tif (line.trim() === \"\") return undefined;\n\ttry {\n\t\tconst value: unknown = JSON.parse(line);\n\t\treturn typeof value === \"object\" && value !== null ? (value as LogEntry) : undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/** True when the entry is a segment record written by the compaction policy. */\nfunction isSegmentEntry(entry: LogEntry): boolean {\n\treturn entry.type === \"custom\" && entry.customType === SEGMENT_CUSTOM_TYPE;\n}\n\n/** `details` key the compaction policy uses for its segment record (S3a). */\nconst SEGMENT_DETAILS_KEY = \"sessionSegmentV1\";\n\n/**\n * Segment records travel two ways: the compaction policy writes one into each\n * compaction entry's `details` (the only persistent channel a policy has), and a\n * host-side writer may append them as standalone custom entries. Both channels\n * are read here so the index works regardless of which one produced it.\n */\nfunction segmentFromCompactionEntry(entry: LogEntry): SegmentRecordV1 | undefined {\n\tif (entry.type !== \"compaction\") return undefined;\n\tconst details = entry.details;\n\tif (typeof details !== \"object\" || details === null) return undefined;\n\tconst raw = (details as Record<string, unknown>)[SEGMENT_DETAILS_KEY];\n\treturn toSegmentRecord({ type: \"custom\", customType: SEGMENT_CUSTOM_TYPE, data: raw });\n}\n\n/** Narrow a segment custom entry's data into a record; invalid shapes are skipped. */\nfunction toSegmentRecord(entry: LogEntry): SegmentRecordV1 | undefined {\n\tconst data = entry.data;\n\tif (typeof data !== \"object\" || data === null) return undefined;\n\tconst raw = data as Record<string, unknown>;\n\tconst id = raw.id;\n\tconst sessionFile = raw.sessionFile;\n\tconst firstEntryId = raw.firstEntryId;\n\tconst lastEntryId = raw.lastEntryId;\n\tconst summary = raw.summary;\n\tif (\n\t\ttypeof id !== \"string\" ||\n\t\ttypeof sessionFile !== \"string\" ||\n\t\ttypeof firstEntryId !== \"string\" ||\n\t\ttypeof lastEntryId !== \"string\" ||\n\t\ttypeof summary !== \"string\"\n\t) {\n\t\treturn undefined;\n\t}\n\tconst keywords = Array.isArray(raw.keywords)\n\t\t? raw.keywords.filter((item): item is string => typeof item === \"string\")\n\t\t: [];\n\treturn {\n\t\tid,\n\t\tsessionFile,\n\t\tfirstEntryId,\n\t\tlastEntryId,\n\t\tsummary,\n\t\tkeywords,\n\t\tclosedAt: typeof raw.closedAt === \"number\" ? raw.closedAt : 0,\n\t\t...(raw.compressed === true ? { compressed: true } : {}),\n\t};\n}\n\n/** Read every segment record from one session log, oldest first. */\nexport async function readSegmentsFromLog(sessionFile: string): Promise<SegmentRecordV1[]> {\n\tconst segments: SegmentRecordV1[] = [];\n\tif (!existsSync(sessionFile) && compressedSibling(sessionFile) === undefined) return segments;\n\tconst reader = openLogLines(physicalLogPath(sessionFile));\n\tfor await (const line of reader) {\n\t\tconst entry = parseLine(line);\n\t\tif (entry === undefined) continue;\n\t\tconst record = isSegmentEntry(entry) ? toSegmentRecord(entry) : segmentFromCompactionEntry(entry);\n\t\tif (record !== undefined) segments.push(record);\n\t}\n\treturn segments;\n}\n\n/** Outcome of reading a segment slice. */\nexport interface SegmentReadResult {\n\treadonly lines: readonly string[];\n\treadonly truncated: boolean;\n\treadonly totalLines: number;\n}\n\n/**\n * Read a slice of one segment's original entries.\n *\n * Lines are the raw JSONL of the range; callers render them. The slice is\n * bounded twice: by `limit` lines and by HISTORY_MAX_OUTPUT_CHARS characters,\n * so a segment with huge tool outputs cannot flood the context in one call.\n */\nexport async function readSegmentSlice(\n\tsegment: SegmentRecordV1,\n\toptions: { readonly offset?: number; readonly limit?: number; readonly currentSessionFile?: string } = {},\n): Promise<SegmentReadResult> {\n\tconst offset = Math.max(0, options.offset ?? 0);\n\tconst limit = Math.min(Math.max(1, options.limit ?? HISTORY_READ_DEFAULT_LIMIT), HISTORY_READ_MAX_LIMIT);\n\tconst physical = physicalLogPath(resolveSegmentFile(segment, options.currentSessionFile));\n\tif (!existsSync(physical)) return { lines: [], truncated: false, totalLines: 0 };\n\n\tconst reader = openLogLines(physical);\n\tconst collected: string[] = [];\n\tlet inRange = false;\n\tlet seen = 0;\n\tlet totalLines = 0;\n\tlet chars = 0;\n\tlet truncated = false;\n\tlet closed = false;\n\n\tconst deadline = Date.now() + HISTORY_STREAM_TIMEOUT_MS;\n\tfor await (const line of reader) {\n\t\tif (Date.now() > deadline) {\n\t\t\ttruncated = true;\n\t\t\tbreak;\n\t\t}\n\t\tconst entry = parseLine(line);\n\t\tif (entry === undefined) continue;\n\t\t// The range boundary is recorded by id, but the segment record itself is\n\t\t// appended after the range closed — stop only on the *second* occurrence of\n\t\t// the boundary id, or on the first one when it is also the range start.\n\t\tif (entry.id === segment.firstEntryId && !inRange) inRange = true;\n\t\tif (!inRange) continue;\n\t\tif (closed) break;\n\t\ttotalLines += 1;\n\t\tif (seen < offset) {\n\t\t\tseen += 1;\n\t\t} else if (collected.length < limit && chars + line.length <= HISTORY_MAX_OUTPUT_CHARS) {\n\t\t\tcollected.push(line);\n\t\t\tchars += line.length + 1;\n\t\t} else {\n\t\t\ttruncated = true;\n\t\t}\n\t\tif (entry.id === segment.lastEntryId) closed = true;\n\t}\n\treader.close();\n\treturn { lines: collected, truncated, totalLines };\n}\n\n/** One search hit inside a segment. */\nexport interface SegmentSearchHit {\n\treadonly line: number;\n\treadonly text: string;\n}\n\n/** Search within one segment's range, bounded by hits and characters. */\nexport async function searchSegment(\n\tsegment: SegmentRecordV1,\n\tquery: string,\n\toptions: { readonly maxHits?: number; readonly currentSessionFile?: string } = {},\n): Promise<readonly SegmentSearchHit[]> {\n\tconst maxHits = Math.min(Math.max(1, options.maxHits ?? 20), 100);\n\tconst needle = query.toLowerCase();\n\tconst physical = physicalLogPath(resolveSegmentFile(segment, options.currentSessionFile));\n\tif (!existsSync(physical)) return [];\n\n\tconst reader = openLogLines(physical);\n\tconst hits: SegmentSearchHit[] = [];\n\tlet inRange = false;\n\tlet lineNumber = 0;\n\tlet chars = 0;\n\tconst deadline = Date.now() + HISTORY_STREAM_TIMEOUT_MS;\n\tfor await (const line of reader) {\n\t\tif (Date.now() > deadline || hits.length >= maxHits || chars >= HISTORY_MAX_OUTPUT_CHARS) break;\n\t\tconst entry = parseLine(line);\n\t\tif (entry === undefined) continue;\n\t\tif (entry.id === segment.firstEntryId) inRange = true;\n\t\tif (!inRange) continue;\n\t\tlineNumber += 1;\n\t\tif (line.toLowerCase().includes(needle)) {\n\t\t\tconst text = line.length > 400 ? `${line.slice(0, 400)}…` : line;\n\t\t\thits.push({ line: lineNumber, text });\n\t\t\tchars += text.length;\n\t\t}\n\t\tif (entry.id === segment.lastEntryId) break;\n\t}\n\treader.close();\n\treturn hits;\n}\n\n/** Filter segments by scope: the index splits active/archived by recency. */\nexport function filterSegmentsByScope(\n\tsegments: readonly SegmentRecordV1[],\n\tscope: \"active\" | \"archive\",\n\tactiveCount: number,\n): readonly SegmentRecordV1[] {\n\tif (segments.length <= activeCount) return scope === \"active\" ? segments : [];\n\treturn scope === \"active\"\n\t\t? segments.slice(segments.length - activeCount)\n\t\t: segments.slice(0, segments.length - activeCount);\n}\n"]}
@@ -0,0 +1,239 @@
1
+ /**
2
+ * 会话历史检索的存储层:读索引、按段回读、段内搜索。
3
+ *
4
+ * 权威数据是会话日志(append-only JSONL),本模块只做**只读**访问:
5
+ *
6
+ * - 段记录以 `custom` 条目落在会话日志里(customType 见
7
+ * `SEGMENT_CUSTOM_TYPE`),带 `{sessionFile, firstEntryId, lastEntryId,
8
+ * summary, keywords}`;本模块扫描这些条目得到索引。
9
+ * - 段原文来自会话日志里 `firstEntryId..lastEntryId` 的条目;日志未压缩时直接
10
+ * 流式读,压缩后(`.jsonl.br`)用 brotli 流式解压再读——不整段载入内存。
11
+ * - 全文搜索只在候选段内做(先按关键词/时间缩小范围),并有输出上限与超时,
12
+ * 防解压炸弹与超大段拖垮会话。
13
+ *
14
+ * 纯只读:不写任何文件,不修改日志。
15
+ */
16
+ import { createReadStream, existsSync } from "node:fs";
17
+ import { createInterface } from "node:readline";
18
+ import { createBrotliDecompress } from "node:zlib";
19
+ /** Custom entry type carrying one closed segment (shared with S1). */
20
+ export const SEGMENT_CUSTOM_TYPE = "session-history.segment.v1";
21
+ /** Default and maximum chunk sizes for `read` (lines per call). */
22
+ export const HISTORY_READ_DEFAULT_LIMIT = 200;
23
+ export const HISTORY_READ_MAX_LIMIT = 2_000;
24
+ /** Hard cap on characters returned by one `read`/`search` call (≈20k tokens). */
25
+ export const HISTORY_MAX_OUTPUT_CHARS = 70_000;
26
+ /** Wall-clock budget for streaming one compressed segment. */
27
+ export const HISTORY_STREAM_TIMEOUT_MS = 10_000;
28
+ /** Opens a session log, decompressing transparently when it is brotli-compressed. */
29
+ function openLogLines(path) {
30
+ const raw = createReadStream(path);
31
+ const stream = path.endsWith(".br") ? raw.pipe(createBrotliDecompress()) : raw;
32
+ return createInterface({ input: stream, crlfDelay: Infinity });
33
+ }
34
+ /** Return the compressed sibling path for a log, or undefined when absent. */
35
+ function compressedSibling(path) {
36
+ const candidate = `${path}.br`;
37
+ return existsSync(candidate) ? candidate : undefined;
38
+ }
39
+ /** Resolve which physical file to read for a logical session log path. */
40
+ function physicalLogPath(sessionFile) {
41
+ if (existsSync(sessionFile))
42
+ return sessionFile;
43
+ const compressed = compressedSibling(sessionFile);
44
+ if (compressed !== undefined)
45
+ return compressed;
46
+ return sessionFile;
47
+ }
48
+ /**
49
+ * Resolve a segment's source file. A segment written by the compaction policy
50
+ * carries the session id instead of a path (the policy has no path access), so
51
+ * the caller's own session file stands in for it — segments are always ranges of
52
+ * the session that produced them.
53
+ */
54
+ function resolveSegmentFile(segment, currentSessionFile) {
55
+ if (existsSync(segment.sessionFile) || compressedSibling(segment.sessionFile) !== undefined)
56
+ return segment.sessionFile;
57
+ return currentSessionFile ?? segment.sessionFile;
58
+ }
59
+ /** Defensive parse: session files are appended by trusted hosts but parsed leniently. */
60
+ function parseLine(line) {
61
+ if (line.trim() === "")
62
+ return undefined;
63
+ try {
64
+ const value = JSON.parse(line);
65
+ return typeof value === "object" && value !== null ? value : undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ /** True when the entry is a segment record written by the compaction policy. */
72
+ function isSegmentEntry(entry) {
73
+ return entry.type === "custom" && entry.customType === SEGMENT_CUSTOM_TYPE;
74
+ }
75
+ /** `details` key the compaction policy uses for its segment record (S3a). */
76
+ const SEGMENT_DETAILS_KEY = "sessionSegmentV1";
77
+ /**
78
+ * Segment records travel two ways: the compaction policy writes one into each
79
+ * compaction entry's `details` (the only persistent channel a policy has), and a
80
+ * host-side writer may append them as standalone custom entries. Both channels
81
+ * are read here so the index works regardless of which one produced it.
82
+ */
83
+ function segmentFromCompactionEntry(entry) {
84
+ if (entry.type !== "compaction")
85
+ return undefined;
86
+ const details = entry.details;
87
+ if (typeof details !== "object" || details === null)
88
+ return undefined;
89
+ const raw = details[SEGMENT_DETAILS_KEY];
90
+ return toSegmentRecord({ type: "custom", customType: SEGMENT_CUSTOM_TYPE, data: raw });
91
+ }
92
+ /** Narrow a segment custom entry's data into a record; invalid shapes are skipped. */
93
+ function toSegmentRecord(entry) {
94
+ const data = entry.data;
95
+ if (typeof data !== "object" || data === null)
96
+ return undefined;
97
+ const raw = data;
98
+ const id = raw.id;
99
+ const sessionFile = raw.sessionFile;
100
+ const firstEntryId = raw.firstEntryId;
101
+ const lastEntryId = raw.lastEntryId;
102
+ const summary = raw.summary;
103
+ if (typeof id !== "string" ||
104
+ typeof sessionFile !== "string" ||
105
+ typeof firstEntryId !== "string" ||
106
+ typeof lastEntryId !== "string" ||
107
+ typeof summary !== "string") {
108
+ return undefined;
109
+ }
110
+ const keywords = Array.isArray(raw.keywords)
111
+ ? raw.keywords.filter((item) => typeof item === "string")
112
+ : [];
113
+ return {
114
+ id,
115
+ sessionFile,
116
+ firstEntryId,
117
+ lastEntryId,
118
+ summary,
119
+ keywords,
120
+ closedAt: typeof raw.closedAt === "number" ? raw.closedAt : 0,
121
+ ...(raw.compressed === true ? { compressed: true } : {}),
122
+ };
123
+ }
124
+ /** Read every segment record from one session log, oldest first. */
125
+ export async function readSegmentsFromLog(sessionFile) {
126
+ const segments = [];
127
+ if (!existsSync(sessionFile) && compressedSibling(sessionFile) === undefined)
128
+ return segments;
129
+ const reader = openLogLines(physicalLogPath(sessionFile));
130
+ for await (const line of reader) {
131
+ const entry = parseLine(line);
132
+ if (entry === undefined)
133
+ continue;
134
+ const record = isSegmentEntry(entry) ? toSegmentRecord(entry) : segmentFromCompactionEntry(entry);
135
+ if (record !== undefined)
136
+ segments.push(record);
137
+ }
138
+ return segments;
139
+ }
140
+ /**
141
+ * Read a slice of one segment's original entries.
142
+ *
143
+ * Lines are the raw JSONL of the range; callers render them. The slice is
144
+ * bounded twice: by `limit` lines and by HISTORY_MAX_OUTPUT_CHARS characters,
145
+ * so a segment with huge tool outputs cannot flood the context in one call.
146
+ */
147
+ export async function readSegmentSlice(segment, options = {}) {
148
+ const offset = Math.max(0, options.offset ?? 0);
149
+ const limit = Math.min(Math.max(1, options.limit ?? HISTORY_READ_DEFAULT_LIMIT), HISTORY_READ_MAX_LIMIT);
150
+ const physical = physicalLogPath(resolveSegmentFile(segment, options.currentSessionFile));
151
+ if (!existsSync(physical))
152
+ return { lines: [], truncated: false, totalLines: 0 };
153
+ const reader = openLogLines(physical);
154
+ const collected = [];
155
+ let inRange = false;
156
+ let seen = 0;
157
+ let totalLines = 0;
158
+ let chars = 0;
159
+ let truncated = false;
160
+ let closed = false;
161
+ const deadline = Date.now() + HISTORY_STREAM_TIMEOUT_MS;
162
+ for await (const line of reader) {
163
+ if (Date.now() > deadline) {
164
+ truncated = true;
165
+ break;
166
+ }
167
+ const entry = parseLine(line);
168
+ if (entry === undefined)
169
+ continue;
170
+ // The range boundary is recorded by id, but the segment record itself is
171
+ // appended after the range closed — stop only on the *second* occurrence of
172
+ // the boundary id, or on the first one when it is also the range start.
173
+ if (entry.id === segment.firstEntryId && !inRange)
174
+ inRange = true;
175
+ if (!inRange)
176
+ continue;
177
+ if (closed)
178
+ break;
179
+ totalLines += 1;
180
+ if (seen < offset) {
181
+ seen += 1;
182
+ }
183
+ else if (collected.length < limit && chars + line.length <= HISTORY_MAX_OUTPUT_CHARS) {
184
+ collected.push(line);
185
+ chars += line.length + 1;
186
+ }
187
+ else {
188
+ truncated = true;
189
+ }
190
+ if (entry.id === segment.lastEntryId)
191
+ closed = true;
192
+ }
193
+ reader.close();
194
+ return { lines: collected, truncated, totalLines };
195
+ }
196
+ /** Search within one segment's range, bounded by hits and characters. */
197
+ export async function searchSegment(segment, query, options = {}) {
198
+ const maxHits = Math.min(Math.max(1, options.maxHits ?? 20), 100);
199
+ const needle = query.toLowerCase();
200
+ const physical = physicalLogPath(resolveSegmentFile(segment, options.currentSessionFile));
201
+ if (!existsSync(physical))
202
+ return [];
203
+ const reader = openLogLines(physical);
204
+ const hits = [];
205
+ let inRange = false;
206
+ let lineNumber = 0;
207
+ let chars = 0;
208
+ const deadline = Date.now() + HISTORY_STREAM_TIMEOUT_MS;
209
+ for await (const line of reader) {
210
+ if (Date.now() > deadline || hits.length >= maxHits || chars >= HISTORY_MAX_OUTPUT_CHARS)
211
+ break;
212
+ const entry = parseLine(line);
213
+ if (entry === undefined)
214
+ continue;
215
+ if (entry.id === segment.firstEntryId)
216
+ inRange = true;
217
+ if (!inRange)
218
+ continue;
219
+ lineNumber += 1;
220
+ if (line.toLowerCase().includes(needle)) {
221
+ const text = line.length > 400 ? `${line.slice(0, 400)}…` : line;
222
+ hits.push({ line: lineNumber, text });
223
+ chars += text.length;
224
+ }
225
+ if (entry.id === segment.lastEntryId)
226
+ break;
227
+ }
228
+ reader.close();
229
+ return hits;
230
+ }
231
+ /** Filter segments by scope: the index splits active/archived by recency. */
232
+ export function filterSegmentsByScope(segments, scope, activeCount) {
233
+ if (segments.length <= activeCount)
234
+ return scope === "active" ? segments : [];
235
+ return scope === "active"
236
+ ? segments.slice(segments.length - activeCount)
237
+ : segments.slice(0, segments.length - activeCount);
238
+ }
239
+ //# sourceMappingURL=history-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"history-store.js","sourceRoot":"","sources":["../src/history-store.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * 会话历史检索的存储层:读索引、按段回读、段内搜索。\n *\n * 权威数据是会话日志(append-only JSONL),本模块只做**只读**访问:\n *\n * - 段记录以 `custom` 条目落在会话日志里(customType 见\n * `SEGMENT_CUSTOM_TYPE`),带 `{sessionFile, firstEntryId, lastEntryId,\n * summary, keywords}`;本模块扫描这些条目得到索引。\n * - 段原文来自会话日志里 `firstEntryId..lastEntryId` 的条目;日志未压缩时直接\n * 流式读,压缩后(`.jsonl.br`)用 brotli 流式解压再读——不整段载入内存。\n * - 全文搜索只在候选段内做(先按关键词/时间缩小范围),并有输出上限与超时,\n * 防解压炸弹与超大段拖垮会话。\n *\n * 纯只读:不写任何文件,不修改日志。\n */\nimport { createReadStream, existsSync } from \"node:fs\";\nimport { createInterface } from \"node:readline\";\nimport { createBrotliDecompress } from \"node:zlib\";\n\n/** Custom entry type carrying one closed segment (shared with S1). */\nexport const SEGMENT_CUSTOM_TYPE = \"session-history.segment.v1\";\n\n/** Default and maximum chunk sizes for `read` (lines per call). */\nexport const HISTORY_READ_DEFAULT_LIMIT = 200;\nexport const HISTORY_READ_MAX_LIMIT = 2_000;\n\n/** Hard cap on characters returned by one `read`/`search` call (≈20k tokens). */\nexport const HISTORY_MAX_OUTPUT_CHARS = 70_000;\n\n/** Wall-clock budget for streaming one compressed segment. */\nexport const HISTORY_STREAM_TIMEOUT_MS = 10_000;\n\n/** A segment as recorded in the session log. */\nexport interface SegmentRecordV1 {\n\treadonly id: string;\n\t/**\n\t * Physical session log path, or a bare session id when the writer (the\n\t * compaction policy) has no path access. Consumers resolve the id form\n\t * through the same discovery the entry uses for its own session file.\n\t */\n\treadonly sessionFile: string;\n\treadonly firstEntryId: string;\n\treadonly lastEntryId: string;\n\treadonly summary: string;\n\treadonly keywords: readonly string[];\n\treadonly closedAt: number;\n\t/** Set when the segment's source log was compressed after the range closed. */\n\treadonly compressed?: boolean;\n}\n\n/** One session-log line, parsed as far as this module needs it. */\ninterface LogEntry {\n\treadonly id?: unknown;\n\treadonly type?: unknown;\n\treadonly message?: unknown;\n\treadonly summary?: unknown;\n\treadonly customType?: unknown;\n\treadonly data?: unknown;\n\treadonly details?: unknown;\n}\n\n/** Opens a session log, decompressing transparently when it is brotli-compressed. */\nfunction openLogLines(path: string): ReturnType<typeof createInterface> {\n\tconst raw = createReadStream(path);\n\tconst stream = path.endsWith(\".br\") ? raw.pipe(createBrotliDecompress()) : raw;\n\treturn createInterface({ input: stream, crlfDelay: Infinity });\n}\n\n/** Return the compressed sibling path for a log, or undefined when absent. */\nfunction compressedSibling(path: string): string | undefined {\n\tconst candidate = `${path}.br`;\n\treturn existsSync(candidate) ? candidate : undefined;\n}\n\n/** Resolve which physical file to read for a logical session log path. */\nfunction physicalLogPath(sessionFile: string): string {\n\tif (existsSync(sessionFile)) return sessionFile;\n\tconst compressed = compressedSibling(sessionFile);\n\tif (compressed !== undefined) return compressed;\n\treturn sessionFile;\n}\n\n/**\n * Resolve a segment's source file. A segment written by the compaction policy\n * carries the session id instead of a path (the policy has no path access), so\n * the caller's own session file stands in for it — segments are always ranges of\n * the session that produced them.\n */\nfunction resolveSegmentFile(segment: SegmentRecordV1, currentSessionFile: string | undefined): string {\n\tif (existsSync(segment.sessionFile) || compressedSibling(segment.sessionFile) !== undefined)\n\t\treturn segment.sessionFile;\n\treturn currentSessionFile ?? segment.sessionFile;\n}\n\n/** Defensive parse: session files are appended by trusted hosts but parsed leniently. */\nfunction parseLine(line: string): LogEntry | undefined {\n\tif (line.trim() === \"\") return undefined;\n\ttry {\n\t\tconst value: unknown = JSON.parse(line);\n\t\treturn typeof value === \"object\" && value !== null ? (value as LogEntry) : undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/** True when the entry is a segment record written by the compaction policy. */\nfunction isSegmentEntry(entry: LogEntry): boolean {\n\treturn entry.type === \"custom\" && entry.customType === SEGMENT_CUSTOM_TYPE;\n}\n\n/** `details` key the compaction policy uses for its segment record (S3a). */\nconst SEGMENT_DETAILS_KEY = \"sessionSegmentV1\";\n\n/**\n * Segment records travel two ways: the compaction policy writes one into each\n * compaction entry's `details` (the only persistent channel a policy has), and a\n * host-side writer may append them as standalone custom entries. Both channels\n * are read here so the index works regardless of which one produced it.\n */\nfunction segmentFromCompactionEntry(entry: LogEntry): SegmentRecordV1 | undefined {\n\tif (entry.type !== \"compaction\") return undefined;\n\tconst details = entry.details;\n\tif (typeof details !== \"object\" || details === null) return undefined;\n\tconst raw = (details as Record<string, unknown>)[SEGMENT_DETAILS_KEY];\n\treturn toSegmentRecord({ type: \"custom\", customType: SEGMENT_CUSTOM_TYPE, data: raw });\n}\n\n/** Narrow a segment custom entry's data into a record; invalid shapes are skipped. */\nfunction toSegmentRecord(entry: LogEntry): SegmentRecordV1 | undefined {\n\tconst data = entry.data;\n\tif (typeof data !== \"object\" || data === null) return undefined;\n\tconst raw = data as Record<string, unknown>;\n\tconst id = raw.id;\n\tconst sessionFile = raw.sessionFile;\n\tconst firstEntryId = raw.firstEntryId;\n\tconst lastEntryId = raw.lastEntryId;\n\tconst summary = raw.summary;\n\tif (\n\t\ttypeof id !== \"string\" ||\n\t\ttypeof sessionFile !== \"string\" ||\n\t\ttypeof firstEntryId !== \"string\" ||\n\t\ttypeof lastEntryId !== \"string\" ||\n\t\ttypeof summary !== \"string\"\n\t) {\n\t\treturn undefined;\n\t}\n\tconst keywords = Array.isArray(raw.keywords)\n\t\t? raw.keywords.filter((item): item is string => typeof item === \"string\")\n\t\t: [];\n\treturn {\n\t\tid,\n\t\tsessionFile,\n\t\tfirstEntryId,\n\t\tlastEntryId,\n\t\tsummary,\n\t\tkeywords,\n\t\tclosedAt: typeof raw.closedAt === \"number\" ? raw.closedAt : 0,\n\t\t...(raw.compressed === true ? { compressed: true } : {}),\n\t};\n}\n\n/** Read every segment record from one session log, oldest first. */\nexport async function readSegmentsFromLog(sessionFile: string): Promise<SegmentRecordV1[]> {\n\tconst segments: SegmentRecordV1[] = [];\n\tif (!existsSync(sessionFile) && compressedSibling(sessionFile) === undefined) return segments;\n\tconst reader = openLogLines(physicalLogPath(sessionFile));\n\tfor await (const line of reader) {\n\t\tconst entry = parseLine(line);\n\t\tif (entry === undefined) continue;\n\t\tconst record = isSegmentEntry(entry) ? toSegmentRecord(entry) : segmentFromCompactionEntry(entry);\n\t\tif (record !== undefined) segments.push(record);\n\t}\n\treturn segments;\n}\n\n/** Outcome of reading a segment slice. */\nexport interface SegmentReadResult {\n\treadonly lines: readonly string[];\n\treadonly truncated: boolean;\n\treadonly totalLines: number;\n}\n\n/**\n * Read a slice of one segment's original entries.\n *\n * Lines are the raw JSONL of the range; callers render them. The slice is\n * bounded twice: by `limit` lines and by HISTORY_MAX_OUTPUT_CHARS characters,\n * so a segment with huge tool outputs cannot flood the context in one call.\n */\nexport async function readSegmentSlice(\n\tsegment: SegmentRecordV1,\n\toptions: { readonly offset?: number; readonly limit?: number; readonly currentSessionFile?: string } = {},\n): Promise<SegmentReadResult> {\n\tconst offset = Math.max(0, options.offset ?? 0);\n\tconst limit = Math.min(Math.max(1, options.limit ?? HISTORY_READ_DEFAULT_LIMIT), HISTORY_READ_MAX_LIMIT);\n\tconst physical = physicalLogPath(resolveSegmentFile(segment, options.currentSessionFile));\n\tif (!existsSync(physical)) return { lines: [], truncated: false, totalLines: 0 };\n\n\tconst reader = openLogLines(physical);\n\tconst collected: string[] = [];\n\tlet inRange = false;\n\tlet seen = 0;\n\tlet totalLines = 0;\n\tlet chars = 0;\n\tlet truncated = false;\n\tlet closed = false;\n\n\tconst deadline = Date.now() + HISTORY_STREAM_TIMEOUT_MS;\n\tfor await (const line of reader) {\n\t\tif (Date.now() > deadline) {\n\t\t\ttruncated = true;\n\t\t\tbreak;\n\t\t}\n\t\tconst entry = parseLine(line);\n\t\tif (entry === undefined) continue;\n\t\t// The range boundary is recorded by id, but the segment record itself is\n\t\t// appended after the range closed — stop only on the *second* occurrence of\n\t\t// the boundary id, or on the first one when it is also the range start.\n\t\tif (entry.id === segment.firstEntryId && !inRange) inRange = true;\n\t\tif (!inRange) continue;\n\t\tif (closed) break;\n\t\ttotalLines += 1;\n\t\tif (seen < offset) {\n\t\t\tseen += 1;\n\t\t} else if (collected.length < limit && chars + line.length <= HISTORY_MAX_OUTPUT_CHARS) {\n\t\t\tcollected.push(line);\n\t\t\tchars += line.length + 1;\n\t\t} else {\n\t\t\ttruncated = true;\n\t\t}\n\t\tif (entry.id === segment.lastEntryId) closed = true;\n\t}\n\treader.close();\n\treturn { lines: collected, truncated, totalLines };\n}\n\n/** One search hit inside a segment. */\nexport interface SegmentSearchHit {\n\treadonly line: number;\n\treadonly text: string;\n}\n\n/** Search within one segment's range, bounded by hits and characters. */\nexport async function searchSegment(\n\tsegment: SegmentRecordV1,\n\tquery: string,\n\toptions: { readonly maxHits?: number; readonly currentSessionFile?: string } = {},\n): Promise<readonly SegmentSearchHit[]> {\n\tconst maxHits = Math.min(Math.max(1, options.maxHits ?? 20), 100);\n\tconst needle = query.toLowerCase();\n\tconst physical = physicalLogPath(resolveSegmentFile(segment, options.currentSessionFile));\n\tif (!existsSync(physical)) return [];\n\n\tconst reader = openLogLines(physical);\n\tconst hits: SegmentSearchHit[] = [];\n\tlet inRange = false;\n\tlet lineNumber = 0;\n\tlet chars = 0;\n\tconst deadline = Date.now() + HISTORY_STREAM_TIMEOUT_MS;\n\tfor await (const line of reader) {\n\t\tif (Date.now() > deadline || hits.length >= maxHits || chars >= HISTORY_MAX_OUTPUT_CHARS) break;\n\t\tconst entry = parseLine(line);\n\t\tif (entry === undefined) continue;\n\t\tif (entry.id === segment.firstEntryId) inRange = true;\n\t\tif (!inRange) continue;\n\t\tlineNumber += 1;\n\t\tif (line.toLowerCase().includes(needle)) {\n\t\t\tconst text = line.length > 400 ? `${line.slice(0, 400)}…` : line;\n\t\t\thits.push({ line: lineNumber, text });\n\t\t\tchars += text.length;\n\t\t}\n\t\tif (entry.id === segment.lastEntryId) break;\n\t}\n\treader.close();\n\treturn hits;\n}\n\n/** Filter segments by scope: the index splits active/archived by recency. */\nexport function filterSegmentsByScope(\n\tsegments: readonly SegmentRecordV1[],\n\tscope: \"active\" | \"archive\",\n\tactiveCount: number,\n): readonly SegmentRecordV1[] {\n\tif (segments.length <= activeCount) return scope === \"active\" ? segments : [];\n\treturn scope === \"active\"\n\t\t? segments.slice(segments.length - activeCount)\n\t\t: segments.slice(0, segments.length - activeCount);\n}\n"]}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `history` 工具:一个工具、三种 type(list / read / search)。
3
+ *
4
+ * 用户裁决 2026-10-20:检索类能力合并为单工具、用类型与参数区分,避免工具过细。
5
+ * 参数组合 fail-closed 校验;每类给出单行用法,模型不必逐个探索。
6
+ */
7
+ import { Type } from "typebox";
8
+ /**
9
+ * Number of most recent segments the active index holds. Mirrors the value the
10
+ * compaction policy uses (plugins must not import across package roots, so the
11
+ * constant is restated here); the split is by recency only, so a mismatch would
12
+ * shift the active/archive boundary but never lose data.
13
+ */
14
+ export declare const HISTORY_ACTIVE_SEGMENTS = 10;
15
+ /** Tool name exposed to the model. */
16
+ export declare const HISTORY_TOOL_NAME = "history";
17
+ export type HistoryType = "list" | "read" | "search";
18
+ /** Parsed tool input after validation. */
19
+ export interface HistoryInput {
20
+ readonly type: HistoryType;
21
+ readonly scope: "active" | "archive";
22
+ readonly segmentId?: string;
23
+ readonly query?: string;
24
+ readonly offset?: number;
25
+ readonly limit?: number;
26
+ }
27
+ /**
28
+ * Validate one tool call. Throws on any combination the schema cannot rule out
29
+ * alone — `read` without an id, `search` without a query, out-of-range paging,
30
+ * or parameters that belong to a different type.
31
+ */
32
+ export declare function parseHistoryInput(raw: unknown): HistoryInput;
33
+ /** Index row returned by `list`. */
34
+ export interface HistoryListRow {
35
+ readonly id: string;
36
+ readonly closedAt: number;
37
+ readonly summary: string;
38
+ readonly summaryTokens: number;
39
+ }
40
+ /** Result shapes, kept small and JSON-serializable for the tool boundary. */
41
+ export type HistoryResult = {
42
+ readonly ok: true;
43
+ readonly type: "list";
44
+ readonly scope: string;
45
+ readonly rows: readonly HistoryListRow[];
46
+ readonly note?: string;
47
+ } | {
48
+ readonly ok: true;
49
+ readonly type: "read";
50
+ readonly segmentId: string;
51
+ readonly lines: readonly string[];
52
+ readonly truncated: boolean;
53
+ readonly totalLines: number;
54
+ } | {
55
+ readonly ok: true;
56
+ readonly type: "search";
57
+ readonly query: string;
58
+ readonly hits: readonly {
59
+ readonly segmentId: string;
60
+ readonly line: number;
61
+ readonly text: string;
62
+ }[];
63
+ };
64
+ /** Execute a validated call against one session log. */
65
+ export declare function runHistory(input: HistoryInput, sessionFile: string): Promise<HistoryResult>;
66
+ /** The tool definition registered with the capability API. */
67
+ export declare function historyToolDefinition(getSessionFile: () => string | undefined): {
68
+ name: string;
69
+ label: string;
70
+ description: string;
71
+ parameters: Type.TObject<{
72
+ type: Type.TUnion<[Type.TLiteral<"list">, Type.TLiteral<"read">, Type.TLiteral<"search">]>;
73
+ scope: Type.TOptional<Type.TUnion<[Type.TLiteral<"active">, Type.TLiteral<"archive">]>>;
74
+ segmentId: Type.TOptional<Type.TString>;
75
+ query: Type.TOptional<Type.TString>;
76
+ offset: Type.TOptional<Type.TNumber>;
77
+ limit: Type.TOptional<Type.TNumber>;
78
+ }>;
79
+ readonlySafe: boolean;
80
+ execute: (first: unknown, second?: unknown) => Promise<HistoryResult>;
81
+ };
82
+ //# sourceMappingURL=history-tool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"history-tool.d.ts","sourceRoot":"","sources":["../src/history-tool.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAG/B;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAE1C,sCAAsC;AACtC,eAAO,MAAM,iBAAiB,YAAY,CAAC;AAE3C,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAC;AAErD,0CAA0C;AAC1C,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,QAAQ,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,YAAY,CAiD5D;AAED,oCAAoC;AACpC,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAC/B;AAED,6EAA6E;AAC7E,MAAM,MAAM,aAAa,GACtB;IACA,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,cAAc,EAAE,CAAC;IACzC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACtB,GACD;IACA,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC3B,GACD;IACA,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS;QAAE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACtG,CAAC;AAIL,wDAAwD;AACxD,wBAAsB,UAAU,CAAC,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CA6CjG;AAED,8DAA8D;AAC9D,wBAAgB,qBAAqB,CAAC,cAAc,EAAE,MAAM,MAAM,GAAG,SAAS;;;;;;;;;;;;;;EA+C7E","sourcesContent":["/**\n * `history` 工具:一个工具、三种 type(list / read / search)。\n *\n * 用户裁决 2026-10-20:检索类能力合并为单工具、用类型与参数区分,避免工具过细。\n * 参数组合 fail-closed 校验;每类给出单行用法,模型不必逐个探索。\n */\nimport { Type } from \"typebox\";\nimport { filterSegmentsByScope, readSegmentSlice, readSegmentsFromLog, searchSegment } from \"./history-store.ts\";\n\n/**\n * Number of most recent segments the active index holds. Mirrors the value the\n * compaction policy uses (plugins must not import across package roots, so the\n * constant is restated here); the split is by recency only, so a mismatch would\n * shift the active/archive boundary but never lose data.\n */\nexport const HISTORY_ACTIVE_SEGMENTS = 10;\n\n/** Tool name exposed to the model. */\nexport const HISTORY_TOOL_NAME = \"history\";\n\nexport type HistoryType = \"list\" | \"read\" | \"search\";\n\n/** Parsed tool input after validation. */\nexport interface HistoryInput {\n\treadonly type: HistoryType;\n\treadonly scope: \"active\" | \"archive\";\n\treadonly segmentId?: string;\n\treadonly query?: string;\n\treadonly offset?: number;\n\treadonly limit?: number;\n}\n\n/**\n * Validate one tool call. Throws on any combination the schema cannot rule out\n * alone — `read` without an id, `search` without a query, out-of-range paging,\n * or parameters that belong to a different type.\n */\nexport function parseHistoryInput(raw: unknown): HistoryInput {\n\tif (typeof raw !== \"object\" || raw === null) throw new Error(\"history requires an object input\");\n\tconst input = raw as Record<string, unknown>;\n\tconst type = input.type;\n\tif (type !== \"list\" && type !== \"read\" && type !== \"search\") {\n\t\tthrow new Error('history.type must be \"list\", \"read\" or \"search\"');\n\t}\n\t// Optional fields accept JSON null as omitted: real models serialize absent\n\t// optional parameters as null rather than dropping the key (observed in the\n\t// 2026-10-24 S4 long-run retrieval: two real calls were rejected until the\n\t// model gave up). Null carries no value, so it is absence, not a value; the\n\t// cross-type and range checks below still fail closed on real conflicts.\n\tconst scope = input.scope === undefined || input.scope === null ? \"active\" : input.scope;\n\tif (scope !== \"active\" && scope !== \"archive\") throw new Error('history.scope must be \"active\" or \"archive\"');\n\n\tconst segmentId =\n\t\ttypeof input.segmentId === \"string\" && input.segmentId.trim() !== \"\" ? input.segmentId.trim() : undefined;\n\tconst query = typeof input.query === \"string\" && input.query.trim() !== \"\" ? input.query.trim() : undefined;\n\tconst offset = input.offset === undefined || input.offset === null ? undefined : input.offset;\n\tconst limit = input.limit === undefined || input.limit === null ? undefined : input.limit;\n\tif (offset !== undefined && (typeof offset !== \"number\" || !Number.isInteger(offset) || offset < 0)) {\n\t\tthrow new Error(\"history.offset must be a non-negative integer\");\n\t}\n\tif (limit !== undefined && (typeof limit !== \"number\" || !Number.isInteger(limit) || limit < 1)) {\n\t\tthrow new Error(\"history.limit must be a positive integer\");\n\t}\n\n\tif (type === \"list\") {\n\t\tif (segmentId !== undefined || query !== undefined || offset !== undefined || limit !== undefined) {\n\t\t\tthrow new Error('history type \"list\" accepts only scope; segmentId/query/offset/limit belong to read/search');\n\t\t}\n\t\treturn { type, scope };\n\t}\n\tif (type === \"read\") {\n\t\tif (segmentId === undefined) throw new Error('history type \"read\" requires segmentId');\n\t\tif (query !== undefined) throw new Error('history type \"read\" does not accept query; use type \"search\"');\n\t\treturn {\n\t\t\ttype,\n\t\t\tscope,\n\t\t\tsegmentId,\n\t\t\t...(offset === undefined ? {} : { offset }),\n\t\t\t...(limit === undefined ? {} : { limit }),\n\t\t};\n\t}\n\tif (query === undefined) throw new Error('history type \"search\" requires query');\n\tif (segmentId !== undefined || offset !== undefined || limit !== undefined) {\n\t\tthrow new Error('history type \"search\" accepts query and scope only; read a hit with type \"read\"');\n\t}\n\treturn { type, scope, query };\n}\n\n/** Index row returned by `list`. */\nexport interface HistoryListRow {\n\treadonly id: string;\n\treadonly closedAt: number;\n\treadonly summary: string;\n\treadonly summaryTokens: number;\n}\n\n/** Result shapes, kept small and JSON-serializable for the tool boundary. */\nexport type HistoryResult =\n\t| {\n\t\t\treadonly ok: true;\n\t\t\treadonly type: \"list\";\n\t\t\treadonly scope: string;\n\t\t\treadonly rows: readonly HistoryListRow[];\n\t\t\treadonly note?: string;\n\t }\n\t| {\n\t\t\treadonly ok: true;\n\t\t\treadonly type: \"read\";\n\t\t\treadonly segmentId: string;\n\t\t\treadonly lines: readonly string[];\n\t\t\treadonly truncated: boolean;\n\t\t\treadonly totalLines: number;\n\t }\n\t| {\n\t\t\treadonly ok: true;\n\t\t\treadonly type: \"search\";\n\t\t\treadonly query: string;\n\t\t\treadonly hits: readonly { readonly segmentId: string; readonly line: number; readonly text: string }[];\n\t };\n\nconst CHARS_PER_TOKEN = 3.5;\n\n/** Execute a validated call against one session log. */\nexport async function runHistory(input: HistoryInput, sessionFile: string): Promise<HistoryResult> {\n\tconst segments = await readSegmentsFromLog(sessionFile);\n\tconst scoped = filterSegmentsByScope(segments, input.scope, HISTORY_ACTIVE_SEGMENTS);\n\n\tif (input.type === \"list\") {\n\t\tconst rows = scoped.map((segment) => ({\n\t\t\tid: segment.id,\n\t\t\tclosedAt: segment.closedAt,\n\t\t\tsummary: segment.summary,\n\t\t\tsummaryTokens: Math.ceil(segment.summary.length / CHARS_PER_TOKEN),\n\t\t}));\n\t\treturn {\n\t\t\tok: true,\n\t\t\ttype: \"list\",\n\t\t\tscope: input.scope,\n\t\t\trows,\n\t\t\t...(scoped.length === 0 ? { note: \"no closed segments yet; the live conversation is still in context\" } : {}),\n\t\t};\n\t}\n\n\tif (input.type === \"read\") {\n\t\tconst segment = scoped.find((item) => item.id === input.segmentId);\n\t\tif (segment === undefined) {\n\t\t\tthrow new Error(\n\t\t\t\t`segment ${input.segmentId} not found in scope \"${input.scope}\"; call history type=\"list\" to see available ids`,\n\t\t\t);\n\t\t}\n\t\tconst result = await readSegmentSlice(segment, {\n\t\t\tcurrentSessionFile: sessionFile,\n\t\t\t...(input.offset === undefined ? {} : { offset: input.offset }),\n\t\t\t...(input.limit === undefined ? {} : { limit: input.limit }),\n\t\t});\n\t\treturn { ok: true, type: \"read\", segmentId: segment.id, ...result };\n\t}\n\n\t// `type === \"search\"` guarantees a query at runtime; the union type does not\n\t// carry that, so narrow once here instead of asserting at each use.\n\tconst query = input.query;\n\tif (query === undefined) throw new Error('history type \"search\" requires query');\n\tconst hits: { segmentId: string; line: number; text: string }[] = [];\n\tfor (const segment of scoped) {\n\t\tconst found = await searchSegment(segment, query, { currentSessionFile: sessionFile });\n\t\tfor (const hit of found) hits.push({ segmentId: segment.id, line: hit.line, text: hit.text });\n\t}\n\treturn { ok: true, type: \"search\", query, hits };\n}\n\n/** The tool definition registered with the capability API. */\nexport function historyToolDefinition(getSessionFile: () => string | undefined) {\n\treturn {\n\t\tname: HISTORY_TOOL_NAME,\n\t\tlabel: \"Session history\",\n\t\tdescription: [\n\t\t\t'Read earlier conversation segments that were cut out of context to bound its size. type=\"list\" shows the index (ids, time, summary);',\n\t\t\t'type=\"read\" returns a segment\\'s original entries (segmentId required; offset/limit page through it);',\n\t\t\t'type=\"search\" greps inside segments (query required). scope=\"archive\" reaches older segments that left the index.',\n\t\t].join(\" \"),\n\t\tparameters: Type.Object({\n\t\t\ttype: Type.Union([Type.Literal(\"list\"), Type.Literal(\"read\"), Type.Literal(\"search\")], {\n\t\t\t\tdescription:\n\t\t\t\t\t\"list = index of segments; read = original entries of one segment; search = grep inside segments\",\n\t\t\t}),\n\t\t\tscope: Type.Optional(\n\t\t\t\tType.Union([Type.Literal(\"active\"), Type.Literal(\"archive\")], {\n\t\t\t\t\tdescription: \"active (default) = segments still in the index; archive = older segments\",\n\t\t\t\t}),\n\t\t\t),\n\t\t\tsegmentId: Type.Optional(\n\t\t\t\tType.String({ description: 'Segment id from type=\"list\" (required for type=\"read\")' }),\n\t\t\t),\n\t\t\tquery: Type.Optional(Type.String({ description: 'Text to find (required for type=\"search\")' })),\n\t\t\toffset: Type.Optional(Type.Number({ description: 'type=\"read\": skip this many lines (default 0)' })),\n\t\t\tlimit: Type.Optional(\n\t\t\t\tType.Number({ description: 'type=\"read\": return at most this many lines (default 200)' }),\n\t\t\t),\n\t\t}),\n\t\treadonlySafe: true,\n\t\texecute: async (first: unknown, second?: unknown) => {\n\t\t\t// The capability API passes either (input), (input, context) or\n\t\t\t// (context, input) depending on the host shape (goal/todo-set use the\n\t\t\t// same objectInput helper). The 2026-10-24 S4 long-run hit the\n\t\t\t// (context, input) shape: every history call failed with \"requires an\n\t\t\t// object input\" until the model gave up. Pick the object argument and\n\t\t\t// unwrap the context envelope's `input` field when present.\n\t\t\tconst candidate = typeof first === \"object\" && first !== null && !Array.isArray(first) ? first : second;\n\t\t\tconst raw =\n\t\t\t\ttypeof candidate === \"object\" && candidate !== null && \"input\" in (candidate as object)\n\t\t\t\t\t? (candidate as { input: unknown }).input\n\t\t\t\t\t: candidate;\n\t\t\tconst sessionFile = getSessionFile();\n\t\t\tif (sessionFile === undefined) throw new Error(\"this session has no file on disk; history is unavailable\");\n\t\t\tconst input = parseHistoryInput(raw);\n\t\t\treturn await runHistory(input, sessionFile);\n\t\t},\n\t};\n}\n"]}
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `history` 工具:一个工具、三种 type(list / read / search)。
3
+ *
4
+ * 用户裁决 2026-10-20:检索类能力合并为单工具、用类型与参数区分,避免工具过细。
5
+ * 参数组合 fail-closed 校验;每类给出单行用法,模型不必逐个探索。
6
+ */
7
+ import { Type } from "typebox";
8
+ import { filterSegmentsByScope, readSegmentSlice, readSegmentsFromLog, searchSegment } from "./history-store.js";
9
+ /**
10
+ * Number of most recent segments the active index holds. Mirrors the value the
11
+ * compaction policy uses (plugins must not import across package roots, so the
12
+ * constant is restated here); the split is by recency only, so a mismatch would
13
+ * shift the active/archive boundary but never lose data.
14
+ */
15
+ export const HISTORY_ACTIVE_SEGMENTS = 10;
16
+ /** Tool name exposed to the model. */
17
+ export const HISTORY_TOOL_NAME = "history";
18
+ /**
19
+ * Validate one tool call. Throws on any combination the schema cannot rule out
20
+ * alone — `read` without an id, `search` without a query, out-of-range paging,
21
+ * or parameters that belong to a different type.
22
+ */
23
+ export function parseHistoryInput(raw) {
24
+ if (typeof raw !== "object" || raw === null)
25
+ throw new Error("history requires an object input");
26
+ const input = raw;
27
+ const type = input.type;
28
+ if (type !== "list" && type !== "read" && type !== "search") {
29
+ throw new Error('history.type must be "list", "read" or "search"');
30
+ }
31
+ // Optional fields accept JSON null as omitted: real models serialize absent
32
+ // optional parameters as null rather than dropping the key (observed in the
33
+ // 2026-10-24 S4 long-run retrieval: two real calls were rejected until the
34
+ // model gave up). Null carries no value, so it is absence, not a value; the
35
+ // cross-type and range checks below still fail closed on real conflicts.
36
+ const scope = input.scope === undefined || input.scope === null ? "active" : input.scope;
37
+ if (scope !== "active" && scope !== "archive")
38
+ throw new Error('history.scope must be "active" or "archive"');
39
+ const segmentId = typeof input.segmentId === "string" && input.segmentId.trim() !== "" ? input.segmentId.trim() : undefined;
40
+ const query = typeof input.query === "string" && input.query.trim() !== "" ? input.query.trim() : undefined;
41
+ const offset = input.offset === undefined || input.offset === null ? undefined : input.offset;
42
+ const limit = input.limit === undefined || input.limit === null ? undefined : input.limit;
43
+ if (offset !== undefined && (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0)) {
44
+ throw new Error("history.offset must be a non-negative integer");
45
+ }
46
+ if (limit !== undefined && (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1)) {
47
+ throw new Error("history.limit must be a positive integer");
48
+ }
49
+ if (type === "list") {
50
+ if (segmentId !== undefined || query !== undefined || offset !== undefined || limit !== undefined) {
51
+ throw new Error('history type "list" accepts only scope; segmentId/query/offset/limit belong to read/search');
52
+ }
53
+ return { type, scope };
54
+ }
55
+ if (type === "read") {
56
+ if (segmentId === undefined)
57
+ throw new Error('history type "read" requires segmentId');
58
+ if (query !== undefined)
59
+ throw new Error('history type "read" does not accept query; use type "search"');
60
+ return {
61
+ type,
62
+ scope,
63
+ segmentId,
64
+ ...(offset === undefined ? {} : { offset }),
65
+ ...(limit === undefined ? {} : { limit }),
66
+ };
67
+ }
68
+ if (query === undefined)
69
+ throw new Error('history type "search" requires query');
70
+ if (segmentId !== undefined || offset !== undefined || limit !== undefined) {
71
+ throw new Error('history type "search" accepts query and scope only; read a hit with type "read"');
72
+ }
73
+ return { type, scope, query };
74
+ }
75
+ const CHARS_PER_TOKEN = 3.5;
76
+ /** Execute a validated call against one session log. */
77
+ export async function runHistory(input, sessionFile) {
78
+ const segments = await readSegmentsFromLog(sessionFile);
79
+ const scoped = filterSegmentsByScope(segments, input.scope, HISTORY_ACTIVE_SEGMENTS);
80
+ if (input.type === "list") {
81
+ const rows = scoped.map((segment) => ({
82
+ id: segment.id,
83
+ closedAt: segment.closedAt,
84
+ summary: segment.summary,
85
+ summaryTokens: Math.ceil(segment.summary.length / CHARS_PER_TOKEN),
86
+ }));
87
+ return {
88
+ ok: true,
89
+ type: "list",
90
+ scope: input.scope,
91
+ rows,
92
+ ...(scoped.length === 0 ? { note: "no closed segments yet; the live conversation is still in context" } : {}),
93
+ };
94
+ }
95
+ if (input.type === "read") {
96
+ const segment = scoped.find((item) => item.id === input.segmentId);
97
+ if (segment === undefined) {
98
+ throw new Error(`segment ${input.segmentId} not found in scope "${input.scope}"; call history type="list" to see available ids`);
99
+ }
100
+ const result = await readSegmentSlice(segment, {
101
+ currentSessionFile: sessionFile,
102
+ ...(input.offset === undefined ? {} : { offset: input.offset }),
103
+ ...(input.limit === undefined ? {} : { limit: input.limit }),
104
+ });
105
+ return { ok: true, type: "read", segmentId: segment.id, ...result };
106
+ }
107
+ // `type === "search"` guarantees a query at runtime; the union type does not
108
+ // carry that, so narrow once here instead of asserting at each use.
109
+ const query = input.query;
110
+ if (query === undefined)
111
+ throw new Error('history type "search" requires query');
112
+ const hits = [];
113
+ for (const segment of scoped) {
114
+ const found = await searchSegment(segment, query, { currentSessionFile: sessionFile });
115
+ for (const hit of found)
116
+ hits.push({ segmentId: segment.id, line: hit.line, text: hit.text });
117
+ }
118
+ return { ok: true, type: "search", query, hits };
119
+ }
120
+ /** The tool definition registered with the capability API. */
121
+ export function historyToolDefinition(getSessionFile) {
122
+ return {
123
+ name: HISTORY_TOOL_NAME,
124
+ label: "Session history",
125
+ description: [
126
+ 'Read earlier conversation segments that were cut out of context to bound its size. type="list" shows the index (ids, time, summary);',
127
+ 'type="read" returns a segment\'s original entries (segmentId required; offset/limit page through it);',
128
+ 'type="search" greps inside segments (query required). scope="archive" reaches older segments that left the index.',
129
+ ].join(" "),
130
+ parameters: Type.Object({
131
+ type: Type.Union([Type.Literal("list"), Type.Literal("read"), Type.Literal("search")], {
132
+ description: "list = index of segments; read = original entries of one segment; search = grep inside segments",
133
+ }),
134
+ scope: Type.Optional(Type.Union([Type.Literal("active"), Type.Literal("archive")], {
135
+ description: "active (default) = segments still in the index; archive = older segments",
136
+ })),
137
+ segmentId: Type.Optional(Type.String({ description: 'Segment id from type="list" (required for type="read")' })),
138
+ query: Type.Optional(Type.String({ description: 'Text to find (required for type="search")' })),
139
+ offset: Type.Optional(Type.Number({ description: 'type="read": skip this many lines (default 0)' })),
140
+ limit: Type.Optional(Type.Number({ description: 'type="read": return at most this many lines (default 200)' })),
141
+ }),
142
+ readonlySafe: true,
143
+ execute: async (first, second) => {
144
+ // The capability API passes either (input), (input, context) or
145
+ // (context, input) depending on the host shape (goal/todo-set use the
146
+ // same objectInput helper). The 2026-10-24 S4 long-run hit the
147
+ // (context, input) shape: every history call failed with "requires an
148
+ // object input" until the model gave up. Pick the object argument and
149
+ // unwrap the context envelope's `input` field when present.
150
+ const candidate = typeof first === "object" && first !== null && !Array.isArray(first) ? first : second;
151
+ const raw = typeof candidate === "object" && candidate !== null && "input" in candidate
152
+ ? candidate.input
153
+ : candidate;
154
+ const sessionFile = getSessionFile();
155
+ if (sessionFile === undefined)
156
+ throw new Error("this session has no file on disk; history is unavailable");
157
+ const input = parseHistoryInput(raw);
158
+ return await runHistory(input, sessionFile);
159
+ },
160
+ };
161
+ }
162
+ //# sourceMappingURL=history-tool.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"history-tool.js","sourceRoot":"","sources":["../src/history-tool.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `history` 工具:一个工具、三种 type(list / read / search)。\n *\n * 用户裁决 2026-10-20:检索类能力合并为单工具、用类型与参数区分,避免工具过细。\n * 参数组合 fail-closed 校验;每类给出单行用法,模型不必逐个探索。\n */\nimport { Type } from \"typebox\";\nimport { filterSegmentsByScope, readSegmentSlice, readSegmentsFromLog, searchSegment } from \"./history-store.ts\";\n\n/**\n * Number of most recent segments the active index holds. Mirrors the value the\n * compaction policy uses (plugins must not import across package roots, so the\n * constant is restated here); the split is by recency only, so a mismatch would\n * shift the active/archive boundary but never lose data.\n */\nexport const HISTORY_ACTIVE_SEGMENTS = 10;\n\n/** Tool name exposed to the model. */\nexport const HISTORY_TOOL_NAME = \"history\";\n\nexport type HistoryType = \"list\" | \"read\" | \"search\";\n\n/** Parsed tool input after validation. */\nexport interface HistoryInput {\n\treadonly type: HistoryType;\n\treadonly scope: \"active\" | \"archive\";\n\treadonly segmentId?: string;\n\treadonly query?: string;\n\treadonly offset?: number;\n\treadonly limit?: number;\n}\n\n/**\n * Validate one tool call. Throws on any combination the schema cannot rule out\n * alone — `read` without an id, `search` without a query, out-of-range paging,\n * or parameters that belong to a different type.\n */\nexport function parseHistoryInput(raw: unknown): HistoryInput {\n\tif (typeof raw !== \"object\" || raw === null) throw new Error(\"history requires an object input\");\n\tconst input = raw as Record<string, unknown>;\n\tconst type = input.type;\n\tif (type !== \"list\" && type !== \"read\" && type !== \"search\") {\n\t\tthrow new Error('history.type must be \"list\", \"read\" or \"search\"');\n\t}\n\t// Optional fields accept JSON null as omitted: real models serialize absent\n\t// optional parameters as null rather than dropping the key (observed in the\n\t// 2026-10-24 S4 long-run retrieval: two real calls were rejected until the\n\t// model gave up). Null carries no value, so it is absence, not a value; the\n\t// cross-type and range checks below still fail closed on real conflicts.\n\tconst scope = input.scope === undefined || input.scope === null ? \"active\" : input.scope;\n\tif (scope !== \"active\" && scope !== \"archive\") throw new Error('history.scope must be \"active\" or \"archive\"');\n\n\tconst segmentId =\n\t\ttypeof input.segmentId === \"string\" && input.segmentId.trim() !== \"\" ? input.segmentId.trim() : undefined;\n\tconst query = typeof input.query === \"string\" && input.query.trim() !== \"\" ? input.query.trim() : undefined;\n\tconst offset = input.offset === undefined || input.offset === null ? undefined : input.offset;\n\tconst limit = input.limit === undefined || input.limit === null ? undefined : input.limit;\n\tif (offset !== undefined && (typeof offset !== \"number\" || !Number.isInteger(offset) || offset < 0)) {\n\t\tthrow new Error(\"history.offset must be a non-negative integer\");\n\t}\n\tif (limit !== undefined && (typeof limit !== \"number\" || !Number.isInteger(limit) || limit < 1)) {\n\t\tthrow new Error(\"history.limit must be a positive integer\");\n\t}\n\n\tif (type === \"list\") {\n\t\tif (segmentId !== undefined || query !== undefined || offset !== undefined || limit !== undefined) {\n\t\t\tthrow new Error('history type \"list\" accepts only scope; segmentId/query/offset/limit belong to read/search');\n\t\t}\n\t\treturn { type, scope };\n\t}\n\tif (type === \"read\") {\n\t\tif (segmentId === undefined) throw new Error('history type \"read\" requires segmentId');\n\t\tif (query !== undefined) throw new Error('history type \"read\" does not accept query; use type \"search\"');\n\t\treturn {\n\t\t\ttype,\n\t\t\tscope,\n\t\t\tsegmentId,\n\t\t\t...(offset === undefined ? {} : { offset }),\n\t\t\t...(limit === undefined ? {} : { limit }),\n\t\t};\n\t}\n\tif (query === undefined) throw new Error('history type \"search\" requires query');\n\tif (segmentId !== undefined || offset !== undefined || limit !== undefined) {\n\t\tthrow new Error('history type \"search\" accepts query and scope only; read a hit with type \"read\"');\n\t}\n\treturn { type, scope, query };\n}\n\n/** Index row returned by `list`. */\nexport interface HistoryListRow {\n\treadonly id: string;\n\treadonly closedAt: number;\n\treadonly summary: string;\n\treadonly summaryTokens: number;\n}\n\n/** Result shapes, kept small and JSON-serializable for the tool boundary. */\nexport type HistoryResult =\n\t| {\n\t\t\treadonly ok: true;\n\t\t\treadonly type: \"list\";\n\t\t\treadonly scope: string;\n\t\t\treadonly rows: readonly HistoryListRow[];\n\t\t\treadonly note?: string;\n\t }\n\t| {\n\t\t\treadonly ok: true;\n\t\t\treadonly type: \"read\";\n\t\t\treadonly segmentId: string;\n\t\t\treadonly lines: readonly string[];\n\t\t\treadonly truncated: boolean;\n\t\t\treadonly totalLines: number;\n\t }\n\t| {\n\t\t\treadonly ok: true;\n\t\t\treadonly type: \"search\";\n\t\t\treadonly query: string;\n\t\t\treadonly hits: readonly { readonly segmentId: string; readonly line: number; readonly text: string }[];\n\t };\n\nconst CHARS_PER_TOKEN = 3.5;\n\n/** Execute a validated call against one session log. */\nexport async function runHistory(input: HistoryInput, sessionFile: string): Promise<HistoryResult> {\n\tconst segments = await readSegmentsFromLog(sessionFile);\n\tconst scoped = filterSegmentsByScope(segments, input.scope, HISTORY_ACTIVE_SEGMENTS);\n\n\tif (input.type === \"list\") {\n\t\tconst rows = scoped.map((segment) => ({\n\t\t\tid: segment.id,\n\t\t\tclosedAt: segment.closedAt,\n\t\t\tsummary: segment.summary,\n\t\t\tsummaryTokens: Math.ceil(segment.summary.length / CHARS_PER_TOKEN),\n\t\t}));\n\t\treturn {\n\t\t\tok: true,\n\t\t\ttype: \"list\",\n\t\t\tscope: input.scope,\n\t\t\trows,\n\t\t\t...(scoped.length === 0 ? { note: \"no closed segments yet; the live conversation is still in context\" } : {}),\n\t\t};\n\t}\n\n\tif (input.type === \"read\") {\n\t\tconst segment = scoped.find((item) => item.id === input.segmentId);\n\t\tif (segment === undefined) {\n\t\t\tthrow new Error(\n\t\t\t\t`segment ${input.segmentId} not found in scope \"${input.scope}\"; call history type=\"list\" to see available ids`,\n\t\t\t);\n\t\t}\n\t\tconst result = await readSegmentSlice(segment, {\n\t\t\tcurrentSessionFile: sessionFile,\n\t\t\t...(input.offset === undefined ? {} : { offset: input.offset }),\n\t\t\t...(input.limit === undefined ? {} : { limit: input.limit }),\n\t\t});\n\t\treturn { ok: true, type: \"read\", segmentId: segment.id, ...result };\n\t}\n\n\t// `type === \"search\"` guarantees a query at runtime; the union type does not\n\t// carry that, so narrow once here instead of asserting at each use.\n\tconst query = input.query;\n\tif (query === undefined) throw new Error('history type \"search\" requires query');\n\tconst hits: { segmentId: string; line: number; text: string }[] = [];\n\tfor (const segment of scoped) {\n\t\tconst found = await searchSegment(segment, query, { currentSessionFile: sessionFile });\n\t\tfor (const hit of found) hits.push({ segmentId: segment.id, line: hit.line, text: hit.text });\n\t}\n\treturn { ok: true, type: \"search\", query, hits };\n}\n\n/** The tool definition registered with the capability API. */\nexport function historyToolDefinition(getSessionFile: () => string | undefined) {\n\treturn {\n\t\tname: HISTORY_TOOL_NAME,\n\t\tlabel: \"Session history\",\n\t\tdescription: [\n\t\t\t'Read earlier conversation segments that were cut out of context to bound its size. type=\"list\" shows the index (ids, time, summary);',\n\t\t\t'type=\"read\" returns a segment\\'s original entries (segmentId required; offset/limit page through it);',\n\t\t\t'type=\"search\" greps inside segments (query required). scope=\"archive\" reaches older segments that left the index.',\n\t\t].join(\" \"),\n\t\tparameters: Type.Object({\n\t\t\ttype: Type.Union([Type.Literal(\"list\"), Type.Literal(\"read\"), Type.Literal(\"search\")], {\n\t\t\t\tdescription:\n\t\t\t\t\t\"list = index of segments; read = original entries of one segment; search = grep inside segments\",\n\t\t\t}),\n\t\t\tscope: Type.Optional(\n\t\t\t\tType.Union([Type.Literal(\"active\"), Type.Literal(\"archive\")], {\n\t\t\t\t\tdescription: \"active (default) = segments still in the index; archive = older segments\",\n\t\t\t\t}),\n\t\t\t),\n\t\t\tsegmentId: Type.Optional(\n\t\t\t\tType.String({ description: 'Segment id from type=\"list\" (required for type=\"read\")' }),\n\t\t\t),\n\t\t\tquery: Type.Optional(Type.String({ description: 'Text to find (required for type=\"search\")' })),\n\t\t\toffset: Type.Optional(Type.Number({ description: 'type=\"read\": skip this many lines (default 0)' })),\n\t\t\tlimit: Type.Optional(\n\t\t\t\tType.Number({ description: 'type=\"read\": return at most this many lines (default 200)' }),\n\t\t\t),\n\t\t}),\n\t\treadonlySafe: true,\n\t\texecute: async (first: unknown, second?: unknown) => {\n\t\t\t// The capability API passes either (input), (input, context) or\n\t\t\t// (context, input) depending on the host shape (goal/todo-set use the\n\t\t\t// same objectInput helper). The 2026-10-24 S4 long-run hit the\n\t\t\t// (context, input) shape: every history call failed with \"requires an\n\t\t\t// object input\" until the model gave up. Pick the object argument and\n\t\t\t// unwrap the context envelope's `input` field when present.\n\t\t\tconst candidate = typeof first === \"object\" && first !== null && !Array.isArray(first) ? first : second;\n\t\t\tconst raw =\n\t\t\t\ttypeof candidate === \"object\" && candidate !== null && \"input\" in (candidate as object)\n\t\t\t\t\t? (candidate as { input: unknown }).input\n\t\t\t\t\t: candidate;\n\t\t\tconst sessionFile = getSessionFile();\n\t\t\tif (sessionFile === undefined) throw new Error(\"this session has no file on disk; history is unavailable\");\n\t\t\tconst input = parseHistoryInput(raw);\n\t\t\treturn await runHistory(input, sessionFile);\n\t\t},\n\t};\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
- "name": "@ai-agent-forge/plugin-session-history",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@ai-agent-forge/plugin-session-history",
3
+ "version": "0.88.15",
4
+ "description": "Session history retrieval for Agent Forge: one `history` tool (list/read/search) over closed conversation segments",
5
+ "type": "module",
6
+ "main": "./dist/entry.js",
7
+ "types": "./dist/entry.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/entry.d.ts",
11
+ "import": "./dist/entry.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "MANUAL.md",
17
+ "agent-forge.json",
18
+ "plugin.json",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "clean": "shx rm -rf dist",
23
+ "build": "tsgo -p tsconfig.build.json",
24
+ "test": "vitest --run",
25
+ "prepublishOnly": "npm run clean && npm run build",
26
+ "test:coverage": "vitest --run --coverage"
27
+ },
28
+ "keywords": [
29
+ "agent-forge",
30
+ "plugin",
31
+ "session-history"
32
+ ],
33
+ "author": "Agent Forge contributors",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/agent-forge/agent-forge.git",
38
+ "directory": "plugins/session-history"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/agent-forge/agent-forge/issues"
42
+ },
43
+ "homepage": "https://github.com/agent-forge/agent-forge#readme",
44
+ "engines": {
45
+ "node": ">=22.19.0"
46
+ },
47
+ "dependencies": {},
48
+ "devDependencies": {
49
+ "shx": "0.3.4",
50
+ "vitest": "4.1.11"
51
+ }
52
+ }
package/plugin.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "id": "agent-forge.plugin.session-history",
3
+ "version": "0.88.15",
4
+ "apiVersion": "1-draft",
5
+ "entry": "./dist/entry.js",
6
+ "hostVersion": ">=0.88.0",
7
+ "description": "会话历史检索:单个 history 工具(list/read/search)读取已关闭段落——分段淘汰后按需回读原文",
8
+ "manual": "./MANUAL.md",
9
+ "requiredCapabilities": [
10
+ "session",
11
+ "tools"
12
+ ],
13
+ "provides": [
14
+ {
15
+ "id": "session-history",
16
+ "version": 1,
17
+ "kind": "contribution"
18
+ }
19
+ ]
20
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.