mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10 (df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。 本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略 均未改动。 - 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、 AGENTS 与 LICENSE;该快照共 156 个上游文件。 - 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、 覆盖率与构建产物。 - 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项: 在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts 对齐、strip-ansi 解析确认。 - 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至 24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为 对应一个已发布、可复现的版本。 尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载 Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。 验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份; 已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { describe, it, afterEach } from "node:test";
|
|
import * as assert from "node:assert/strict";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { registerSessionSearchTool } from "../../src/tools/session-search-tool.js";
|
|
import { DatabaseManager } from "../../src/store/db.js";
|
|
import { indexSession } from "../../src/store/session-indexer.js";
|
|
|
|
let ROOT_DIR = "";
|
|
|
|
afterEach(() => {
|
|
if (ROOT_DIR) fs.rmSync(ROOT_DIR, { recursive: true, force: true });
|
|
ROOT_DIR = "";
|
|
});
|
|
|
|
function makeSessionsDir(): string {
|
|
ROOT_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "pi-session-search-tool-test-"));
|
|
return ROOT_DIR;
|
|
}
|
|
|
|
describe("registerSessionSearchTool", () => {
|
|
it("registers the legacy query schema by default", () => {
|
|
let captured: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => { captured = def; },
|
|
} as any;
|
|
|
|
registerSessionSearchTool(mockPi, {} as any);
|
|
|
|
const schema = JSON.stringify(captured.parameters);
|
|
assert.strictEqual(captured.name, "session_search");
|
|
assert.match(schema, /query/);
|
|
assert.doesNotMatch(schema, /markdown/);
|
|
assert.match(schema, /"minimum":1/);
|
|
assert.match(schema, /"maximum":20/);
|
|
});
|
|
|
|
it("clamps negative and fractional legacy limits before querying", async () => {
|
|
let captured: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => { captured = def; },
|
|
} as any;
|
|
const memoryDir = makeSessionsDir();
|
|
const dbManager = new DatabaseManager(memoryDir);
|
|
|
|
try {
|
|
indexSession(dbManager, {
|
|
id: "bounded-limit-session",
|
|
project: "bounded-project",
|
|
cwd: "/work/bounded",
|
|
startedAt: "2026-07-11T00:00:00.000Z",
|
|
endedAt: null,
|
|
messages: Array.from({ length: 25 }, (_, index) => ({
|
|
id: `bounded-limit-message-${index}`,
|
|
role: "assistant",
|
|
content: `bounded-limit-needle ${index}`,
|
|
timestamp: `2026-07-11T00:${String(index).padStart(2, "0")}:00.000Z`,
|
|
})),
|
|
});
|
|
registerSessionSearchTool(mockPi, dbManager);
|
|
|
|
const negative = await captured.execute("tc-negative-limit", {
|
|
query: "bounded-limit-needle",
|
|
limit: -1,
|
|
});
|
|
const fractional = await captured.execute("tc-fractional-limit", {
|
|
query: "bounded-limit-needle",
|
|
limit: 2.9,
|
|
});
|
|
|
|
assert.strictEqual(negative.details.count, 1);
|
|
assert.strictEqual(fractional.details.count, 2);
|
|
assert.ok(negative.content[0].text.length < 2_000);
|
|
assert.ok(fractional.content[0].text.length < 4_000);
|
|
} finally {
|
|
dbManager.close();
|
|
}
|
|
});
|
|
|
|
it("clamps non-finite snippetChars values instead of propagating NaN", async () => {
|
|
let captured: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => { captured = def; },
|
|
} as any;
|
|
const memoryDir = makeSessionsDir();
|
|
const dbManager = new DatabaseManager(memoryDir);
|
|
|
|
try {
|
|
indexSession(dbManager, {
|
|
id: "nan-snippet-session",
|
|
project: "nan-snippet-project",
|
|
cwd: "/work/nan-snippet",
|
|
startedAt: "2026-07-11T00:00:00.000Z",
|
|
endedAt: null,
|
|
messages: [{
|
|
id: "nan-snippet-message",
|
|
role: "assistant",
|
|
content: `needle ${"a".repeat(500)}`,
|
|
timestamp: "2026-07-11T00:01:00.000Z",
|
|
}],
|
|
});
|
|
registerSessionSearchTool(mockPi, dbManager);
|
|
|
|
const nanResult = await captured.execute("tc-nan-snippet", {
|
|
query: "needle",
|
|
snippetChars: NaN,
|
|
});
|
|
const infinityResult = await captured.execute("tc-infinity-snippet", {
|
|
query: "needle",
|
|
snippetChars: Infinity,
|
|
});
|
|
const largeResult = await captured.execute("tc-large-snippet", {
|
|
query: "needle",
|
|
snippetChars: 999_999_999,
|
|
});
|
|
|
|
assert.strictEqual(nanResult.details.snippetChars, 1_200);
|
|
assert.strictEqual(infinityResult.details.snippetChars, 1_200);
|
|
assert.strictEqual(largeResult.details.snippetChars, 4_000);
|
|
assert.ok(Number.isFinite(nanResult.details.snippetChars));
|
|
assert.match(nanResult.content[0].text, /needle a+/);
|
|
} finally {
|
|
dbManager.close();
|
|
}
|
|
});
|
|
|
|
it("bounds oversized legacy results and reports truncation without duplicating output in details", async () => {
|
|
let captured: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => { captured = def; },
|
|
} as any;
|
|
const memoryDir = makeSessionsDir();
|
|
const dbManager = new DatabaseManager(memoryDir);
|
|
const oversizedContent = `needle ${"x".repeat(6_000_000)}`;
|
|
|
|
try {
|
|
// Seed a pre-cap database row directly. New ingestion paths must cap
|
|
// message content, but search output still needs to stay bounded for
|
|
// oversized rows written by older extension versions.
|
|
const db = dbManager.getDb();
|
|
db.prepare(`
|
|
INSERT INTO sessions (id, project, cwd, started_at, ended_at, message_count)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
"oversized-session",
|
|
"oversized-project",
|
|
"/work/oversized",
|
|
"2026-07-11T00:00:00.000Z",
|
|
null,
|
|
1,
|
|
);
|
|
db.prepare(`
|
|
INSERT INTO messages (id, session_id, role, content, timestamp)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`).run(
|
|
"oversized-message",
|
|
"oversized-session",
|
|
"assistant",
|
|
oversizedContent,
|
|
"2026-07-11T00:01:00.000Z",
|
|
);
|
|
registerSessionSearchTool(mockPi, dbManager);
|
|
|
|
const result = await captured.execute("tc-oversized", { query: "needle" });
|
|
const output = result.content[0].text as string;
|
|
|
|
assert.ok(output.length <= 50 * 1024, `expected <= 50 KiB, got ${output.length}`);
|
|
assert.match(output, /truncated/);
|
|
assert.match(output, /6000007 chars total/);
|
|
assert.strictEqual(result.details.truncatedCount, 1);
|
|
assert.strictEqual(result.details.outputChars, output.length);
|
|
assert.strictEqual(result.details.output, undefined);
|
|
assert.ok(JSON.stringify(result.details).length < 1_000);
|
|
} finally {
|
|
dbManager.close();
|
|
}
|
|
});
|
|
|
|
it("offers a bounded snippetChars override for legacy searches", async () => {
|
|
let captured: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => { captured = def; },
|
|
} as any;
|
|
const memoryDir = makeSessionsDir();
|
|
const dbManager = new DatabaseManager(memoryDir);
|
|
|
|
try {
|
|
indexSession(dbManager, {
|
|
id: "bounded-override-session",
|
|
project: "bounded-project",
|
|
cwd: "/work/bounded",
|
|
startedAt: "2026-07-11T00:00:00.000Z",
|
|
endedAt: null,
|
|
messages: [{
|
|
id: "bounded-override-message",
|
|
role: "assistant",
|
|
content: `needle ${"y".repeat(10_000)}`,
|
|
timestamp: "2026-07-11T00:01:00.000Z",
|
|
}],
|
|
});
|
|
registerSessionSearchTool(mockPi, dbManager);
|
|
|
|
assert.match(JSON.stringify(captured.parameters), /snippetChars/);
|
|
const result = await captured.execute("tc-bounded-override", {
|
|
query: "needle",
|
|
snippetChars: 2_000,
|
|
});
|
|
|
|
assert.strictEqual(result.details.snippetChars, 2_000);
|
|
assert.strictEqual(result.details.truncatedCount, 1);
|
|
assert.match(result.content[0].text, /10007 chars total/);
|
|
assert.ok(result.content[0].text.length < 3_000);
|
|
} finally {
|
|
dbManager.close();
|
|
}
|
|
});
|
|
|
|
it("enforces a hard 50 KiB ceiling across many large legacy results", async () => {
|
|
let captured: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => { captured = def; },
|
|
} as any;
|
|
const memoryDir = makeSessionsDir();
|
|
const dbManager = new DatabaseManager(memoryDir);
|
|
|
|
try {
|
|
indexSession(dbManager, {
|
|
id: "aggregate-ceiling-session",
|
|
project: "aggregate-project",
|
|
cwd: "/work/aggregate",
|
|
startedAt: "2026-07-11T00:00:00.000Z",
|
|
endedAt: null,
|
|
messages: Array.from({ length: 20 }, (_, index) => ({
|
|
id: `aggregate-message-${index}`,
|
|
role: "assistant",
|
|
content: `needle-${index} ${"z".repeat(10_000)}`,
|
|
timestamp: `2026-07-11T00:${String(index).padStart(2, "0")}:00.000Z`,
|
|
})),
|
|
});
|
|
registerSessionSearchTool(mockPi, dbManager);
|
|
|
|
const result = await captured.execute("tc-aggregate-ceiling", {
|
|
query: "needle",
|
|
limit: 20,
|
|
snippetChars: 4_000,
|
|
});
|
|
const output = result.content[0].text as string;
|
|
|
|
assert.ok(output.length <= 50 * 1024, `expected <= 50 KiB, got ${output.length}`);
|
|
assert.strictEqual(result.details.outputTruncated, true);
|
|
assert.match(output, /output truncated/);
|
|
assert.match(output, /refine the query or lower the result limit/);
|
|
} finally {
|
|
dbManager.close();
|
|
}
|
|
});
|
|
|
|
it("bounds the zero-result response without echoing an oversized query", async () => {
|
|
let captured: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => { captured = def; },
|
|
} as any;
|
|
const memoryDir = makeSessionsDir();
|
|
const dbManager = new DatabaseManager(memoryDir);
|
|
|
|
try {
|
|
indexSession(dbManager, {
|
|
id: "zero-result-session",
|
|
project: "zero-result-project",
|
|
cwd: "/work/zero-result",
|
|
startedAt: "2026-07-11T00:00:00.000Z",
|
|
endedAt: null,
|
|
messages: [{
|
|
id: "zero-result-message",
|
|
role: "assistant",
|
|
content: "indexed haystack",
|
|
timestamp: "2026-07-11T00:01:00.000Z",
|
|
}],
|
|
});
|
|
registerSessionSearchTool(mockPi, dbManager);
|
|
const query = `${" ".repeat(60_000)}missing`;
|
|
|
|
const result = await captured.execute("tc-zero-result", { query });
|
|
const output = result.content[0].text as string;
|
|
|
|
assert.strictEqual(result.details.count, 0);
|
|
assert.ok(output.length <= 50 * 1024, `expected <= 50 KiB, got ${output.length}`);
|
|
assert.strictEqual(output.includes(query), false);
|
|
assert.ok(JSON.stringify(result.details).length < 1_000);
|
|
} finally {
|
|
dbManager.close();
|
|
}
|
|
});
|
|
|
|
it("registers and executes the anchor markdown-only schema when configured", async () => {
|
|
let captured: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => { captured = def; },
|
|
} as any;
|
|
const sessionsDir = makeSessionsDir();
|
|
const filePath = path.join(sessionsDir, "session.jsonl");
|
|
fs.writeFileSync(filePath, `${JSON.stringify({
|
|
type: "message",
|
|
timestamp: "2026-05-15T10:00:00.000Z",
|
|
sessionId: "session-1",
|
|
cwd: "/work/project",
|
|
message: { role: "user", content: "needle" },
|
|
})}\n`);
|
|
|
|
registerSessionSearchTool(mockPi, {} as any, { variant: "anchors" }, { sessionsDir });
|
|
|
|
const schema = JSON.stringify(captured.parameters);
|
|
assert.strictEqual(captured.name, "session_search");
|
|
assert.match(schema, /markdown/);
|
|
assert.doesNotMatch(schema, /query/);
|
|
assert.match(captured.description, /all terms must match/);
|
|
assert.match(captured.description, /any requires at least one listed term/);
|
|
assert.match(captured.description, /exclude removes matching ranges/);
|
|
assert.match(captured.description, /Output is plain text: count, optional message/);
|
|
assert.match(captured.description, /path:startLine-endLine with a short reason/);
|
|
assert.match(captured.description, /Example:\nfrom: 2026-05-14/);
|
|
assert.match(captured.promptGuidelines.join("\n"), /Use all for required terms/);
|
|
|
|
const empty = await captured.execute("tc-1", { markdown: "" });
|
|
assert.strictEqual(empty.details.success, false);
|
|
assert.strictEqual(empty.details.message, "markdown is required");
|
|
|
|
const result = await captured.execute("tc-2", { markdown: "any:\n- needle" });
|
|
assert.strictEqual(result.details.success, true);
|
|
assert.strictEqual(result.details.count, 1);
|
|
assert.deepStrictEqual(result.details.ranges.map((range: any) => ({
|
|
path: range.path,
|
|
startLine: range.startLine,
|
|
endLine: range.endLine,
|
|
reason: range.reason,
|
|
})), [{ path: filePath, startLine: 1, endLine: 1, reason: "matched any: needle" }]);
|
|
assert.strictEqual(result.details.output, result.content[0].text);
|
|
assert.match(result.content[0].text, /^count: 1\nanchors:\n-/);
|
|
assert.match(result.content[0].text, new RegExp(`${filePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:1-1 — matched any: needle`));
|
|
assert.doesNotMatch(result.content[0].text, /"ranges"/);
|
|
assert.doesNotMatch(result.content[0].text, /"startLine"/);
|
|
assert.doesNotMatch(result.content[0].text, /"sessionId"/);
|
|
});
|
|
});
|