@flrande/browserctl 0.4.0-dev.15.1 → 0.5.0-dev.19.1
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/apps/browserctl/src/commands/act.test.ts +71 -0
- package/apps/browserctl/src/commands/act.ts +45 -1
- package/apps/browserctl/src/commands/command-wrappers.test.ts +302 -0
- package/apps/browserctl/src/commands/console-list.test.ts +102 -0
- package/apps/browserctl/src/commands/console-list.ts +89 -1
- package/apps/browserctl/src/commands/har-export.test.ts +112 -0
- package/apps/browserctl/src/commands/har-export.ts +120 -0
- package/apps/browserctl/src/commands/memory-delete.ts +20 -0
- package/apps/browserctl/src/commands/memory-inspect.ts +20 -0
- package/apps/browserctl/src/commands/memory-list.ts +90 -0
- package/apps/browserctl/src/commands/memory-mode-set.ts +29 -0
- package/apps/browserctl/src/commands/memory-purge.ts +16 -0
- package/apps/browserctl/src/commands/memory-resolve.ts +56 -0
- package/apps/browserctl/src/commands/memory-status.ts +16 -0
- package/apps/browserctl/src/commands/memory-ttl-set.ts +28 -0
- package/apps/browserctl/src/commands/memory-upsert.ts +142 -0
- package/apps/browserctl/src/commands/network-list.test.ts +110 -0
- package/apps/browserctl/src/commands/network-list.ts +112 -0
- package/apps/browserctl/src/commands/session-drop.test.ts +36 -0
- package/apps/browserctl/src/commands/session-drop.ts +16 -0
- package/apps/browserctl/src/commands/session-list.test.ts +81 -0
- package/apps/browserctl/src/commands/session-list.ts +70 -0
- package/apps/browserctl/src/commands/trace-get.test.ts +61 -0
- package/apps/browserctl/src/commands/trace-get.ts +62 -0
- package/apps/browserctl/src/commands/wait-element.test.ts +80 -0
- package/apps/browserctl/src/commands/wait-element.ts +76 -0
- package/apps/browserctl/src/commands/wait-text.test.ts +110 -0
- package/apps/browserctl/src/commands/wait-text.ts +93 -0
- package/apps/browserctl/src/commands/wait-url.test.ts +80 -0
- package/apps/browserctl/src/commands/wait-url.ts +76 -0
- package/apps/browserctl/src/main.dispatch.test.ts +206 -1
- package/apps/browserctl/src/main.test.ts +30 -0
- package/apps/browserctl/src/main.ts +246 -4
- package/apps/browserd/src/container.ts +1603 -48
- package/apps/browserd/src/main.test.ts +538 -1
- package/apps/browserd/src/tool-matrix.test.ts +492 -3
- package/package.json +5 -1
- package/packages/core/src/driver.ts +1 -1
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/navigation-memory.test.ts +259 -0
- package/packages/core/src/navigation-memory.ts +360 -0
- package/packages/core/src/session-store.test.ts +33 -0
- package/packages/core/src/session-store.ts +111 -6
- package/packages/driver-chrome-relay/src/chrome-relay-driver.test.ts +112 -2
- package/packages/driver-chrome-relay/src/chrome-relay-driver.ts +233 -10
- package/packages/driver-managed/src/managed-driver.test.ts +124 -0
- package/packages/driver-managed/src/managed-driver.ts +233 -17
- package/packages/driver-managed/src/managed-local-driver.test.ts +104 -2
- package/packages/driver-managed/src/managed-local-driver.ts +232 -10
- package/packages/driver-remote-cdp/src/remote-cdp-driver.test.ts +112 -2
- package/packages/driver-remote-cdp/src/remote-cdp-driver.ts +232 -10
- package/packages/transport-mcp-stdio/src/tool-map.ts +18 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { createNavigationMemoryStore } from "./navigation-memory";
|
|
8
|
+
|
|
9
|
+
describe("navigation memory contracts", () => {
|
|
10
|
+
it("scopes by domain+profile", async () => {
|
|
11
|
+
const store = createNavigationMemoryStore({
|
|
12
|
+
path: ":memory:",
|
|
13
|
+
now: () => 1_000
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
await store.upsert({
|
|
17
|
+
domain: "forum.example",
|
|
18
|
+
profileId: "chrome-relay",
|
|
19
|
+
intentKey: "open_profile",
|
|
20
|
+
signals: [{ kind: "urlPattern", value: "/u/*" }]
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const hit = await store.resolve({
|
|
24
|
+
domain: "forum.example",
|
|
25
|
+
profileId: "chrome-relay",
|
|
26
|
+
intentKey: "open_profile"
|
|
27
|
+
});
|
|
28
|
+
const missByProfile = await store.resolve({
|
|
29
|
+
domain: "forum.example",
|
|
30
|
+
profileId: "managed-local",
|
|
31
|
+
intentKey: "open_profile"
|
|
32
|
+
});
|
|
33
|
+
const missByDomain = await store.resolve({
|
|
34
|
+
domain: "news.example",
|
|
35
|
+
profileId: "chrome-relay",
|
|
36
|
+
intentKey: "open_profile"
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(hit.hit).toBe(true);
|
|
40
|
+
expect(hit.entry?.signals).toEqual([{ kind: "urlPattern", value: "/u/*" }]);
|
|
41
|
+
expect(missByProfile.hit).toBe(false);
|
|
42
|
+
expect(missByDomain.hit).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("implements the core store API", async () => {
|
|
46
|
+
let now = 10_000;
|
|
47
|
+
const store = createNavigationMemoryStore({
|
|
48
|
+
path: ":memory:",
|
|
49
|
+
now: () => now,
|
|
50
|
+
ttlDays: 1
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const upserted = await store.upsert({
|
|
54
|
+
domain: "news.example",
|
|
55
|
+
profileId: "chrome-relay",
|
|
56
|
+
intentKey: "hot",
|
|
57
|
+
signals: [{ kind: "selector", value: "#hot-list" }],
|
|
58
|
+
confidence: 0.7
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(upserted.created).toBe(true);
|
|
62
|
+
expect((await store.list())).toHaveLength(1);
|
|
63
|
+
expect(await store.inspect(upserted.entry.id)).toMatchObject({
|
|
64
|
+
id: upserted.entry.id,
|
|
65
|
+
domain: "news.example",
|
|
66
|
+
profileId: "chrome-relay",
|
|
67
|
+
intentKey: "hot"
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await store.setMode("ask");
|
|
71
|
+
await store.setTtlDays(0);
|
|
72
|
+
expect(store.status()).toMatchObject({
|
|
73
|
+
mode: "ask",
|
|
74
|
+
ttlDays: 0,
|
|
75
|
+
totalEntries: 0
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
now += 1;
|
|
79
|
+
expect(await store.purgeExpired()).toBe(1);
|
|
80
|
+
expect(await store.delete(upserted.entry.id)).toBe(false);
|
|
81
|
+
expect(await store.list()).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keeps task1 store in-memory even when path is set", async () => {
|
|
85
|
+
const dirPath = await mkdtemp(join(tmpdir(), "navigation-memory-task1-"));
|
|
86
|
+
const filePath = join(dirPath, "navigation-memory.json");
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const store = createNavigationMemoryStore({
|
|
90
|
+
path: filePath,
|
|
91
|
+
now: () => 1_000
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await store.upsert({
|
|
95
|
+
domain: "forum.example",
|
|
96
|
+
profileId: "chrome-relay",
|
|
97
|
+
intentKey: "open_profile",
|
|
98
|
+
signals: [{ kind: "urlPattern", value: "/u/*" }]
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(existsSync(filePath)).toBe(false);
|
|
102
|
+
} finally {
|
|
103
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("does not revive expired entries when ttl days changes", async () => {
|
|
108
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
109
|
+
let now = 5_000;
|
|
110
|
+
const store = createNavigationMemoryStore({
|
|
111
|
+
path: ":memory:",
|
|
112
|
+
now: () => now,
|
|
113
|
+
ttlDays: 1
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const upserted = await store.upsert({
|
|
117
|
+
domain: "forum.example",
|
|
118
|
+
profileId: "chrome-relay",
|
|
119
|
+
intentKey: "open_profile",
|
|
120
|
+
signals: [{ kind: "selector", value: "#profile-link" }]
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
now += dayMs + 1;
|
|
124
|
+
|
|
125
|
+
const status = await store.setTtlDays(30);
|
|
126
|
+
expect(status.totalEntries).toBe(0);
|
|
127
|
+
expect(await store.inspect(upserted.entry.id)).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("supports extracted setMode and setTtlDays calls", async () => {
|
|
131
|
+
const store = createNavigationMemoryStore({
|
|
132
|
+
path: ":memory:"
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const { setMode, setTtlDays } = store;
|
|
136
|
+
|
|
137
|
+
const modeStatus = await setMode("ask");
|
|
138
|
+
expect(modeStatus.mode).toBe("ask");
|
|
139
|
+
|
|
140
|
+
const ttlStatus = await setTtlDays(7);
|
|
141
|
+
expect(ttlStatus.ttlDays).toBe(7);
|
|
142
|
+
|
|
143
|
+
expect(store.status()).toMatchObject({
|
|
144
|
+
mode: "ask",
|
|
145
|
+
ttlDays: 7
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("renews expireAt when resolve hits", async () => {
|
|
150
|
+
let now = 10_000;
|
|
151
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
152
|
+
const store = createNavigationMemoryStore({
|
|
153
|
+
path: ":memory:",
|
|
154
|
+
now: () => now,
|
|
155
|
+
ttlDays: 30
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const created = await store.upsert({
|
|
159
|
+
domain: "news.example",
|
|
160
|
+
profileId: "chrome-relay",
|
|
161
|
+
intentKey: "hot",
|
|
162
|
+
signals: []
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const before = await store.inspect(created.entry.id);
|
|
166
|
+
now += 60_000;
|
|
167
|
+
|
|
168
|
+
const first = await store.resolve({
|
|
169
|
+
domain: "news.example",
|
|
170
|
+
profileId: "chrome-relay",
|
|
171
|
+
intentKey: "hot"
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(first.hit).toBe(true);
|
|
175
|
+
expect(first.entry).toBeDefined();
|
|
176
|
+
expect(first.entry!.expireAt).not.toBe(before!.expireAt);
|
|
177
|
+
expect(first.entry!.expireAt).toBe(new Date(now + 30 * dayMs).toISOString());
|
|
178
|
+
|
|
179
|
+
const inspected = await store.inspect(first.entry!.id);
|
|
180
|
+
expect(inspected!.hitCount).toBe(1);
|
|
181
|
+
expect(inspected!.expireAt).toBe(first.entry!.expireAt);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("rejects write when mode=ask and no explicit approval", async () => {
|
|
185
|
+
const store = createNavigationMemoryStore({ path: ":memory:", mode: "ask" });
|
|
186
|
+
|
|
187
|
+
await expect(
|
|
188
|
+
store.upsert({
|
|
189
|
+
domain: "forum.example",
|
|
190
|
+
profileId: "chrome-relay",
|
|
191
|
+
intentKey: "open_profile",
|
|
192
|
+
signals: [],
|
|
193
|
+
confirmed: false
|
|
194
|
+
})
|
|
195
|
+
).rejects.toThrow(/confirmation/i);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("returns miss and does not mutate entry when mode=off on resolve", async () => {
|
|
199
|
+
let now = 10_000;
|
|
200
|
+
const store = createNavigationMemoryStore({
|
|
201
|
+
path: ":memory:",
|
|
202
|
+
now: () => now,
|
|
203
|
+
ttlDays: 30
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const created = await store.upsert({
|
|
207
|
+
domain: "forum.example",
|
|
208
|
+
profileId: "chrome-relay",
|
|
209
|
+
intentKey: "open_profile",
|
|
210
|
+
signals: [{ kind: "selector", value: "#profile-link" }]
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const before = await store.inspect(created.entry.id);
|
|
214
|
+
await store.setMode("off");
|
|
215
|
+
now += 5_000;
|
|
216
|
+
|
|
217
|
+
const resolved = await store.resolve({
|
|
218
|
+
domain: "forum.example",
|
|
219
|
+
profileId: "chrome-relay",
|
|
220
|
+
intentKey: "open_profile"
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(resolved.hit).toBe(false);
|
|
224
|
+
expect(resolved.entry).toBeUndefined();
|
|
225
|
+
|
|
226
|
+
const after = await store.inspect(created.entry.id);
|
|
227
|
+
expect(after).toBeDefined();
|
|
228
|
+
expect(after!.hitCount).toBe(before!.hitCount);
|
|
229
|
+
expect(after!.lastHitAt).toBe(before!.lastHitAt);
|
|
230
|
+
expect(after!.expireAt).toBe(before!.expireAt);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does not delete expired entry when mode=off resolve is called", async () => {
|
|
234
|
+
const store = createNavigationMemoryStore({
|
|
235
|
+
path: ":memory:",
|
|
236
|
+
now: () => 10_000,
|
|
237
|
+
ttlDays: 0
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const created = await store.upsert({
|
|
241
|
+
domain: "forum.example",
|
|
242
|
+
profileId: "chrome-relay",
|
|
243
|
+
intentKey: "open_profile",
|
|
244
|
+
signals: [{ kind: "selector", value: "#profile-link" }]
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await store.setMode("off");
|
|
248
|
+
|
|
249
|
+
const resolved = await store.resolve({
|
|
250
|
+
domain: "forum.example",
|
|
251
|
+
profileId: "chrome-relay",
|
|
252
|
+
intentKey: "open_profile"
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(resolved.hit).toBe(false);
|
|
256
|
+
expect(resolved.entry).toBeUndefined();
|
|
257
|
+
expect(await store.delete(created.entry.id)).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
const DEFAULT_TTL_DAYS = 30;
|
|
2
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
3
|
+
|
|
4
|
+
export type NavigationSignal =
|
|
5
|
+
| { kind: "urlPattern"; value: string }
|
|
6
|
+
| { kind: "selector"; value: string }
|
|
7
|
+
| { kind: "hop"; from: string; to: string };
|
|
8
|
+
|
|
9
|
+
export type NavigationMemoryEntry = {
|
|
10
|
+
id: string;
|
|
11
|
+
domain: string;
|
|
12
|
+
profileId: string;
|
|
13
|
+
intentKey: string;
|
|
14
|
+
signals: NavigationSignal[];
|
|
15
|
+
confidence: number;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
lastHitAt: string;
|
|
18
|
+
expireAt: string;
|
|
19
|
+
hitCount: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type NavigationMemoryMode = "off" | "ask" | "auto";
|
|
23
|
+
|
|
24
|
+
export type NavigationMemoryStoreOptions = {
|
|
25
|
+
path: string;
|
|
26
|
+
mode?: NavigationMemoryMode;
|
|
27
|
+
ttlDays?: number;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type NavigationMemoryResolveInput = {
|
|
32
|
+
domain: string;
|
|
33
|
+
profileId: string;
|
|
34
|
+
intentKey: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type NavigationMemoryResolveResult =
|
|
38
|
+
| { hit: false; entry?: undefined }
|
|
39
|
+
| { hit: true; entry: NavigationMemoryEntry };
|
|
40
|
+
|
|
41
|
+
export type NavigationMemoryUpsertInput = {
|
|
42
|
+
domain: string;
|
|
43
|
+
profileId: string;
|
|
44
|
+
intentKey: string;
|
|
45
|
+
signals: NavigationSignal[];
|
|
46
|
+
confidence?: number;
|
|
47
|
+
confirmed?: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type NavigationMemoryUpsertResult = {
|
|
51
|
+
created: boolean;
|
|
52
|
+
entry: NavigationMemoryEntry;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type NavigationMemoryListInput = {
|
|
56
|
+
domain?: string;
|
|
57
|
+
profileId?: string;
|
|
58
|
+
intentKey?: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type NavigationMemoryStatus = {
|
|
62
|
+
path: string;
|
|
63
|
+
mode: NavigationMemoryMode;
|
|
64
|
+
ttlDays: number;
|
|
65
|
+
totalEntries: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type NavigationMemoryStore = {
|
|
69
|
+
resolve(input: NavigationMemoryResolveInput): Promise<NavigationMemoryResolveResult>;
|
|
70
|
+
upsert(input: NavigationMemoryUpsertInput): Promise<NavigationMemoryUpsertResult>;
|
|
71
|
+
list(filter?: NavigationMemoryListInput): Promise<NavigationMemoryEntry[]>;
|
|
72
|
+
inspect(id: string): Promise<NavigationMemoryEntry | undefined>;
|
|
73
|
+
delete(id: string): Promise<boolean>;
|
|
74
|
+
purgeExpired(): Promise<number>;
|
|
75
|
+
setMode(mode: NavigationMemoryMode): Promise<NavigationMemoryStatus>;
|
|
76
|
+
setTtlDays(ttlDays: number): Promise<NavigationMemoryStatus>;
|
|
77
|
+
status(): NavigationMemoryStatus;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function toIso(timestampMs: number): string {
|
|
81
|
+
return new Date(timestampMs).toISOString();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseTime(value: string): number {
|
|
85
|
+
const parsed = Date.parse(value);
|
|
86
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeTtlDays(value: number | undefined): number {
|
|
90
|
+
if (value === undefined) {
|
|
91
|
+
return DEFAULT_TTL_DAYS;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return Math.max(0, Math.trunc(value));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeConfidence(value: number | undefined): number {
|
|
98
|
+
if (value === undefined || Number.isNaN(value) || !Number.isFinite(value)) {
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (value <= 0) {
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (value >= 1) {
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function toExpireAt(nowMs: number, ttlDays: number): string {
|
|
114
|
+
return toIso(nowMs + normalizeTtlDays(ttlDays) * DAY_MS);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isScopeMatch(
|
|
118
|
+
entry: NavigationMemoryEntry,
|
|
119
|
+
input: Pick<NavigationMemoryResolveInput, "domain" | "profileId" | "intentKey">
|
|
120
|
+
): boolean {
|
|
121
|
+
return entry.domain === input.domain && entry.profileId === input.profileId && entry.intentKey === input.intentKey;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function cloneSignal(signal: NavigationSignal): NavigationSignal {
|
|
125
|
+
if (signal.kind === "hop") {
|
|
126
|
+
return {
|
|
127
|
+
kind: "hop",
|
|
128
|
+
from: signal.from,
|
|
129
|
+
to: signal.to
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
kind: signal.kind,
|
|
135
|
+
value: signal.value
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function cloneEntry(entry: NavigationMemoryEntry): NavigationMemoryEntry {
|
|
140
|
+
return {
|
|
141
|
+
...entry,
|
|
142
|
+
signals: entry.signals.map((signal) => cloneSignal(signal))
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isExpired(entry: NavigationMemoryEntry, nowMs: number): boolean {
|
|
147
|
+
return parseTime(entry.expireAt) <= nowMs;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isNewer(a: NavigationMemoryEntry, b: NavigationMemoryEntry): boolean {
|
|
151
|
+
return parseTime(a.lastHitAt) > parseTime(b.lastHitAt);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createNavigationMemoryStore(options: NavigationMemoryStoreOptions): NavigationMemoryStore {
|
|
155
|
+
if (options.path.trim().length === 0) {
|
|
156
|
+
throw new Error("Navigation memory path must not be empty.");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const now = options.now ?? (() => Date.now());
|
|
160
|
+
let mode: NavigationMemoryMode = options.mode ?? "auto";
|
|
161
|
+
let ttlDays = normalizeTtlDays(options.ttlDays);
|
|
162
|
+
const entries = new Map<string, NavigationMemoryEntry>();
|
|
163
|
+
let nextId = 1;
|
|
164
|
+
|
|
165
|
+
function createId(): string {
|
|
166
|
+
const id = `memory:${nextId}`;
|
|
167
|
+
nextId += 1;
|
|
168
|
+
return id;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function pruneExpired(): number {
|
|
172
|
+
const currentTime = now();
|
|
173
|
+
let removed = 0;
|
|
174
|
+
for (const [entryId, entry] of entries.entries()) {
|
|
175
|
+
if (!isExpired(entry, currentTime)) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
entries.delete(entryId);
|
|
180
|
+
removed += 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return removed;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function findScopedEntry(input: NavigationMemoryResolveInput): NavigationMemoryEntry | undefined {
|
|
187
|
+
let selected: NavigationMemoryEntry | undefined;
|
|
188
|
+
for (const entry of entries.values()) {
|
|
189
|
+
if (!isScopeMatch(entry, input)) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (selected === undefined) {
|
|
194
|
+
selected = entry;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (entry.confidence > selected.confidence || (entry.confidence === selected.confidence && isNewer(entry, selected))) {
|
|
199
|
+
selected = entry;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return selected;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getStatus(): NavigationMemoryStatus {
|
|
207
|
+
const currentTime = now();
|
|
208
|
+
const totalEntries = Array.from(entries.values()).filter((entry) => !isExpired(entry, currentTime)).length;
|
|
209
|
+
return {
|
|
210
|
+
path: options.path,
|
|
211
|
+
mode,
|
|
212
|
+
ttlDays,
|
|
213
|
+
totalEntries
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function assertUpsertAllowed(input: NavigationMemoryUpsertInput): void {
|
|
218
|
+
if (mode === "auto") {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (mode === "ask") {
|
|
223
|
+
if (input.confirmed === true) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
throw new Error("Navigation memory write requires confirmation in ask mode.");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
throw new Error("Navigation memory write is disabled when mode is off.");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
async resolve(input: NavigationMemoryResolveInput): Promise<NavigationMemoryResolveResult> {
|
|
235
|
+
if (mode === "off") {
|
|
236
|
+
return { hit: false };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
pruneExpired();
|
|
240
|
+
const existing = findScopedEntry(input);
|
|
241
|
+
if (existing === undefined) {
|
|
242
|
+
return { hit: false };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const currentTime = now();
|
|
246
|
+
const updated: NavigationMemoryEntry = {
|
|
247
|
+
...existing,
|
|
248
|
+
lastHitAt: toIso(currentTime),
|
|
249
|
+
expireAt: toExpireAt(currentTime, ttlDays),
|
|
250
|
+
hitCount: existing.hitCount + 1
|
|
251
|
+
};
|
|
252
|
+
entries.set(updated.id, updated);
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
hit: true,
|
|
256
|
+
entry: cloneEntry(updated)
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async upsert(input: NavigationMemoryUpsertInput): Promise<NavigationMemoryUpsertResult> {
|
|
261
|
+
assertUpsertAllowed(input);
|
|
262
|
+
pruneExpired();
|
|
263
|
+
const currentTime = now();
|
|
264
|
+
const confidence = normalizeConfidence(input.confidence);
|
|
265
|
+
const signals = input.signals.map((signal) => cloneSignal(signal));
|
|
266
|
+
const existing = findScopedEntry(input);
|
|
267
|
+
|
|
268
|
+
if (existing !== undefined) {
|
|
269
|
+
const updated: NavigationMemoryEntry = {
|
|
270
|
+
...existing,
|
|
271
|
+
signals,
|
|
272
|
+
confidence,
|
|
273
|
+
expireAt: toExpireAt(currentTime, ttlDays)
|
|
274
|
+
};
|
|
275
|
+
entries.set(updated.id, updated);
|
|
276
|
+
return {
|
|
277
|
+
created: false,
|
|
278
|
+
entry: cloneEntry(updated)
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const createdAt = toIso(currentTime);
|
|
283
|
+
const created: NavigationMemoryEntry = {
|
|
284
|
+
id: createId(),
|
|
285
|
+
domain: input.domain,
|
|
286
|
+
profileId: input.profileId,
|
|
287
|
+
intentKey: input.intentKey,
|
|
288
|
+
signals,
|
|
289
|
+
confidence,
|
|
290
|
+
createdAt,
|
|
291
|
+
lastHitAt: createdAt,
|
|
292
|
+
expireAt: toExpireAt(currentTime, ttlDays),
|
|
293
|
+
hitCount: 0
|
|
294
|
+
};
|
|
295
|
+
entries.set(created.id, created);
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
created: true,
|
|
299
|
+
entry: cloneEntry(created)
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
async list(filter?: NavigationMemoryListInput): Promise<NavigationMemoryEntry[]> {
|
|
304
|
+
pruneExpired();
|
|
305
|
+
return Array.from(entries.values())
|
|
306
|
+
.filter((entry) => {
|
|
307
|
+
if (filter === undefined) {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
if (filter.domain !== undefined && filter.domain !== entry.domain) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
if (filter.profileId !== undefined && filter.profileId !== entry.profileId) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
if (filter.intentKey !== undefined && filter.intentKey !== entry.intentKey) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
})
|
|
321
|
+
.map((entry) => cloneEntry(entry));
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
async inspect(id: string): Promise<NavigationMemoryEntry | undefined> {
|
|
325
|
+
pruneExpired();
|
|
326
|
+
const entry = entries.get(id);
|
|
327
|
+
return entry === undefined ? undefined : cloneEntry(entry);
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
async delete(id: string): Promise<boolean> {
|
|
331
|
+
return entries.delete(id);
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
async purgeExpired(): Promise<number> {
|
|
335
|
+
return pruneExpired();
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
async setMode(nextMode: NavigationMemoryMode): Promise<NavigationMemoryStatus> {
|
|
339
|
+
mode = nextMode;
|
|
340
|
+
return getStatus();
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
async setTtlDays(nextTtlDays: number): Promise<NavigationMemoryStatus> {
|
|
344
|
+
pruneExpired();
|
|
345
|
+
ttlDays = normalizeTtlDays(nextTtlDays);
|
|
346
|
+
const refreshedExpireAt = toExpireAt(now(), ttlDays);
|
|
347
|
+
for (const [entryId, entry] of entries.entries()) {
|
|
348
|
+
entries.set(entryId, {
|
|
349
|
+
...entry,
|
|
350
|
+
expireAt: refreshedExpireAt
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return getStatus();
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
status(): NavigationMemoryStatus {
|
|
357
|
+
return getStatus();
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
@@ -46,4 +46,37 @@ describe("SessionStore", () => {
|
|
|
46
46
|
|
|
47
47
|
expect(store.delete("missing")).toBe(false);
|
|
48
48
|
});
|
|
49
|
+
|
|
50
|
+
it("expires sessions after ttl and returns undefined", () => {
|
|
51
|
+
let now = 1_000;
|
|
52
|
+
const store = new SessionStore({
|
|
53
|
+
ttlMs: 100,
|
|
54
|
+
now: () => now
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
store.useProfile("session:ttl", "managed");
|
|
58
|
+
now += 150;
|
|
59
|
+
|
|
60
|
+
expect(store.get("session:ttl")).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("cleans up expired sessions and keeps active ones", () => {
|
|
64
|
+
let now = 1_000;
|
|
65
|
+
const store = new SessionStore({
|
|
66
|
+
ttlMs: 100,
|
|
67
|
+
now: () => now
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
store.useProfile("session:old", "managed");
|
|
71
|
+
now += 50;
|
|
72
|
+
store.useProfile("session:active", "managed");
|
|
73
|
+
now += 60;
|
|
74
|
+
|
|
75
|
+
expect(store.cleanupExpired()).toBe(1);
|
|
76
|
+
expect(store.get("session:old")).toBeUndefined();
|
|
77
|
+
expect(store.get("session:active")).toEqual({
|
|
78
|
+
sessionId: "session:active",
|
|
79
|
+
profile: "managed"
|
|
80
|
+
});
|
|
81
|
+
});
|
|
49
82
|
});
|