@selesai/code 0.13.29 → 0.13.30
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/CHANGELOG.md +10 -0
- package/README.md +8 -2
- package/dist/core/model-registry.d.ts +13 -1
- package/dist/core/model-registry.js +16 -0
- package/dist/defaults/settings.json +8 -13
- package/dist/extensions/capability-gateway/catalog.ts +2 -2
- package/dist/extensions/capability-gateway/index.ts +103 -18
- package/dist/extensions/capability-gateway/integration.test.ts +394 -8
- package/dist/extensions/capability-gateway/routing.test.ts +415 -0
- package/dist/extensions/capability-gateway/routing.ts +221 -0
- package/dist/extensions/grep-app/index.ts +10 -0
- package/dist/extensions/jev/decisions.test.ts +316 -0
- package/dist/extensions/jev/decisions.ts +527 -0
- package/dist/extensions/jev/test-support.ts +233 -0
- package/dist/extensions/jev-advisory-lifecycle.test.ts +206 -0
- package/dist/extensions/jev-advisory-memory.test.ts +191 -0
- package/dist/extensions/jev-advisory-recommendations.test.ts +240 -0
- package/dist/extensions/jev-advisory-routing.ts +539 -0
- package/dist/extensions/package.json +2 -2
- package/dist/extensions/pi-hermes-memory/src/memory-search-bridge.ts +40 -0
- package/dist/extensions/pi-hermes-memory/src/tools/memory-search-tool.ts +57 -46
- package/dist/extensions/pi-hermes-memory/src/tools/memory-tool.ts +17 -0
- package/dist/extensions/pi-hermes-memory/src/tools/session-search-tool.ts +10 -0
- package/dist/extensions/pi-hermes-memory/src/tools/skill-tool.ts +5 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/memory-search-tool.test.ts +25 -0
- package/dist/extensions/pi-intercom/index.ts +10 -0
- package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +5 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +5 -0
- package/dist/extensions/pi-subagents/src/intercom/native-supervisor-channel.ts +10 -0
- package/dist/extensions/pi-subagents/src/runs/background/wait-tool.ts +10 -0
- package/dist/extensions/pi-web-agent/src/extension.ts +5 -0
- package/dist/extensions/question/index.ts +5 -0
- package/dist/extensions/tokenin-onboarding.ts +185 -0
- package/dist/skills/code-review-and-quality/SKILL.md +396 -0
- package/dist/skills/code-simplification/SKILL.md +331 -0
- package/dist/skills/incremental-implementation/SKILL.md +249 -0
- package/dist/skills/planning-and-task-breakdown/SKILL.md +257 -0
- package/dist/skills/references/agent-skills-LICENSE +21 -0
- package/dist/skills/references/definition-of-done.md +67 -0
- package/dist/skills/references/performance-checklist.md +236 -0
- package/dist/skills/references/security-checklist.md +248 -0
- package/docs/settings.md +68 -31
- package/package.json +3 -3
- package/dist/extensions/auto-model.test.ts +0 -438
- package/dist/extensions/auto-model.ts +0 -357
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test doubles for the Jev advisory routing extension.
|
|
3
|
+
*
|
|
4
|
+
* The extension is driven the way Selesai drives it: an `input` event opens a
|
|
5
|
+
* turn, `before_agent_start` answers it, and telemetry arrives on the shared
|
|
6
|
+
* event bus. Tests assert observable outcomes — the advisory context the parent
|
|
7
|
+
* would receive, the request Jev would be sent, and the telemetry shape — never
|
|
8
|
+
* private helper order.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { vi } from "vitest";
|
|
14
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
15
|
+
import type { ExtensionAPI } from "@selesai/code";
|
|
16
|
+
import { JEV_ROUTING_EVENT, type JevAdvisoryConfig } from "./decisions.ts";
|
|
17
|
+
import {
|
|
18
|
+
isMemorySearchRequest,
|
|
19
|
+
MEMORY_SEARCH_REQUEST_EVENT,
|
|
20
|
+
type MemorySearchRequestInput,
|
|
21
|
+
type MemorySearchResponse,
|
|
22
|
+
} from "../pi-hermes-memory/src/memory-search-bridge.ts";
|
|
23
|
+
|
|
24
|
+
export interface SkillStub {
|
|
25
|
+
name: string;
|
|
26
|
+
description: string;
|
|
27
|
+
filePath: string;
|
|
28
|
+
scope?: "user" | "project" | "temporary";
|
|
29
|
+
disableModelInvocation?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CommandStub {
|
|
33
|
+
name: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
source: "extension" | "prompt" | "skill";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AdvisoryMessage {
|
|
39
|
+
customType: string;
|
|
40
|
+
content: string;
|
|
41
|
+
display?: boolean;
|
|
42
|
+
details?: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Handler = (event: unknown, ctx: unknown) => unknown;
|
|
46
|
+
|
|
47
|
+
export interface AdvisoryHarness {
|
|
48
|
+
pi: Record<string, unknown>;
|
|
49
|
+
handlers: Map<string, Handler[]>;
|
|
50
|
+
ctx: Record<string, unknown>;
|
|
51
|
+
cwd: string;
|
|
52
|
+
/** Every telemetry payload published on the routing channel, in order. */
|
|
53
|
+
telemetry: Record<string, unknown>[];
|
|
54
|
+
|
|
55
|
+
/** The message the extension would hand the parent, if it recommended anything. */
|
|
56
|
+
advise(prompt: string, input?: Record<string, unknown>): Promise<AdvisoryMessage | undefined>;
|
|
57
|
+
fire(event: string, payload?: unknown): Promise<unknown>;
|
|
58
|
+
/** Fire the same turn's hook again, as a retry or a replayed run would. */
|
|
59
|
+
replay(prompt: string): Promise<unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A registered model `jevModel` inherits its base URL from. */
|
|
63
|
+
export function providerTemplate() {
|
|
64
|
+
return {
|
|
65
|
+
provider: "tokenin",
|
|
66
|
+
id: "celestial-pro",
|
|
67
|
+
name: "celestial-pro",
|
|
68
|
+
api: "openai-completions",
|
|
69
|
+
baseUrl: "https://lite.andlet.me/v1",
|
|
70
|
+
reasoning: true,
|
|
71
|
+
input: ["text"],
|
|
72
|
+
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
|
|
73
|
+
contextWindow: 393216,
|
|
74
|
+
maxTokens: 64000,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A directory that looks like the working tree of a Git repository. */
|
|
79
|
+
export function makeRepo(prefix: string): { root: string; repo: string } {
|
|
80
|
+
const root = mkdtempSync(join(tmpdir(), prefix));
|
|
81
|
+
const repo = join(root, "repo");
|
|
82
|
+
mkdirSync(join(repo, ".git"), { recursive: true });
|
|
83
|
+
return { root, repo };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function writeJevSettings(settingsPath: string, advisory: unknown): void {
|
|
87
|
+
writeFileSync(settingsPath, JSON.stringify({ jevAdvisory: advisory }), "utf-8");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function makeHarness(options: {
|
|
91
|
+
settingsPath: string;
|
|
92
|
+
extension: (pi: ExtensionAPI) => void;
|
|
93
|
+
cwd: string;
|
|
94
|
+
skills?: SkillStub[];
|
|
95
|
+
commands?: CommandStub[];
|
|
96
|
+
branch?: unknown[];
|
|
97
|
+
credential?: boolean;
|
|
98
|
+
template?: boolean;
|
|
99
|
+
/** The completion transport the route's registry serves; a failing one by default. */
|
|
100
|
+
complete?: ReturnType<typeof vi.fn>;
|
|
101
|
+
/** Local Hermes lookup result; this simulates the bundled memory extension. */
|
|
102
|
+
memorySearch?: (input: MemorySearchRequestInput) => MemorySearchResponse | undefined;
|
|
103
|
+
}): AdvisoryHarness {
|
|
104
|
+
const handlers = new Map<string, Handler[]>();
|
|
105
|
+
const telemetry: Record<string, unknown>[] = [];
|
|
106
|
+
const busHandlers = new Map<string, Array<(data: unknown) => void>>();
|
|
107
|
+
const pi = {
|
|
108
|
+
on: vi.fn((event: string, handler: Handler) => {
|
|
109
|
+
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
|
110
|
+
}),
|
|
111
|
+
events: {
|
|
112
|
+
on: (channel: string, handler: (data: unknown) => void) => {
|
|
113
|
+
busHandlers.set(channel, [...(busHandlers.get(channel) ?? []), handler]);
|
|
114
|
+
},
|
|
115
|
+
emit: (channel: string, data: unknown) => {
|
|
116
|
+
if (channel === JEV_ROUTING_EVENT) telemetry.push(data as Record<string, unknown>);
|
|
117
|
+
for (const handler of busHandlers.get(channel) ?? []) handler(data);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
getResolvedSkills: () => options.skills ?? [],
|
|
121
|
+
getCommands: () => options.commands ?? [],
|
|
122
|
+
};
|
|
123
|
+
const ctx = {
|
|
124
|
+
cwd: options.cwd,
|
|
125
|
+
hasUI: true,
|
|
126
|
+
mode: "tui",
|
|
127
|
+
isIdle: () => true,
|
|
128
|
+
isProjectTrusted: () => true,
|
|
129
|
+
getSystemPrompt: () => "system prompt",
|
|
130
|
+
modelRegistry: {
|
|
131
|
+
getAll: () => (options.template === false ? [] : [providerTemplate()]),
|
|
132
|
+
getApiKeyAndHeaders: vi.fn(async () =>
|
|
133
|
+
options.credential === false ? { ok: false, error: "no key" } : { ok: true, apiKey: "key", headers: {} },
|
|
134
|
+
),
|
|
135
|
+
// A route that reaches the provider in a test must fail closed, so the
|
|
136
|
+
// default answer is an error message rather than a live call.
|
|
137
|
+
complete: options.complete ?? vi.fn(async () => jevResponse("{}", { stopReason: "error", errorMessage: "no transport" })),
|
|
138
|
+
},
|
|
139
|
+
sessionManager: { getBranch: () => options.branch ?? [], getEntries: () => options.branch ?? [] },
|
|
140
|
+
ui: { notify: vi.fn(), setStatus: vi.fn() },
|
|
141
|
+
};
|
|
142
|
+
options.extension(pi as unknown as ExtensionAPI);
|
|
143
|
+
pi.events.on(MEMORY_SEARCH_REQUEST_EVENT, (request) => {
|
|
144
|
+
if (!isMemorySearchRequest(request)) return;
|
|
145
|
+
const result = (options.memorySearch ?? (() => ({ success: true, count: 1, output: "LOCAL_MEMORY_RESULT" })))(request.input);
|
|
146
|
+
if (result) request.respond(result);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const fire = async (event: string, payload?: unknown): Promise<unknown> => {
|
|
150
|
+
let result: unknown;
|
|
151
|
+
for (const handler of handlers.get(event) ?? []) {
|
|
152
|
+
result = (await handler(payload ?? { type: event }, ctx)) ?? result;
|
|
153
|
+
}
|
|
154
|
+
return result;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const advisory: AdvisoryHarness = {
|
|
158
|
+
pi,
|
|
159
|
+
handlers,
|
|
160
|
+
ctx,
|
|
161
|
+
cwd: options.cwd,
|
|
162
|
+
telemetry,
|
|
163
|
+
fire,
|
|
164
|
+
async advise(prompt, input = {}) {
|
|
165
|
+
await fire("input", { type: "input", text: prompt, source: "interactive", ...input });
|
|
166
|
+
const result = await fire("before_agent_start", { type: "before_agent_start", prompt });
|
|
167
|
+
return (result as { message?: AdvisoryMessage } | undefined)?.message;
|
|
168
|
+
},
|
|
169
|
+
replay(prompt) {
|
|
170
|
+
return fire("before_agent_start", { type: "before_agent_start", prompt });
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
return advisory;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The decisions request inside a completion call, or undefined. */
|
|
177
|
+
function callContent(call: unknown[] | undefined): string | undefined {
|
|
178
|
+
const content = (call?.[1] as { messages?: Array<{ content?: unknown }> } | undefined)?.messages?.[0]?.content;
|
|
179
|
+
return typeof content === "string" ? content : undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The parsed decision request sent to Jev last, or undefined. */
|
|
183
|
+
export function sentPayload(transport: { mock: { calls: unknown[][] } }): Record<string, unknown> | undefined {
|
|
184
|
+
const content = callContent(transport.mock.calls.at(-1));
|
|
185
|
+
return content === undefined ? undefined : JSON.parse(content);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Every decision request sent to Jev, parsed, oldest first. */
|
|
189
|
+
export function sentPayloads(transport: { mock: { calls: unknown[][] } }): Array<Record<string, unknown>> {
|
|
190
|
+
return transport.mock.calls
|
|
191
|
+
.map(callContent)
|
|
192
|
+
.filter((content): content is string => content !== undefined)
|
|
193
|
+
.map((content) => JSON.parse(content) as Record<string, unknown>);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The answers envelope Jev returns for the given choices. */
|
|
197
|
+
export function jevAnswers(answers: Record<string, { choice: string; confidence?: number }>): string {
|
|
198
|
+
return JSON.stringify({
|
|
199
|
+
answers: Object.fromEntries(
|
|
200
|
+
Object.entries(answers).map(([question, answer]) => [
|
|
201
|
+
question,
|
|
202
|
+
{ type: "choice", choice: answer.choice, ...(answer.confidence === undefined ? {} : { confidence: answer.confidence }) },
|
|
203
|
+
]),
|
|
204
|
+
),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A decisions answer as pi's completion transport hands it back. */
|
|
209
|
+
export function jevResponse(text: string, overrides: Partial<AssistantMessage> = {}): AssistantMessage {
|
|
210
|
+
return {
|
|
211
|
+
role: "assistant",
|
|
212
|
+
content: [{ type: "text", text }],
|
|
213
|
+
api: "openai-completions",
|
|
214
|
+
provider: "tokenin",
|
|
215
|
+
model: "jev-1.13",
|
|
216
|
+
usage: {
|
|
217
|
+
input: 0,
|
|
218
|
+
output: 0,
|
|
219
|
+
cacheRead: 0,
|
|
220
|
+
cacheWrite: 0,
|
|
221
|
+
totalTokens: 0,
|
|
222
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
223
|
+
},
|
|
224
|
+
stopReason: "stop",
|
|
225
|
+
timestamp: 1,
|
|
226
|
+
...overrides,
|
|
227
|
+
} as AssistantMessage;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A route config with everything enabled, for tests that are not about enablement. */
|
|
231
|
+
export function enabledAdvisoryRoutes(routes: Array<"memory" | "recommendations"> = ["memory", "recommendations"]) {
|
|
232
|
+
return Object.fromEntries(routes.map((route) => [route, { enabled: true }])) as Partial<JevAdvisoryConfig>["routes"];
|
|
233
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input lifecycle, idempotency, and telemetry for the advisory routes.
|
|
3
|
+
*
|
|
4
|
+
* Only idle, top-level, interactive user input is eligible: extension-injected
|
|
5
|
+
* turns, slash commands, queued steering/follow-up input, and empty input never
|
|
6
|
+
* reach Jev. One eligible input produces at most one advisory, however many
|
|
7
|
+
* times the hook is re-entered, and telemetry never carries content or breaks a
|
|
8
|
+
* turn.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
|
|
15
|
+
const state = vi.hoisted(() => ({ settingsPath: "" }));
|
|
16
|
+
|
|
17
|
+
vi.mock("@selesai/code", () => ({ getSettingsPath: () => state.settingsPath }));
|
|
18
|
+
// The client speaks through pi's completion transport (the Token-In provider
|
|
19
|
+
// layer owns the non-streaming decisions request), so a suite stubs that.
|
|
20
|
+
import jevAdvisoryRoutingExtension from "./jev-advisory-routing.ts";
|
|
21
|
+
import { JEV_ROUTING_EVENT } from "./jev/decisions.ts";
|
|
22
|
+
import {
|
|
23
|
+
enabledAdvisoryRoutes,
|
|
24
|
+
jevAnswers,
|
|
25
|
+
jevResponse,
|
|
26
|
+
makeHarness,
|
|
27
|
+
makeRepo,
|
|
28
|
+
writeJevSettings,
|
|
29
|
+
type SkillStub,
|
|
30
|
+
} from "./jev/test-support.ts";
|
|
31
|
+
|
|
32
|
+
const completeMock = vi.fn();
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
let root: string;
|
|
37
|
+
let repo: string;
|
|
38
|
+
const skills: SkillStub[] = [{ name: "implanger", description: "Plan a UI change.", filePath: "/tmp/SKILL.md" }];
|
|
39
|
+
|
|
40
|
+
beforeAll(() => {
|
|
41
|
+
const created = makeRepo("jev-lifecycle-");
|
|
42
|
+
root = created.root;
|
|
43
|
+
repo = created.repo;
|
|
44
|
+
state.settingsPath = join(root, "agent", "settings.json");
|
|
45
|
+
mkdirSync(join(root, "agent"), { recursive: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
49
|
+
|
|
50
|
+
/** One answer envelope carrying a decision for every route's questions. */
|
|
51
|
+
function answerEverything(): void {
|
|
52
|
+
completeMock.mockResolvedValue(
|
|
53
|
+
jevResponse(
|
|
54
|
+
jevAnswers({
|
|
55
|
+
memory_target: { choice: "project", confidence: 0.9 },
|
|
56
|
+
memory_category: { choice: "convention", confidence: 0.9 },
|
|
57
|
+
recommendation: { choice: "skill:implanger", confidence: 0.8 },
|
|
58
|
+
verification: { choice: "targeted", confidence: 0.9 },
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function harness(advisory: unknown = { routes: enabledAdvisoryRoutes() }) {
|
|
65
|
+
writeJevSettings(state.settingsPath, advisory);
|
|
66
|
+
return makeHarness({
|
|
67
|
+
settingsPath: state.settingsPath,
|
|
68
|
+
extension: jevAdvisoryRoutingExtension,
|
|
69
|
+
cwd: repo,
|
|
70
|
+
skills,
|
|
71
|
+
complete: completeMock,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
vi.clearAllMocks();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("enablement", () => {
|
|
80
|
+
it("does nothing at all while the routes stay disabled", async () => {
|
|
81
|
+
const session = harness({});
|
|
82
|
+
await session.fire("session_start");
|
|
83
|
+
expect(await session.advise("what conventions does this project follow?")).toBeUndefined();
|
|
84
|
+
expect(completeMock).not.toHaveBeenCalled();
|
|
85
|
+
expect(session.telemetry).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("combines every accepted route into one advisory message", async () => {
|
|
89
|
+
answerEverything();
|
|
90
|
+
const session = harness();
|
|
91
|
+
await session.fire("session_start");
|
|
92
|
+
const message = await session.advise("what conventions does this project follow?");
|
|
93
|
+
expect(message?.content).toContain('<jev-memory source="local-memory-search"');
|
|
94
|
+
expect(message?.content).toContain('route="recommendation"');
|
|
95
|
+
expect(message?.details).toEqual({
|
|
96
|
+
routes: [
|
|
97
|
+
{ route: "memory", item: "project" },
|
|
98
|
+
{ route: "recommendations", item: "skill:implanger" },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
expect(completeMock).toHaveBeenCalledTimes(2);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("eligibility", () => {
|
|
106
|
+
it("ignores extension-injected, queued, command, and empty input", async () => {
|
|
107
|
+
answerEverything();
|
|
108
|
+
const session = harness();
|
|
109
|
+
await session.fire("session_start");
|
|
110
|
+
|
|
111
|
+
expect(await session.advise("do the thing", { source: "extension" })).toBeUndefined();
|
|
112
|
+
expect(await session.advise("do the thing", { streamingBehavior: "steer" })).toBeUndefined();
|
|
113
|
+
expect(await session.advise("do the thing", { streamingBehavior: "followUp" })).toBeUndefined();
|
|
114
|
+
expect(await session.advise("/implement")).toBeUndefined();
|
|
115
|
+
expect(await session.advise(" ")).toBeUndefined();
|
|
116
|
+
expect(completeMock).not.toHaveBeenCalled();
|
|
117
|
+
expect(session.telemetry).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("ignores a turn with no eligible input before it", async () => {
|
|
121
|
+
answerEverything();
|
|
122
|
+
const session = harness();
|
|
123
|
+
await session.fire("session_start");
|
|
124
|
+
const result = await session.fire("before_agent_start", {
|
|
125
|
+
type: "before_agent_start",
|
|
126
|
+
prompt: "an extension-injected turn",
|
|
127
|
+
});
|
|
128
|
+
expect(result).toBeUndefined();
|
|
129
|
+
expect(completeMock).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("forgets an eligible window a later input invalidates", async () => {
|
|
133
|
+
answerEverything();
|
|
134
|
+
const session = harness();
|
|
135
|
+
await session.fire("session_start");
|
|
136
|
+
await session.fire("input", { type: "input", text: "do the thing", source: "interactive" });
|
|
137
|
+
await session.fire("input", { type: "input", text: "/model", source: "interactive" });
|
|
138
|
+
const result = await session.fire("before_agent_start", { type: "before_agent_start", prompt: "/model" });
|
|
139
|
+
expect(result).toBeUndefined();
|
|
140
|
+
expect(completeMock).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("idempotency", () => {
|
|
145
|
+
it("recommends once per turn, however often the hook is re-entered", async () => {
|
|
146
|
+
answerEverything();
|
|
147
|
+
const session = harness();
|
|
148
|
+
await session.fire("session_start");
|
|
149
|
+
const message = await session.advise("what conventions does this project follow?");
|
|
150
|
+
expect(message).toBeDefined();
|
|
151
|
+
expect(await session.replay("what conventions does this project follow?")).toBeUndefined();
|
|
152
|
+
expect(await session.replay("what conventions does this project follow?")).toBeUndefined();
|
|
153
|
+
expect(completeMock).toHaveBeenCalledTimes(2);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("recommends again on the next prompt", async () => {
|
|
157
|
+
answerEverything();
|
|
158
|
+
const session = harness();
|
|
159
|
+
await session.fire("session_start");
|
|
160
|
+
expect(await session.advise("what conventions does this project follow?")).toBeDefined();
|
|
161
|
+
expect(await session.advise("and what else?")).toBeDefined();
|
|
162
|
+
expect(completeMock).toHaveBeenCalledTimes(3);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("publishes nothing after the session shuts down", async () => {
|
|
166
|
+
answerEverything();
|
|
167
|
+
const session = harness();
|
|
168
|
+
await session.fire("session_start");
|
|
169
|
+
await session.fire("session_shutdown", { type: "session_shutdown", reason: "quit" });
|
|
170
|
+
await session.fire("input", { type: "input", text: "still there?", source: "interactive" });
|
|
171
|
+
expect(await session.replay("still there?")).toBeUndefined();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("telemetry", () => {
|
|
176
|
+
it("reports decisions, abstentions, and adoption without any content", async () => {
|
|
177
|
+
answerEverything();
|
|
178
|
+
const session = harness();
|
|
179
|
+
await session.fire("session_start");
|
|
180
|
+
await session.advise("what conventions does this project follow?");
|
|
181
|
+
await session.fire("agent_settled");
|
|
182
|
+
|
|
183
|
+
const [memory, recommendations, ...adoption] = session.telemetry;
|
|
184
|
+
expect(memory).toMatchObject({ event: "decision", route: "memory", outcome: "jev", item: "project", confidence: "high" });
|
|
185
|
+
expect(recommendations).toMatchObject({ event: "decision", route: "recommendations", outcome: "jev" });
|
|
186
|
+
expect(typeof memory.elapsedMs).toBe("number");
|
|
187
|
+
expect(adoption).toEqual([{ event: "adoption", route: "recommendations", item: "skill:implanger", adopted: false }]);
|
|
188
|
+
|
|
189
|
+
const serialized = JSON.stringify(session.telemetry);
|
|
190
|
+
expect(serialized).not.toContain("conventions");
|
|
191
|
+
expect(serialized).not.toContain("Plan a UI change");
|
|
192
|
+
expect(serialized).not.toContain("answers");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("keeps routing when telemetry itself fails", async () => {
|
|
196
|
+
answerEverything();
|
|
197
|
+
const session = harness();
|
|
198
|
+
await session.fire("session_start");
|
|
199
|
+
// A subscriber that throws must not change what the parent receives.
|
|
200
|
+
(session.pi.events as { on(channel: string, handler: () => void): void }).on(JEV_ROUTING_EVENT, () => {
|
|
201
|
+
throw new Error("telemetry sink down");
|
|
202
|
+
});
|
|
203
|
+
const message = await session.advise("what conventions does this project follow?");
|
|
204
|
+
expect(message?.content).toContain("LOCAL_MEMORY_RESULT");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jev's memory lane is deliberately narrow: an explicit durable-memory cue
|
|
3
|
+
* chooses one local read-only scope, then Hermes performs that lookup.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
const state = vi.hoisted(() => ({ settingsPath: "" }));
|
|
11
|
+
|
|
12
|
+
vi.mock("@selesai/code", () => ({ getSettingsPath: () => state.settingsPath }));
|
|
13
|
+
|
|
14
|
+
import jevAdvisoryRoutingExtension, {
|
|
15
|
+
activeProjectName,
|
|
16
|
+
hasMemoryCue,
|
|
17
|
+
MAX_MEMORY_QUERY_CHARS,
|
|
18
|
+
MAX_MEMORY_RESULT_CHARS,
|
|
19
|
+
memoryQuery,
|
|
20
|
+
} from "./jev-advisory-routing.ts";
|
|
21
|
+
const completeMock = vi.fn();
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
enabledAdvisoryRoutes,
|
|
25
|
+
jevAnswers,
|
|
26
|
+
jevResponse,
|
|
27
|
+
makeHarness,
|
|
28
|
+
makeRepo,
|
|
29
|
+
sentPayload,
|
|
30
|
+
writeJevSettings,
|
|
31
|
+
type AdvisoryHarness,
|
|
32
|
+
} from "./jev/test-support.ts";
|
|
33
|
+
|
|
34
|
+
const MEMORY_SECRETS = ["SECRET_MEMORY_ENTRY", "SECRET_USER_ENTRY", "SECRET_STANDING_RULE"];
|
|
35
|
+
let root: string;
|
|
36
|
+
let agentDir: string;
|
|
37
|
+
let repo: string;
|
|
38
|
+
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
const created = makeRepo("jev-memory-");
|
|
41
|
+
root = created.root;
|
|
42
|
+
repo = created.repo;
|
|
43
|
+
agentDir = join(root, "agent");
|
|
44
|
+
mkdirSync(agentDir, { recursive: true });
|
|
45
|
+
state.settingsPath = join(agentDir, "settings.json");
|
|
46
|
+
writeFileSync(join(agentDir, "MEMORY.md"), `- ${MEMORY_SECRETS[0]}\n`, "utf-8");
|
|
47
|
+
writeFileSync(join(agentDir, "USER.md"), `- ${MEMORY_SECRETS[1]}\n`, "utf-8");
|
|
48
|
+
writeFileSync(join(agentDir, "standing.md"), `- ${MEMORY_SECRETS[2]}\n`, "utf-8");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
52
|
+
|
|
53
|
+
type MemoryAnswer = { target: string; confidence?: number } | "malformed" | "rejected";
|
|
54
|
+
|
|
55
|
+
function harness(
|
|
56
|
+
options: {
|
|
57
|
+
branch?: unknown[];
|
|
58
|
+
credential?: boolean;
|
|
59
|
+
answer?: MemoryAnswer;
|
|
60
|
+
memorySearch?: Parameters<typeof makeHarness>[0]["memorySearch"];
|
|
61
|
+
} = {},
|
|
62
|
+
): AdvisoryHarness {
|
|
63
|
+
writeJevSettings(state.settingsPath, { routes: enabledAdvisoryRoutes(["memory"]) });
|
|
64
|
+
const session = makeHarness({
|
|
65
|
+
settingsPath: state.settingsPath,
|
|
66
|
+
extension: jevAdvisoryRoutingExtension,
|
|
67
|
+
cwd: repo,
|
|
68
|
+
branch: options.branch,
|
|
69
|
+
credential: options.credential,
|
|
70
|
+
complete: completeMock,
|
|
71
|
+
memorySearch: options.memorySearch,
|
|
72
|
+
});
|
|
73
|
+
const answer = options.answer ?? { target: "project" };
|
|
74
|
+
if (answer === "malformed") completeMock.mockResolvedValue(jevResponse("{ not json"));
|
|
75
|
+
else if (answer === "rejected") completeMock.mockRejectedValue(new Error("network down"));
|
|
76
|
+
else completeMock.mockResolvedValue(jevResponse(jevAnswers({ memory_target: { choice: answer.target, confidence: answer.confidence ?? 0.9 } })));
|
|
77
|
+
return session;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
beforeEach(() => vi.clearAllMocks());
|
|
81
|
+
|
|
82
|
+
describe("memory cues", () => {
|
|
83
|
+
it("only classifies explicit references to durable context", () => {
|
|
84
|
+
for (const prompt of ["use the convention we decided", "do it as before", "don't repeat the past failure", "what are my preferences?"]) {
|
|
85
|
+
expect(hasMemoryCue(prompt), prompt).toBe(true);
|
|
86
|
+
}
|
|
87
|
+
for (const prompt of ["fix the login crash", "tell me a joke about databases", "what are we doing lately?"]) {
|
|
88
|
+
expect(hasMemoryCue(prompt), prompt).toBe(false);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not call Jev or local memory for a normal coding request", async () => {
|
|
93
|
+
const session = harness();
|
|
94
|
+
await session.fire("session_start");
|
|
95
|
+
expect(await session.advise("fix the login crash")).toBeUndefined();
|
|
96
|
+
expect(completeMock).not.toHaveBeenCalled();
|
|
97
|
+
expect(session.telemetry).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("local read-only lookup", () => {
|
|
102
|
+
it("selects one scope, then injects the bounded local result", async () => {
|
|
103
|
+
const calls: unknown[] = [];
|
|
104
|
+
const session = harness({
|
|
105
|
+
answer: { target: "project" },
|
|
106
|
+
memorySearch: (input) => {
|
|
107
|
+
calls.push(input);
|
|
108
|
+
return { success: true, count: 1, output: "DEPLOYMENT_CONVENTION" };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
await session.fire("session_start");
|
|
112
|
+
const message = await session.advise("use the deployment convention we decided");
|
|
113
|
+
|
|
114
|
+
expect(calls).toEqual([{ query: "use the deployment convention we decided", target: "project", project: "repo", limit: 5 }]);
|
|
115
|
+
expect(message?.content).toContain('<jev-memory source="local-memory-search" confidence="0.90">');
|
|
116
|
+
expect(message?.content).toContain("DEPLOYMENT_CONVENTION");
|
|
117
|
+
expect(message?.content).not.toContain("memory_search {");
|
|
118
|
+
expect(session.telemetry.at(-1)).toMatchObject({ route: "memory", outcome: "jev", item: "project" });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("does not inject an unavailable, empty, or failed local result", async () => {
|
|
122
|
+
for (const result of [undefined, { success: true, count: 0 }, { success: false, message: "unavailable" }]) {
|
|
123
|
+
const session = harness({ answer: { target: "project" }, memorySearch: () => result });
|
|
124
|
+
await session.fire("session_start");
|
|
125
|
+
expect(await session.advise("use the convention we decided"), String(result)).toBeUndefined();
|
|
126
|
+
expect(session.telemetry.at(-1)).toMatchObject({ route: "memory", outcome: "fallback" });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("bounds long local entries before they reach the turn", async () => {
|
|
131
|
+
const session = harness({ answer: { target: "memory" }, memorySearch: () => ({ success: true, output: "x".repeat(MAX_MEMORY_RESULT_CHARS + 100) }) });
|
|
132
|
+
await session.fire("session_start");
|
|
133
|
+
const message = await session.advise("what do you remember about our convention?");
|
|
134
|
+
expect(message?.content).toContain(`${"x".repeat(MAX_MEMORY_RESULT_CHARS)}…`);
|
|
135
|
+
expect(message?.content).not.toContain("x".repeat(MAX_MEMORY_RESULT_CHARS + 1));
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("abstention and privacy", () => {
|
|
140
|
+
it("does not search for none, low confidence, malformed, rejected, or credential-less decisions", async () => {
|
|
141
|
+
const cases: Array<[string, MemoryAnswer, boolean | undefined]> = [
|
|
142
|
+
["none", { target: "none" }, undefined],
|
|
143
|
+
["low confidence", { target: "project", confidence: 0.2 }, undefined],
|
|
144
|
+
["malformed", "malformed", undefined],
|
|
145
|
+
["rejected", "rejected", undefined],
|
|
146
|
+
["no credential", { target: "project" }, false],
|
|
147
|
+
];
|
|
148
|
+
for (const [name, answer, credential] of cases) {
|
|
149
|
+
const memorySearch = vi.fn(() => ({ success: true, output: "SHOULD_NOT_SEARCH" }));
|
|
150
|
+
const session = harness({ answer, credential, memorySearch });
|
|
151
|
+
await session.fire("session_start");
|
|
152
|
+
expect(await session.advise("use the convention we decided"), name).toBeUndefined();
|
|
153
|
+
expect(memorySearch, name).not.toHaveBeenCalled();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("sends Jev only the bounded current prompt, never history or memory contents", async () => {
|
|
158
|
+
const branch = [
|
|
159
|
+
{ type: "message", message: { role: "user", content: "OLDER_USER_TURN" } },
|
|
160
|
+
{ type: "message", message: { role: "assistant", content: "ASSISTANT_NARRATION" } },
|
|
161
|
+
{ type: "message", message: { role: "toolResult", content: "TOOL_OUTPUT" } },
|
|
162
|
+
];
|
|
163
|
+
const session = harness({ branch, memorySearch: () => ({ success: true, output: "LOCAL_MEMORY_RESULT" }) });
|
|
164
|
+
await session.fire("session_start");
|
|
165
|
+
await session.advise("what do you remember about our convention?");
|
|
166
|
+
|
|
167
|
+
const request = JSON.stringify(sentPayload(completeMock));
|
|
168
|
+
for (const forbidden of [...MEMORY_SECRETS, "OLDER_USER_TURN", "ASSISTANT_NARRATION", "TOOL_OUTPUT"]) {
|
|
169
|
+
expect(request, forbidden).not.toContain(forbidden);
|
|
170
|
+
}
|
|
171
|
+
expect(sentPayload(completeMock)?.state).toEqual({
|
|
172
|
+
conversation: [{ role: "user", text: "what do you remember about our convention?" }],
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("query and project identity", () => {
|
|
178
|
+
it("derives a bounded plain-text query", () => {
|
|
179
|
+
expect(memoryQuery(" what does\nthe release process look like? ")).toBe("what does the release process look like?");
|
|
180
|
+
expect(memoryQuery("Fix this\n```ts\nconst a = 1;\n```\nplease")).toBe("Fix this please");
|
|
181
|
+
expect(memoryQuery("word ".repeat(100))).toHaveLength(MAX_MEMORY_QUERY_CHARS);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("uses the repository root name for project memory", () => {
|
|
185
|
+
expect(activeProjectName(repo)).toBe("repo");
|
|
186
|
+
const nested = join(repo, "src", "deep");
|
|
187
|
+
mkdirSync(nested, { recursive: true });
|
|
188
|
+
expect(activeProjectName(nested)).toBe("repo");
|
|
189
|
+
expect(activeProjectName(homedir())).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
});
|