@gethmy/mcp 2.4.6 → 2.5.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/README.md +34 -1
- package/dist/cli.js +20867 -18386
- package/dist/index.js +20999 -18518
- package/dist/lib/api-client.js +130 -926
- package/dist/lib/config.js +5 -1
- package/package.json +2 -2
- package/src/__tests__/mcp-integration.test.ts +141 -0
- package/src/__tests__/memory-floor.test.ts +126 -0
- package/src/__tests__/memory-park.test.ts +213 -0
- package/src/__tests__/memory-session.test.ts +77 -0
- package/src/__tests__/prompt-builder.test.ts +234 -0
- package/src/__tests__/remote-routing.test.ts +285 -0
- package/src/__tests__/skills.test.ts +111 -0
- package/src/__tests__/tool-dispatch.test.ts +260 -0
- package/src/api-client.ts +133 -96
- package/src/memory-floor.ts +264 -0
- package/src/memory-park.ts +252 -0
- package/src/memory-session.ts +61 -0
- package/src/prompt-builder.ts +93 -0
- package/src/remote.ts +270 -77
- package/src/server.ts +351 -1467
- package/src/__tests__/active-learning.test.ts +0 -483
- package/src/__tests__/agent-performance-profiles.test.ts +0 -468
- package/src/__tests__/context-assembly.test.ts +0 -506
- package/src/__tests__/lifecycle-maintenance.test.ts +0 -238
- package/src/__tests__/memory-audit.test.ts +0 -528
- package/src/__tests__/pattern-detection.test.ts +0 -438
- package/src/active-learning.ts +0 -1165
- package/src/consolidation.ts +0 -383
- package/src/context-assembly.ts +0 -1175
- package/src/lifecycle-maintenance.ts +0 -120
- package/src/memory-audit.ts +0 -578
- package/src/memory-cleanup.ts +0 -902
package/dist/lib/config.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
var __defProp = Object.defineProperty;
|
|
2
|
+
var __returnValue = (v) => v;
|
|
3
|
+
function __exportSetter(name, newValue) {
|
|
4
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
5
|
+
}
|
|
2
6
|
var __export = (target, all) => {
|
|
3
7
|
for (var name in all)
|
|
4
8
|
__defProp(target, name, {
|
|
5
9
|
get: all[name],
|
|
6
10
|
enumerable: true,
|
|
7
11
|
configurable: true,
|
|
8
|
-
set: (
|
|
12
|
+
set: __exportSetter.bind(all, name)
|
|
9
13
|
});
|
|
10
14
|
};
|
|
11
15
|
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gethmy/mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"serve:remote": "bun src/remote.ts",
|
|
60
60
|
"dev": "bun --watch src/index.ts",
|
|
61
61
|
"test": "bun run test:unit && bun run test:integration",
|
|
62
|
-
"test:unit": "bun test src/__tests__/active-learning.test.ts src/__tests__/context-assembly.test.ts src/__tests__/prompt-builder.test.ts src/__tests__/memory-audit.test.ts",
|
|
62
|
+
"test:unit": "bun test src/__tests__/active-learning.test.ts src/__tests__/context-assembly.test.ts src/__tests__/prompt-builder.test.ts src/__tests__/memory-audit.test.ts src/__tests__/skills.test.ts src/__tests__/tool-dispatch.test.ts src/__tests__/mcp-integration.test.ts",
|
|
63
63
|
"test:integration": "bun test src/__tests__/integration-memory-system.test.ts src/__tests__/integration-memory-crud.test.ts",
|
|
64
64
|
"typecheck": "tsc --noEmit",
|
|
65
65
|
"prepublishOnly": "bun run build"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process MCP integration smoke. Card #182.
|
|
3
|
+
*
|
|
4
|
+
* Wires a real Server through `registerHandlers` and a real Client over an
|
|
5
|
+
* InMemoryTransport pair. Asserts JSON-RPC wire format end-to-end:
|
|
6
|
+
*
|
|
7
|
+
* - listTools returns every TOOLS row.
|
|
8
|
+
* - callTool dispatches and returns content.
|
|
9
|
+
* - listResources / readResource roundtrip.
|
|
10
|
+
* - unknown tool name surfaces as MCP-level error.
|
|
11
|
+
*
|
|
12
|
+
* This is the only place we exercise the SDK's transport + JSON-RPC layer.
|
|
13
|
+
* The dispatch unit tests in tool-dispatch.test.ts cover handler logic with
|
|
14
|
+
* a fake Server; this file confirms nothing breaks at the wire boundary.
|
|
15
|
+
*
|
|
16
|
+
* Run with: bun test packages/mcp-server/src/__tests__/mcp-integration.test.ts
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
20
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
21
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
22
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
RESOURCES,
|
|
26
|
+
registerHandlers,
|
|
27
|
+
TOOLS,
|
|
28
|
+
type ToolDeps,
|
|
29
|
+
} from "../server.js";
|
|
30
|
+
|
|
31
|
+
const TOOL_COUNT = Object.keys(TOOLS).length;
|
|
32
|
+
|
|
33
|
+
function makeDeps(): ToolDeps {
|
|
34
|
+
return {
|
|
35
|
+
getClient: () => ({}) as never,
|
|
36
|
+
isConfigured: () => true,
|
|
37
|
+
getActiveProjectId: () => "11111111-1111-1111-1111-111111111111",
|
|
38
|
+
getActiveWorkspaceId: () => "22222222-2222-2222-2222-222222222222",
|
|
39
|
+
setActiveProject: () => {},
|
|
40
|
+
setActiveWorkspace: () => {},
|
|
41
|
+
getApiUrl: () => "http://localhost",
|
|
42
|
+
getMemoryDir: () => null,
|
|
43
|
+
getUserEmail: () => null,
|
|
44
|
+
saveConfig: () => {},
|
|
45
|
+
resetClient: () => {},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("MCP integration — in-process Client ↔ Server", () => {
|
|
50
|
+
let client: Client;
|
|
51
|
+
let server: Server;
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
const [clientTransport, serverTransport] =
|
|
55
|
+
InMemoryTransport.createLinkedPair();
|
|
56
|
+
|
|
57
|
+
server = new Server(
|
|
58
|
+
{ name: "harmony-test", version: "0.0.0" },
|
|
59
|
+
{ capabilities: { tools: {}, resources: {} } },
|
|
60
|
+
);
|
|
61
|
+
registerHandlers(server, makeDeps());
|
|
62
|
+
await server.connect(serverTransport);
|
|
63
|
+
|
|
64
|
+
client = new Client(
|
|
65
|
+
{ name: "harmony-test-client", version: "0.0.0" },
|
|
66
|
+
{ capabilities: {} },
|
|
67
|
+
);
|
|
68
|
+
await client.connect(clientTransport);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterAll(async () => {
|
|
72
|
+
await client.close();
|
|
73
|
+
await server.close();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("listTools delivers every registered tool", async () => {
|
|
77
|
+
const result = await client.listTools();
|
|
78
|
+
expect(result.tools.length).toBe(TOOL_COUNT);
|
|
79
|
+
for (const tool of result.tools) {
|
|
80
|
+
expect(typeof tool.name).toBe("string");
|
|
81
|
+
expect(typeof tool.description).toBe("string");
|
|
82
|
+
expect(tool.inputSchema).toMatchObject({ type: "object" });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("callTool routes harmony_get_context end-to-end", async () => {
|
|
87
|
+
const result = await client.callTool({
|
|
88
|
+
name: "harmony_get_context",
|
|
89
|
+
arguments: {},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(result.isError).toBeUndefined();
|
|
93
|
+
const content = result.content as Array<{ type: string; text: string }>;
|
|
94
|
+
expect(content[0].type).toBe("text");
|
|
95
|
+
const payload = JSON.parse(content[0].text);
|
|
96
|
+
expect(payload.success).toBe(true);
|
|
97
|
+
expect(payload.context.activeWorkspaceId).toBe(
|
|
98
|
+
"22222222-2222-2222-2222-222222222222",
|
|
99
|
+
);
|
|
100
|
+
expect(payload.context.activeProjectId).toBe(
|
|
101
|
+
"11111111-1111-1111-1111-111111111111",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("callTool with unknown name returns isError content", async () => {
|
|
106
|
+
const result = await client.callTool({
|
|
107
|
+
name: "harmony_does_not_exist",
|
|
108
|
+
arguments: {},
|
|
109
|
+
});
|
|
110
|
+
expect(result.isError).toBe(true);
|
|
111
|
+
const content = result.content as Array<{ type: string; text: string }>;
|
|
112
|
+
expect(content[0].text.toLowerCase()).toContain("error");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("listResources delivers RESOURCES", async () => {
|
|
116
|
+
const result = await client.listResources();
|
|
117
|
+
expect(result.resources.length).toBe(RESOURCES.length);
|
|
118
|
+
expect(result.resources[0].uri).toBe(RESOURCES[0].uri);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("readResource serves harmony://context", async () => {
|
|
122
|
+
const result = await client.readResource({ uri: "harmony://context" });
|
|
123
|
+
const contents = result.contents as Array<{
|
|
124
|
+
uri: string;
|
|
125
|
+
mimeType?: string;
|
|
126
|
+
text?: string;
|
|
127
|
+
}>;
|
|
128
|
+
expect(contents[0].uri).toBe("harmony://context");
|
|
129
|
+
expect(contents[0].mimeType).toBe("application/json");
|
|
130
|
+
const parsed = JSON.parse(contents[0].text ?? "{}");
|
|
131
|
+
expect(parsed).toHaveProperty("configured");
|
|
132
|
+
expect(parsed).toHaveProperty("activeWorkspaceId");
|
|
133
|
+
expect(parsed).toHaveProperty("activeProjectId");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("readResource on unknown URI surfaces JSON-RPC error", async () => {
|
|
137
|
+
await expect(
|
|
138
|
+
client.readResource({ uri: "harmony://unknown" }),
|
|
139
|
+
).rejects.toThrow(/Unknown resource/);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateMemoryQuality } from "../memory-floor.js";
|
|
3
|
+
|
|
4
|
+
const ok = (
|
|
5
|
+
overrides: Partial<Parameters<typeof validateMemoryQuality>[0]> = {},
|
|
6
|
+
) => ({
|
|
7
|
+
title: "AnnotatedTextarea auto-grow deps must include open state",
|
|
8
|
+
content:
|
|
9
|
+
"When the parent prop changes from closed to open, the auto-grow effect must re-measure. Without the dep, the textarea stays at the cached height and clips multi-line input.",
|
|
10
|
+
type: "pattern",
|
|
11
|
+
scope: "project",
|
|
12
|
+
...overrides,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("Memory Utility Floor (plan §4.5.1)", () => {
|
|
16
|
+
test("accepts a real engineering lesson", () => {
|
|
17
|
+
expect(validateMemoryQuality(ok())).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("rejects comma-separated tag concatenation", () => {
|
|
21
|
+
const r = validateMemoryQuality(
|
|
22
|
+
ok({
|
|
23
|
+
title: "Consolidated procedure: procedure, add, user",
|
|
24
|
+
content:
|
|
25
|
+
"Consolidated from 5 procedure memories: foo bar baz qux many words.",
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
expect(r?.rule).toBe("tag-concat");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("rejects slash-separated tag concatenation", () => {
|
|
32
|
+
const r = validateMemoryQuality(
|
|
33
|
+
ok({
|
|
34
|
+
title: "Procedure: Procedure / Card / Mobile / Native",
|
|
35
|
+
content:
|
|
36
|
+
"3 related procedure entities consolidated. Original titles list goes here.",
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
expect(r?.rule).toBe("tag-concat-slash");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("rejects frequency-meta titles", () => {
|
|
43
|
+
const r = validateMemoryQuality(
|
|
44
|
+
ok({
|
|
45
|
+
title: "Pattern: recurring procedure (17 instances)",
|
|
46
|
+
content: "Recurring pattern: procedure entities appearing 19 times.",
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
expect(r?.rule).toBe("frequency-meta");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("rejects bare type prefix with empty content after colon", () => {
|
|
53
|
+
const r = validateMemoryQuality(
|
|
54
|
+
ok({
|
|
55
|
+
title: "Memory:",
|
|
56
|
+
content:
|
|
57
|
+
"Not enough substance here for any kind of memory entry value.",
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
expect(r?.rule).toBe("bare-type-prefix");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("rejects self-referential operational notes", () => {
|
|
64
|
+
const r = validateMemoryQuality(
|
|
65
|
+
ok({
|
|
66
|
+
title: "Auto-consolidation produces tautological clusters on tags",
|
|
67
|
+
content:
|
|
68
|
+
"The consolidation pipeline clusters entities by tag overlap, but doesn't distinguish content-bearing tags from structural pipeline tags.",
|
|
69
|
+
type: "lesson",
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
expect(r?.rule).toBe("self-referential");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("rejects content that's too short", () => {
|
|
76
|
+
const r = validateMemoryQuality(
|
|
77
|
+
ok({
|
|
78
|
+
title: "AnnotatedTextarea auto-grow deps fix",
|
|
79
|
+
content: "Short.",
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
expect(r?.rule).toBe("length-floor");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("rejects under-specific titles", () => {
|
|
86
|
+
const r = validateMemoryQuality(
|
|
87
|
+
ok({
|
|
88
|
+
title: "fix the bug",
|
|
89
|
+
content:
|
|
90
|
+
"There is a bug somewhere and it should be fixed at some point in the future.",
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
expect(r?.rule).toBe("specificity-floor");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("rejects Agent Profile dumps", () => {
|
|
97
|
+
const r = validateMemoryQuality({
|
|
98
|
+
title: "Agent Profile: claude-code",
|
|
99
|
+
content:
|
|
100
|
+
"## claude-code Performance Profile - Total sessions: 183 - Completed: 180 (98%)",
|
|
101
|
+
type: "agent",
|
|
102
|
+
});
|
|
103
|
+
expect(r?.rule).toBe("operational-data-ban");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("session-scope memories bypass the Floor entirely", () => {
|
|
107
|
+
const r = validateMemoryQuality({
|
|
108
|
+
title: "user wants Q3 revenue rolled up by team",
|
|
109
|
+
content: "x",
|
|
110
|
+
type: "context",
|
|
111
|
+
scope: "session:abc123",
|
|
112
|
+
});
|
|
113
|
+
expect(r).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("doc-source memories bypass title checks but not length", () => {
|
|
117
|
+
const r = validateMemoryQuality({
|
|
118
|
+
title: "## API Routes",
|
|
119
|
+
content:
|
|
120
|
+
"Section heading from CLAUDE.md auto-import. Long enough body content here ok.",
|
|
121
|
+
type: "context",
|
|
122
|
+
source_trust: "document",
|
|
123
|
+
});
|
|
124
|
+
expect(r).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
fitToBudget,
|
|
4
|
+
type ParkInput,
|
|
5
|
+
relevanceFromRank,
|
|
6
|
+
rescore,
|
|
7
|
+
} from "../memory-park.js";
|
|
8
|
+
|
|
9
|
+
const NOW = new Date("2026-05-08T12:00:00Z");
|
|
10
|
+
|
|
11
|
+
function entity(
|
|
12
|
+
partial: Partial<ParkInput> & {
|
|
13
|
+
id: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
content?: string;
|
|
16
|
+
},
|
|
17
|
+
): ParkInput & { id: string; title?: string; content?: string } {
|
|
18
|
+
return {
|
|
19
|
+
id: partial.id,
|
|
20
|
+
type: partial.type ?? "pattern",
|
|
21
|
+
importance: partial.importance,
|
|
22
|
+
last_accessed_at: partial.last_accessed_at,
|
|
23
|
+
created_at: partial.created_at,
|
|
24
|
+
title: partial.title,
|
|
25
|
+
content: partial.content,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("memory-park rescore", () => {
|
|
30
|
+
test("fresh + high-importance pattern beats stale + low-importance pattern", () => {
|
|
31
|
+
const fresh = entity({
|
|
32
|
+
id: "fresh",
|
|
33
|
+
type: "pattern",
|
|
34
|
+
importance: 9,
|
|
35
|
+
last_accessed_at: "2026-05-08T11:00:00Z",
|
|
36
|
+
});
|
|
37
|
+
const stale = entity({
|
|
38
|
+
id: "stale",
|
|
39
|
+
type: "pattern",
|
|
40
|
+
importance: 4,
|
|
41
|
+
last_accessed_at: "2024-05-08T11:00:00Z", // 2y old
|
|
42
|
+
});
|
|
43
|
+
const out = rescore([stale, fresh], { now: NOW });
|
|
44
|
+
expect(out[0]?.entity.id).toBe("fresh");
|
|
45
|
+
expect(out[1]?.entity.id).toBe("stale");
|
|
46
|
+
expect(out[0]?.score).toBeGreaterThan(out[1]?.score ?? 0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("preferences never decay (tau=Infinity → recency=1)", () => {
|
|
50
|
+
const ancient = entity({
|
|
51
|
+
id: "p",
|
|
52
|
+
type: "preference",
|
|
53
|
+
importance: 9,
|
|
54
|
+
last_accessed_at: "2020-01-01T00:00:00Z",
|
|
55
|
+
});
|
|
56
|
+
const out = rescore([ancient], { now: NOW });
|
|
57
|
+
expect(out[0]?.recency).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("caller-supplied relevance dominates when weight is high", () => {
|
|
61
|
+
const a = entity({
|
|
62
|
+
id: "a",
|
|
63
|
+
type: "pattern",
|
|
64
|
+
importance: 5,
|
|
65
|
+
last_accessed_at: "2026-04-01T00:00:00Z",
|
|
66
|
+
});
|
|
67
|
+
const b = entity({
|
|
68
|
+
id: "b",
|
|
69
|
+
type: "pattern",
|
|
70
|
+
importance: 9,
|
|
71
|
+
last_accessed_at: "2026-04-01T00:00:00Z",
|
|
72
|
+
});
|
|
73
|
+
const relevance = new Map([
|
|
74
|
+
["a", 1.0],
|
|
75
|
+
["b", 0.0],
|
|
76
|
+
]);
|
|
77
|
+
const out = rescore([a, b], { now: NOW, relevance });
|
|
78
|
+
expect(out[0]?.entity.id).toBe("a");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("missing importance falls back to per-type default", () => {
|
|
82
|
+
const e = entity({
|
|
83
|
+
id: "p",
|
|
84
|
+
type: "preference", // default 9
|
|
85
|
+
importance: null,
|
|
86
|
+
last_accessed_at: "2026-05-08T11:00:00Z",
|
|
87
|
+
});
|
|
88
|
+
const out = rescore([e], { now: NOW });
|
|
89
|
+
expect(out[0]?.importance).toBeCloseTo(0.9);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("clamps importance to [1,10]", () => {
|
|
93
|
+
const tooLow = entity({ id: "lo", type: "pattern", importance: -5 });
|
|
94
|
+
const tooHigh = entity({ id: "hi", type: "pattern", importance: 99 });
|
|
95
|
+
const out = rescore([tooLow, tooHigh], { now: NOW });
|
|
96
|
+
const lo = out.find((s) => s.entity.id === "lo");
|
|
97
|
+
const hi = out.find((s) => s.entity.id === "hi");
|
|
98
|
+
expect(lo?.importance).toBeCloseTo(0.1);
|
|
99
|
+
expect(hi?.importance).toBeCloseTo(1.0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("score is α·relevance + β·recency + γ·importance", () => {
|
|
103
|
+
const e = entity({
|
|
104
|
+
id: "x",
|
|
105
|
+
type: "pattern",
|
|
106
|
+
importance: 5,
|
|
107
|
+
last_accessed_at: "2026-05-08T11:00:00Z", // very fresh
|
|
108
|
+
});
|
|
109
|
+
const out = rescore([e], {
|
|
110
|
+
now: NOW,
|
|
111
|
+
relevance: new Map([["x", 1.0]]),
|
|
112
|
+
});
|
|
113
|
+
const r = out[0]!;
|
|
114
|
+
const expected = 0.55 * r.relevance + 0.25 * r.recency + 0.2 * r.importance;
|
|
115
|
+
expect(r.score).toBeCloseTo(expected, 5);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("memory-park relevanceFromRank (hybrid retrieval bridge)", () => {
|
|
120
|
+
test("top-ranked entity gets relevance 1.0", () => {
|
|
121
|
+
const ranked = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
|
122
|
+
const map = relevanceFromRank(ranked);
|
|
123
|
+
expect(map.get("a")).toBeCloseTo(1.0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("relevance decays exponentially with rank", () => {
|
|
127
|
+
const ranked = Array.from({ length: 20 }, (_, i) => ({ id: `r${i}` }));
|
|
128
|
+
const map = relevanceFromRank(ranked, 10);
|
|
129
|
+
expect(map.get("r0")).toBeCloseTo(1.0);
|
|
130
|
+
expect(map.get("r10")).toBeCloseTo(Math.exp(-1));
|
|
131
|
+
expect(map.get("r20")).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("hybrid-ranked top-1 dominates Park rescore over a stale high-importance pattern", () => {
|
|
135
|
+
const matchedByQuery = entity({
|
|
136
|
+
id: "match",
|
|
137
|
+
type: "pattern",
|
|
138
|
+
importance: 5,
|
|
139
|
+
last_accessed_at: "2025-05-08T11:00:00Z",
|
|
140
|
+
});
|
|
141
|
+
const stalePopular = entity({
|
|
142
|
+
id: "popular",
|
|
143
|
+
type: "pattern",
|
|
144
|
+
importance: 9,
|
|
145
|
+
last_accessed_at: "2024-05-08T11:00:00Z",
|
|
146
|
+
});
|
|
147
|
+
const ranked = [{ id: "match" }, { id: "popular" }];
|
|
148
|
+
const relevance = relevanceFromRank(ranked);
|
|
149
|
+
const scored = rescore([stalePopular, matchedByQuery], {
|
|
150
|
+
now: NOW,
|
|
151
|
+
relevance,
|
|
152
|
+
});
|
|
153
|
+
expect(scored[0]?.entity.id).toBe("match");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("custom decay scales the falloff curve", () => {
|
|
157
|
+
const ranked = Array.from({ length: 5 }, (_, i) => ({ id: `r${i}` }));
|
|
158
|
+
const tight = relevanceFromRank(ranked, 1);
|
|
159
|
+
const loose = relevanceFromRank(ranked, 100);
|
|
160
|
+
expect(tight.get("r1")).toBeLessThan(loose.get("r1") ?? 0);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("memory-park fitToBudget", () => {
|
|
165
|
+
test("returns prefix that fits within token budget", () => {
|
|
166
|
+
const ents = [
|
|
167
|
+
entity({
|
|
168
|
+
id: "1",
|
|
169
|
+
type: "pattern",
|
|
170
|
+
title: "x".repeat(40),
|
|
171
|
+
content: "y".repeat(40),
|
|
172
|
+
}),
|
|
173
|
+
entity({
|
|
174
|
+
id: "2",
|
|
175
|
+
type: "pattern",
|
|
176
|
+
title: "x".repeat(40),
|
|
177
|
+
content: "y".repeat(40),
|
|
178
|
+
}),
|
|
179
|
+
entity({
|
|
180
|
+
id: "3",
|
|
181
|
+
type: "pattern",
|
|
182
|
+
title: "x".repeat(40),
|
|
183
|
+
content: "y".repeat(40),
|
|
184
|
+
}),
|
|
185
|
+
];
|
|
186
|
+
const scored = rescore(ents, { now: NOW });
|
|
187
|
+
// Each entity ~ (40/4) + (40/4) + 4 = 24 tokens
|
|
188
|
+
const fit = fitToBudget(scored, 50);
|
|
189
|
+
expect(fit.length).toBe(2);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("always includes top result even if over budget", () => {
|
|
193
|
+
const big = entity({
|
|
194
|
+
id: "big",
|
|
195
|
+
type: "pattern",
|
|
196
|
+
title: "x".repeat(2000),
|
|
197
|
+
content: "y".repeat(2000),
|
|
198
|
+
});
|
|
199
|
+
const scored = rescore([big], { now: NOW });
|
|
200
|
+
const fit = fitToBudget(scored, 10);
|
|
201
|
+
expect(fit.length).toBe(1);
|
|
202
|
+
expect(fit[0]?.entity.id).toBe("big");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("zero or negative budget returns empty", () => {
|
|
206
|
+
const ents = [
|
|
207
|
+
entity({ id: "1", type: "pattern", title: "abc", content: "def" }),
|
|
208
|
+
];
|
|
209
|
+
const scored = rescore(ents, { now: NOW });
|
|
210
|
+
expect(fitToBudget(scored, 0)).toHaveLength(0);
|
|
211
|
+
expect(fitToBudget(scored, -10)).toHaveLength(0);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isSessionScope,
|
|
5
|
+
resolveSessionScope,
|
|
6
|
+
sessionScopeFor,
|
|
7
|
+
} from "../memory-session.js";
|
|
8
|
+
|
|
9
|
+
describe("resolveSessionScope", () => {
|
|
10
|
+
it("returns undefined when caller did not pass a scope", () => {
|
|
11
|
+
expect(resolveSessionScope(undefined, "abc-123")).toBeUndefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("passes a non-alias scope through unchanged", () => {
|
|
15
|
+
expect(resolveSessionScope("project", "abc-123")).toBe("project");
|
|
16
|
+
expect(resolveSessionScope("private", undefined)).toBe("private");
|
|
17
|
+
expect(resolveSessionScope("workspace", "abc-123")).toBe("workspace");
|
|
18
|
+
expect(resolveSessionScope("global", undefined)).toBe("global");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("resolves the 'session' alias to session:<id> when a session is active", () => {
|
|
22
|
+
expect(
|
|
23
|
+
resolveSessionScope("session", "11111111-1111-1111-1111-111111111111"),
|
|
24
|
+
).toBe("session:11111111-1111-1111-1111-111111111111");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("throws when 'session' alias is used without an active session", () => {
|
|
28
|
+
expect(() => resolveSessionScope("session", undefined)).toThrow(
|
|
29
|
+
/requires an active agent session/i,
|
|
30
|
+
);
|
|
31
|
+
expect(() => resolveSessionScope("session", "")).toThrow(
|
|
32
|
+
/requires an active agent session/i,
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not auto-bind any other scope to the active session", () => {
|
|
37
|
+
expect(resolveSessionScope("project", "abc-123")).toBe("project");
|
|
38
|
+
expect(resolveSessionScope("private", "abc-123")).toBe("private");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("isSessionScope", () => {
|
|
43
|
+
it("recognizes concrete session:<id> values", () => {
|
|
44
|
+
expect(isSessionScope("session:abc-123")).toBe(true);
|
|
45
|
+
expect(isSessionScope("session:11111111-1111-1111-1111-111111111111")).toBe(
|
|
46
|
+
true,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("rejects the bare 'session' alias (not yet resolved)", () => {
|
|
51
|
+
expect(isSessionScope("session")).toBe(false);
|
|
52
|
+
expect(isSessionScope("session:")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rejects standard scopes", () => {
|
|
56
|
+
expect(isSessionScope("private")).toBe(false);
|
|
57
|
+
expect(isSessionScope("project")).toBe(false);
|
|
58
|
+
expect(isSessionScope("workspace")).toBe(false);
|
|
59
|
+
expect(isSessionScope("global")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects non-string inputs", () => {
|
|
63
|
+
expect(isSessionScope(undefined)).toBe(false);
|
|
64
|
+
expect(isSessionScope(null)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("sessionScopeFor", () => {
|
|
69
|
+
it("constructs the canonical scope value for a session id", () => {
|
|
70
|
+
expect(sessionScopeFor("abc-123")).toBe("session:abc-123");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("output round-trips through isSessionScope", () => {
|
|
74
|
+
const scope = sessionScopeFor("11111111-1111-1111-1111-111111111111");
|
|
75
|
+
expect(isSessionScope(scope)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|