pi-vcc 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/demo.gif +0 -0
- package/flow/plans/20260515-1300/plan.md +206 -0
- package/index.ts +14 -0
- package/package.json +36 -0
- package/pi-vcc-config.schema.json +131 -0
- package/scripts/audit-sessions.ts +88 -0
- package/scripts/benchmark-real-sessions.ts +25 -0
- package/scripts/compare-before-after.ts +36 -0
- package/scripts/dump-branch-output.ts +20 -0
- package/src/commands/pi-vcc.ts +33 -0
- package/src/commands/vcc-recall.ts +65 -0
- package/src/core/brief.ts +381 -0
- package/src/core/build-sections.ts +87 -0
- package/src/core/content.ts +60 -0
- package/src/core/filter-noise.ts +42 -0
- package/src/core/format-recall.ts +27 -0
- package/src/core/format.ts +56 -0
- package/src/core/lineage.ts +26 -0
- package/src/core/load-messages.ts +63 -0
- package/src/core/normalize.ts +66 -0
- package/src/core/recall-scope.ts +14 -0
- package/src/core/render-entries.ts +68 -0
- package/src/core/report.ts +237 -0
- package/src/core/sanitize.ts +5 -0
- package/src/core/search-entries.ts +230 -0
- package/src/core/settings.ts +215 -0
- package/src/core/skill-collapse.ts +35 -0
- package/src/core/summarize.ts +159 -0
- package/src/core/tool-args.ts +14 -0
- package/src/details.ts +7 -0
- package/src/extract/commits.ts +69 -0
- package/src/extract/files.ts +80 -0
- package/src/extract/goals.ts +79 -0
- package/src/extract/preferences.ts +55 -0
- package/src/extract/references.ts +214 -0
- package/src/extract/signals.ts +145 -0
- package/src/hooks/before-compact.ts +405 -0
- package/src/sections.ts +14 -0
- package/src/tools/recall.ts +109 -0
- package/src/types.ts +14 -0
- package/tests/before-compact-hook.test.ts +181 -0
- package/tests/before-compact.test.ts +140 -0
- package/tests/brief.test.ts +206 -0
- package/tests/build-sections.test.ts +90 -0
- package/tests/compile.test.ts +110 -0
- package/tests/config-integration.test.ts +107 -0
- package/tests/content.test.ts +31 -0
- package/tests/edge-cases.test.ts +368 -0
- package/tests/extract-goals.test.ts +86 -0
- package/tests/extract-preferences.test.ts +30 -0
- package/tests/extract-references.test.ts +475 -0
- package/tests/extract-signals.test.ts +561 -0
- package/tests/filter-noise.test.ts +61 -0
- package/tests/fixtures.ts +61 -0
- package/tests/format-recall.test.ts +30 -0
- package/tests/format.test.ts +91 -0
- package/tests/lineage.test.ts +33 -0
- package/tests/load-messages.test.ts +51 -0
- package/tests/normalize.test.ts +97 -0
- package/tests/real-sessions.test.ts +38 -0
- package/tests/recall-expand.test.ts +15 -0
- package/tests/recall-scope.test.ts +32 -0
- package/tests/recall-tool-scope.test.ts +67 -0
- package/tests/render-entries.test.ts +62 -0
- package/tests/report.test.ts +44 -0
- package/tests/sanitize.test.ts +24 -0
- package/tests/search-entries.test.ts +144 -0
- package/tests/settings-scaffold.test.ts +120 -0
- package/tests/settings.test.ts +32 -0
- package/tests/support/load-session.ts +23 -0
- package/tests/support/real-sessions.ts +51 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { searchEntries } from "../src/core/search-entries";
|
|
3
|
+
import type { RenderedEntry } from "../src/core/render-entries";
|
|
4
|
+
import type { Message } from "@mariozechner/pi-ai";
|
|
5
|
+
|
|
6
|
+
const entries: RenderedEntry[] = [
|
|
7
|
+
{ index: 0, role: "user", summary: "Fix login bug" },
|
|
8
|
+
{ index: 1, role: "assistant", summary: "Reading auth.ts" },
|
|
9
|
+
{ index: 2, role: "tool_result", summary: "[Read] code here" },
|
|
10
|
+
{ index: 3, role: "assistant", summary: "Found the root cause in auth module" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const messages: Message[] = [
|
|
14
|
+
{ role: "user", content: "Fix login bug" } as any,
|
|
15
|
+
{ role: "assistant", content: [{ type: "text", text: "Reading auth.ts" }] } as any,
|
|
16
|
+
{ role: "toolResult", content: [{ type: "text", text: "[Read] code here" }] } as any,
|
|
17
|
+
{ role: "assistant", content: [{ type: "text", text: "Found the root cause in auth module" }] } as any,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe("searchEntries", () => {
|
|
21
|
+
it("returns all for empty query", () => {
|
|
22
|
+
expect(searchEntries(entries, messages)).toEqual(entries);
|
|
23
|
+
expect(searchEntries(entries, messages, "")).toEqual(entries);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("filters by single term", () => {
|
|
27
|
+
const r = searchEntries(entries, messages, "login");
|
|
28
|
+
expect(r).toHaveLength(1);
|
|
29
|
+
expect(r[0].index).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns empty for no match", () => {
|
|
33
|
+
expect(searchEntries(entries, messages, "xyz123")).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("finds keyword beyond clip boundary in full content", () => {
|
|
37
|
+
const longText = "A".repeat(400) + " hidden_keyword here";
|
|
38
|
+
const longEntries: RenderedEntry[] = [
|
|
39
|
+
{ index: 0, role: "user", summary: "A".repeat(300) },
|
|
40
|
+
];
|
|
41
|
+
const longMsgs: Message[] = [
|
|
42
|
+
{ role: "user", content: longText } as any,
|
|
43
|
+
];
|
|
44
|
+
const r = searchEntries(longEntries, longMsgs, "hidden_keyword");
|
|
45
|
+
expect(r).toHaveLength(1);
|
|
46
|
+
expect(r[0].snippet).toContain("hidden_keyword");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns snippet around matched term", () => {
|
|
50
|
+
const r = searchEntries(entries, messages, "root");
|
|
51
|
+
expect(r).toHaveLength(1);
|
|
52
|
+
expect(r[0].snippet).toBeDefined();
|
|
53
|
+
expect(r[0].snippet).toContain("root");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── regex support ──
|
|
57
|
+
|
|
58
|
+
it("supports regex pattern: alternation", () => {
|
|
59
|
+
const r = searchEntries(entries, messages, "login|auth");
|
|
60
|
+
expect(r).toHaveLength(3); // "login bug", "auth.ts", "auth module"
|
|
61
|
+
expect(r.map((h) => h.index).sort()).toEqual([0, 1, 3]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("supports regex pattern: wildcard", () => {
|
|
65
|
+
const r = searchEntries(entries, messages, "Read.*auth");
|
|
66
|
+
expect(r).toHaveLength(1);
|
|
67
|
+
expect(r[0].index).toBe(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("falls back to escaped literal for invalid regex", () => {
|
|
71
|
+
const extraEntries: RenderedEntry[] = [
|
|
72
|
+
{ index: 0, role: "user", summary: "test (foo" },
|
|
73
|
+
{ index: 1, role: "assistant", summary: "no match here" },
|
|
74
|
+
];
|
|
75
|
+
const extraMsgs: Message[] = [
|
|
76
|
+
{ role: "user", content: "error with (foo pattern" } as any,
|
|
77
|
+
{ role: "assistant", content: [{ type: "text", text: "no match here" }] } as any,
|
|
78
|
+
];
|
|
79
|
+
const r = searchEntries(extraEntries, extraMsgs, "(foo");
|
|
80
|
+
expect(r).toHaveLength(1);
|
|
81
|
+
expect(r[0].index).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("regex is case-insensitive", () => {
|
|
85
|
+
const r = searchEntries(entries, messages, "FIX|ROOT");
|
|
86
|
+
expect(r).toHaveLength(2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── natural language queries (OR logic + ranking) ──
|
|
90
|
+
|
|
91
|
+
it("natural language query uses OR logic", () => {
|
|
92
|
+
// "root cause auth" -- matches entries containing ANY of these terms
|
|
93
|
+
const r = searchEntries(entries, messages, "root cause auth");
|
|
94
|
+
expect(r.length).toBeGreaterThanOrEqual(2); // #3 has all 3, #1 has auth
|
|
95
|
+
// Best match (highest BM25) should come first
|
|
96
|
+
expect(r[0].index).toBe(3); // "Found the root cause in auth module" matches all 3
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("natural language ranks by BM25 score", () => {
|
|
100
|
+
const r = searchEntries(entries, messages, "root cause auth");
|
|
101
|
+
// Top result has more terms matched = higher BM25 score
|
|
102
|
+
expect(r[0].matchCount!).toBeGreaterThanOrEqual(r[r.length - 1].matchCount!);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("filters stopwords from queries", () => {
|
|
106
|
+
// "the root cause of it" → stopwords: the, of, it → meaningful: root, cause
|
|
107
|
+
const r = searchEntries(entries, messages, "the root cause of it");
|
|
108
|
+
expect(r).toHaveLength(1);
|
|
109
|
+
expect(r[0].index).toBe(3);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("keeps all terms if all are stopwords", () => {
|
|
113
|
+
// When all terms are stopwords, keep them (don't drop everything)
|
|
114
|
+
// "the" appears in "Found the root cause" so it matches
|
|
115
|
+
const r = searchEntries(entries, messages, "the");
|
|
116
|
+
expect(r.length).toBeGreaterThan(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── line-based snippet ──
|
|
120
|
+
|
|
121
|
+
it("snippet shows context lines around match", () => {
|
|
122
|
+
const multiline = "line 0\nline 1\nline 2 TARGET\nline 3\nline 4\nline 5";
|
|
123
|
+
const e: RenderedEntry[] = [{ index: 0, role: "user", summary: "test" }];
|
|
124
|
+
const m: Message[] = [{ role: "user", content: multiline } as any];
|
|
125
|
+
const r = searchEntries(e, m, "TARGET");
|
|
126
|
+
expect(r).toHaveLength(1);
|
|
127
|
+
const snip = r[0].snippet!;
|
|
128
|
+
expect(snip).toContain("line 2 TARGET");
|
|
129
|
+
expect(snip).toContain("line 0");
|
|
130
|
+
expect(snip).toContain("line 4");
|
|
131
|
+
expect(snip).not.toContain("line 5");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("snippet handles match at beginning", () => {
|
|
135
|
+
const multiline = "TARGET here\nline 1\nline 2\nline 3";
|
|
136
|
+
const e: RenderedEntry[] = [{ index: 0, role: "user", summary: "test" }];
|
|
137
|
+
const m: Message[] = [{ role: "user", content: multiline } as any];
|
|
138
|
+
const r = searchEntries(e, m, "TARGET");
|
|
139
|
+
const snip = r[0].snippet!;
|
|
140
|
+
expect(snip).toContain("TARGET here");
|
|
141
|
+
expect(snip).toContain("line 2");
|
|
142
|
+
expect(snip).not.toContain("line 3");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock fs/promises before importing settings
|
|
4
|
+
const mockReadFile = vi.fn();
|
|
5
|
+
const mockWriteFile = vi.fn();
|
|
6
|
+
const mockMkdir = vi.fn();
|
|
7
|
+
|
|
8
|
+
vi.mock("fs/promises", () => ({
|
|
9
|
+
readFile: mockReadFile,
|
|
10
|
+
writeFile: mockWriteFile,
|
|
11
|
+
mkdir: mockMkdir,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// We need to mock dirname to control the directory path
|
|
15
|
+
// but it's used internally. Instead, mock the env var to control settingsPath().
|
|
16
|
+
const ORIGINAL_ENV = process.env.PI_VCC_CONFIG_PATH;
|
|
17
|
+
|
|
18
|
+
describe("scaffoldSettingsAsync", () => {
|
|
19
|
+
let scaffoldSettingsAsync: () => Promise<void>;
|
|
20
|
+
let DEFAULT_SETTINGS: Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
// Set a known config path
|
|
25
|
+
process.env.PI_VCC_CONFIG_PATH = "/tmp/test-vcc/pi-vcc-config.json";
|
|
26
|
+
// Re-import to pick up env var
|
|
27
|
+
vi.resetModules();
|
|
28
|
+
const mod = await import("../src/core/settings");
|
|
29
|
+
scaffoldSettingsAsync = mod.scaffoldSettingsAsync;
|
|
30
|
+
DEFAULT_SETTINGS = mod.DEFAULT_SETTINGS as Record<string, unknown>;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("creates config file with defaults when no file exists", async () => {
|
|
34
|
+
// readFile throws ENOENT → file doesn't exist
|
|
35
|
+
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
|
36
|
+
enoent.code = "ENOENT";
|
|
37
|
+
mockReadFile.mockRejectedValue(enoent);
|
|
38
|
+
mockMkdir.mockResolvedValue(undefined);
|
|
39
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
40
|
+
|
|
41
|
+
await scaffoldSettingsAsync();
|
|
42
|
+
|
|
43
|
+
// Should mkdir for parent dir
|
|
44
|
+
expect(mockMkdir).toHaveBeenCalledWith("/tmp/test-vcc", { recursive: true });
|
|
45
|
+
// Should write default settings
|
|
46
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
47
|
+
"/tmp/test-vcc/pi-vcc-config.json",
|
|
48
|
+
`${JSON.stringify(DEFAULT_SETTINGS, null, 2)}\n`,
|
|
49
|
+
);
|
|
50
|
+
// Should be called exactly once (create, no second write)
|
|
51
|
+
expect(mockWriteFile).toHaveBeenCalledTimes(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("does NOT clobber existing valid config (only fills missing keys)", async () => {
|
|
55
|
+
const existing = { overrideDefaultCompaction: true, debug: false, extraction: { references: { enabled: true, extraUrlPatterns: [], extraGithubRefPatterns: [], extraVersionPatterns: [], extraBranchPatterns: [] }, keySignals: { enabled: true, extraConstraintPatterns: [], extraDecisionPatterns: [], extraStatusPatterns: [] }, goals: { enabled: true, extraTaskVerbs: [], extraScopeChangeWords: [] } } };
|
|
56
|
+
mockReadFile.mockResolvedValue(JSON.stringify(existing));
|
|
57
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
58
|
+
|
|
59
|
+
await scaffoldSettingsAsync();
|
|
60
|
+
|
|
61
|
+
// All DEFAULT_SETTINGS keys already present → no write
|
|
62
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("fills in missing default keys and writes", async () => {
|
|
66
|
+
// Partial config missing 'debug' key
|
|
67
|
+
const partial = { overrideDefaultCompaction: false, extraction: { references: { enabled: true, extraUrlPatterns: [], extraGithubRefPatterns: [], extraVersionPatterns: [], extraBranchPatterns: [] }, keySignals: { enabled: true, extraConstraintPatterns: [], extraDecisionPatterns: [], extraStatusPatterns: [] }, goals: { enabled: true, extraTaskVerbs: [], extraScopeChangeWords: [] } } };
|
|
68
|
+
mockReadFile.mockResolvedValue(JSON.stringify(partial));
|
|
69
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
70
|
+
|
|
71
|
+
await scaffoldSettingsAsync();
|
|
72
|
+
|
|
73
|
+
expect(mockWriteFile).toHaveBeenCalledTimes(1);
|
|
74
|
+
const written = mockWriteFile.mock.calls[0][1] as string;
|
|
75
|
+
const parsed = JSON.parse(written);
|
|
76
|
+
expect(parsed.debug).toBe(false);
|
|
77
|
+
// Existing key preserved
|
|
78
|
+
expect(parsed.overrideDefaultCompaction).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("no-ops when existing file has invalid JSON", async () => {
|
|
82
|
+
mockReadFile.mockResolvedValue("this is not json {{{");
|
|
83
|
+
|
|
84
|
+
await scaffoldSettingsAsync();
|
|
85
|
+
|
|
86
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
87
|
+
expect(mockMkdir).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("creates parent directory if missing", async () => {
|
|
91
|
+
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
|
92
|
+
enoent.code = "ENOENT";
|
|
93
|
+
mockReadFile.mockRejectedValue(enoent);
|
|
94
|
+
mockMkdir.mockResolvedValue(undefined);
|
|
95
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
96
|
+
|
|
97
|
+
await scaffoldSettingsAsync();
|
|
98
|
+
|
|
99
|
+
expect(mockMkdir).toHaveBeenCalledWith("/tmp/test-vcc", { recursive: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("no-ops on any error (best-effort, catches exceptions)", async () => {
|
|
103
|
+
mockReadFile.mockRejectedValue(new Error("permission denied"));
|
|
104
|
+
|
|
105
|
+
// Should not throw
|
|
106
|
+
await expect(scaffoldSettingsAsync()).resolves.toBeUndefined();
|
|
107
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns void/undefined (fire-and-forget style)", async () => {
|
|
111
|
+
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
|
112
|
+
enoent.code = "ENOENT";
|
|
113
|
+
mockReadFile.mockRejectedValue(enoent);
|
|
114
|
+
mockMkdir.mockResolvedValue(undefined);
|
|
115
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
116
|
+
|
|
117
|
+
const result = await scaffoldSettingsAsync();
|
|
118
|
+
expect(result).toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { DEFAULT_SETTINGS, loadSettings } from "../src/core/settings";
|
|
3
|
+
|
|
4
|
+
describe("settings", () => {
|
|
5
|
+
it("DEFAULT_SETTINGS has extraction config with all categories", () => {
|
|
6
|
+
expect(DEFAULT_SETTINGS.extraction).toBeDefined();
|
|
7
|
+
expect(DEFAULT_SETTINGS.extraction.references.enabled).toBe(true);
|
|
8
|
+
expect(DEFAULT_SETTINGS.extraction.keySignals.enabled).toBe(true);
|
|
9
|
+
expect(DEFAULT_SETTINGS.extraction.goals.enabled).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("DEFAULT_SETTINGS extraction arrays are empty (additive only)", () => {
|
|
13
|
+
expect(DEFAULT_SETTINGS.extraction.references.extraUrlPatterns).toEqual([]);
|
|
14
|
+
expect(DEFAULT_SETTINGS.extraction.references.extraGithubRefPatterns).toEqual([]);
|
|
15
|
+
expect(DEFAULT_SETTINGS.extraction.references.extraVersionPatterns).toEqual([]);
|
|
16
|
+
expect(DEFAULT_SETTINGS.extraction.references.extraBranchPatterns).toEqual([]);
|
|
17
|
+
expect(DEFAULT_SETTINGS.extraction.keySignals.extraConstraintPatterns).toEqual([]);
|
|
18
|
+
expect(DEFAULT_SETTINGS.extraction.keySignals.extraDecisionPatterns).toEqual([]);
|
|
19
|
+
expect(DEFAULT_SETTINGS.extraction.keySignals.extraStatusPatterns).toEqual([]);
|
|
20
|
+
expect(DEFAULT_SETTINGS.extraction.goals.extraTaskVerbs).toEqual([]);
|
|
21
|
+
expect(DEFAULT_SETTINGS.extraction.goals.extraScopeChangeWords).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("loadSettings returns valid config with extraction defaults", async () => {
|
|
25
|
+
const s = await loadSettings();
|
|
26
|
+
// Don't assert on boolean fields that may differ per environment
|
|
27
|
+
expect(s.extraction).toBeDefined();
|
|
28
|
+
expect(s.extraction.references.enabled).toBe(true);
|
|
29
|
+
expect(s.extraction.keySignals.enabled).toBe(true);
|
|
30
|
+
expect(s.extraction.goals.enabled).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { buildSessionContext, loadEntriesFromFile } from "../../node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.js";
|
|
2
|
+
import type { Message } from "@mariozechner/pi-ai";
|
|
3
|
+
|
|
4
|
+
export interface LoadedSession {
|
|
5
|
+
messageCount: number;
|
|
6
|
+
skippedCount: number;
|
|
7
|
+
messages: Message[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const loadSessionMessages = (file: string): LoadedSession => {
|
|
11
|
+
const entries = loadEntriesFromFile(file);
|
|
12
|
+
const sessionEntries = entries.filter((entry) => entry.type !== "header");
|
|
13
|
+
const context = buildSessionContext(sessionEntries as any);
|
|
14
|
+
const messages = (context.messages as any[]).filter(
|
|
15
|
+
(msg): msg is Message =>
|
|
16
|
+
msg && typeof msg.role === "string" && "content" in msg,
|
|
17
|
+
);
|
|
18
|
+
return {
|
|
19
|
+
messageCount: messages.length,
|
|
20
|
+
skippedCount: context.messages.length - messages.length,
|
|
21
|
+
messages,
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, copyFile, chmod, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
|
+
|
|
5
|
+
const SESSION_ROOT = join(process.env.HOME ?? "", ".pi/agent/sessions");
|
|
6
|
+
|
|
7
|
+
export interface SessionSample {
|
|
8
|
+
source: string;
|
|
9
|
+
copy: string;
|
|
10
|
+
size: number;
|
|
11
|
+
mtimeMs: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const walk = async (dir: string): Promise<string[]> => {
|
|
15
|
+
const names = await readdir(dir, { withFileTypes: true });
|
|
16
|
+
const out: string[] = [];
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
const path = join(dir, name.name);
|
|
19
|
+
if (name.isDirectory()) out.push(...await walk(path));
|
|
20
|
+
else if (name.isFile() && path.endsWith(".jsonl")) out.push(path);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const pickLargest = async (limit: number): Promise<string[]> => {
|
|
26
|
+
const files = await walk(SESSION_ROOT);
|
|
27
|
+
const sized = await Promise.all(
|
|
28
|
+
files.map(async (file) => ({ file, size: (await stat(file)).size })),
|
|
29
|
+
);
|
|
30
|
+
return sized.sort((a, b) => b.size - a.size).slice(0, limit).map((x) => x.file);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const prepareSessionSamples = async (limit = 2): Promise<SessionSample[]> => {
|
|
34
|
+
const selected = await pickLargest(limit);
|
|
35
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-vcc-sessions-"));
|
|
36
|
+
await mkdir(dir, { recursive: true });
|
|
37
|
+
const samples: SessionSample[] = [];
|
|
38
|
+
for (const source of selected) {
|
|
39
|
+
const srcStat = await stat(source);
|
|
40
|
+
const copy = join(dir, basename(source));
|
|
41
|
+
await copyFile(source, copy);
|
|
42
|
+
await chmod(copy, 0o444);
|
|
43
|
+
samples.push({ source, copy, size: srcStat.size, mtimeMs: srcStat.mtimeMs });
|
|
44
|
+
}
|
|
45
|
+
return samples;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const readSourceStat = async (sample: SessionSample) => {
|
|
49
|
+
const s = await stat(sample.source);
|
|
50
|
+
return { size: s.size, mtimeMs: s.mtimeMs };
|
|
51
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"outDir": "dist"
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*.ts"],
|
|
13
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
14
|
+
}
|