@mickey-kras/hindsight-memory-router-coding-agents 0.0.0-stage → 0.8.7
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/DEVIATIONS.md +166 -0
- package/LICENSE +23 -0
- package/README.md +83 -2
- package/UPSTREAM.json +263 -0
- package/cordis.patch.yml +18 -0
- package/dist/antigravity-hook.js +5666 -0
- package/dist/antigravity-statusline.js +1329 -0
- package/dist/antigravity-stop-hook.js +5859 -0
- package/dist/claude-hook.js +5666 -0
- package/dist/claude-sessionstart-hook.js +6349 -0
- package/dist/claude-stop-hook.js +5859 -0
- package/dist/cline.js +24976 -0
- package/dist/codebuddy-hook.js +5666 -0
- package/dist/codebuddy-sessionstart-hook.js +6349 -0
- package/dist/codebuddy-stop-hook.js +5859 -0
- package/dist/codex-hook.js +5666 -0
- package/dist/codex-sessionstart-hook.js +6349 -0
- package/dist/codex-stop-hook.js +5859 -0
- package/dist/copilot-hook.js +5666 -0
- package/dist/copilot-sessionstart-hook.js +6349 -0
- package/dist/copilot-stop-hook.js +5859 -0
- package/dist/cursor-hook.js +5666 -0
- package/dist/cursor-sessionstart-hook.js +6349 -0
- package/dist/cursor-stop-hook.js +5859 -0
- package/dist/daemon-start.js +13 -0
- package/dist/dcode-hook.js +5666 -0
- package/dist/dcode-sessionstart-hook.js +6349 -0
- package/dist/dcode-stop-hook.js +5859 -0
- package/dist/deepen.js +3119 -0
- package/dist/devin-hook.js +5666 -0
- package/dist/devin-sessionstart-hook.js +6349 -0
- package/dist/devin-stop-hook.js +5859 -0
- package/dist/droid-hook.js +5666 -0
- package/dist/droid-sessionstart-hook.js +6349 -0
- package/dist/droid-stop-hook.js +5859 -0
- package/dist/dsh.js +25098 -0
- package/dist/grok-hook.js +5666 -0
- package/dist/grok-sessionstart-hook.js +6349 -0
- package/dist/grok-stop-hook.js +5859 -0
- package/dist/hindsight-seed.js +1131 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +37505 -0
- package/dist/installer.js +9030 -0
- package/dist/kilo.js +37505 -0
- package/dist/kimi-hook.js +5666 -0
- package/dist/kimi-sessionstart-hook.js +6349 -0
- package/dist/kimi-stop-hook.js +5859 -0
- package/dist/mcp-server.js +39699 -0
- package/dist/opencode2.js +37464 -0
- package/dist/pi.js +25044 -0
- package/dist/prime-agent.js +25044 -0
- package/dist/qwen-hook.js +5666 -0
- package/dist/qwen-sessionstart-hook.js +6349 -0
- package/dist/qwen-stop-hook.js +5859 -0
- package/dist/status.js +2516 -0
- package/dist/survey-supervisor.js +81 -0
- package/dist/traecode-hook.js +5666 -0
- package/dist/traecode-sessionstart-hook.js +6349 -0
- package/dist/traecode-stop-hook.js +5859 -0
- package/dist/workbuddy-hook.js +5666 -0
- package/dist/workbuddy-sessionstart-hook.js +6349 -0
- package/dist/workbuddy-stop-hook.js +5859 -0
- package/dist/zcode-hook.js +5666 -0
- package/dist/zcode-sessionstart-hook.js +6349 -0
- package/dist/zcode-stop-hook.js +5859 -0
- package/hooks/hooks.json +37 -0
- package/index.js +10 -0
- package/npm-shrinkwrap.json +7756 -0
- package/package.json +124 -4
- package/plugin.json +16 -0
- package/skill/SKILL.md +141 -0
package/dist/status.js
ADDED
|
@@ -0,0 +1,2516 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../../coding-agents/runtime.ts
|
|
4
|
+
import { realpathSync } from "fs";
|
|
5
|
+
import { isAbsolute as isAbsolute2, resolve, sep } from "path";
|
|
6
|
+
|
|
7
|
+
// ../../shared/patterns.ts
|
|
8
|
+
var PRINCIPAL_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
9
|
+
var TOKEN_FORMAT_PATTERN = /^mr_[A-Za-z0-9._-]{1,64}_[0-9a-f]{64}$/;
|
|
10
|
+
var BANK_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
11
|
+
|
|
12
|
+
// ../../shared/bank-access.ts
|
|
13
|
+
var AccessDeniedError = class extends Error {
|
|
14
|
+
statusCode = 403;
|
|
15
|
+
constructor() {
|
|
16
|
+
super("memory access denied");
|
|
17
|
+
this.name = "AccessDeniedError";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
function visibleBanks(access) {
|
|
21
|
+
const banks = [.../* @__PURE__ */ new Set([...access.writeBank ? [access.writeBank] : [], ...access.additionalReadBanks])];
|
|
22
|
+
if (banks.some((bank) => !BANK_ID_PATTERN.test(bank) || bank === "." || bank === "..")) {
|
|
23
|
+
throw new AccessDeniedError();
|
|
24
|
+
}
|
|
25
|
+
return banks;
|
|
26
|
+
}
|
|
27
|
+
function requireBank(access, bank, operation) {
|
|
28
|
+
if (!visibleBanks(access).includes(bank) || operation === "write" && access.writeBank !== bank) {
|
|
29
|
+
throw new AccessDeniedError();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function classifyOperation(method, suffix) {
|
|
33
|
+
if (method === "POST" && ["/memories/recall", "/reflect"].includes(suffix)) return "read";
|
|
34
|
+
if (method === "GET" && /^\/(?:config|stats|tags|graph|documents|operations|memories|mental-models|knowledge-base)(?:\/[^/]+)*$/.test(
|
|
35
|
+
suffix
|
|
36
|
+
))
|
|
37
|
+
return "read";
|
|
38
|
+
if (method === "GET" && suffix === "") return "read";
|
|
39
|
+
if (["POST", "PUT", "PATCH", "DELETE"].includes(method) && /^(?:\/(?:config|import|memories|documents|mental-models|knowledge-base|consolidate|consolidation)(?:\/[^/]+)*)?$/.test(
|
|
40
|
+
suffix
|
|
41
|
+
))
|
|
42
|
+
return "write";
|
|
43
|
+
throw new AccessDeniedError();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ../../shared/managed-config.ts
|
|
47
|
+
import { readFileSync } from "fs";
|
|
48
|
+
import { isAbsolute } from "path";
|
|
49
|
+
|
|
50
|
+
// ../../shared/tool-prefix.ts
|
|
51
|
+
var ToolPrefixError = class extends Error {
|
|
52
|
+
};
|
|
53
|
+
var DEFAULT_TOOL_PREFIX = "hindsight_";
|
|
54
|
+
var PREFIX_PATTERN = /^[a-z][a-z0-9_]*_$/;
|
|
55
|
+
function validatedToolPrefix(value) {
|
|
56
|
+
if (value === void 0) return DEFAULT_TOOL_PREFIX;
|
|
57
|
+
if (typeof value !== "string" || value.length > 32 || !PREFIX_PATTERN.test(value)) {
|
|
58
|
+
throw new ToolPrefixError(
|
|
59
|
+
"invalid toolPrefix: expected 2-32 lowercase letters, digits or underscores, ending in underscore"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ../../shared/managed-config.ts
|
|
66
|
+
var TOKEN_ENV_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
67
|
+
function validatedPrincipal(configured) {
|
|
68
|
+
const human = configured.human;
|
|
69
|
+
if ((human === void 0 ? typeof configured.tokenEnv !== "string" || !TOKEN_ENV_PATTERN.test(configured.tokenEnv) : typeof human !== "object" || human === null || Object.keys(human).length !== 1 || typeof human.credentialRef !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(human.credentialRef) || Object.hasOwn(configured, "tokenEnv")) || "token" in configured || "apiToken" in configured) {
|
|
70
|
+
throw new AccessDeniedError();
|
|
71
|
+
}
|
|
72
|
+
if (configured.source !== void 0 && (typeof configured.source !== "string" || configured.source.trim() === "")) {
|
|
73
|
+
throw new AccessDeniedError();
|
|
74
|
+
}
|
|
75
|
+
if (typeof configured.writeBank === "string" && configured.writeBank.trim() === "") {
|
|
76
|
+
throw new AccessDeniedError();
|
|
77
|
+
}
|
|
78
|
+
if (configured.additionalReadBanks !== void 0 && !Array.isArray(configured.additionalReadBanks)) {
|
|
79
|
+
throw new AccessDeniedError();
|
|
80
|
+
}
|
|
81
|
+
const principal = { ...configured, additionalReadBanks: configured.additionalReadBanks ?? [] };
|
|
82
|
+
visibleBanks(principal);
|
|
83
|
+
return principal;
|
|
84
|
+
}
|
|
85
|
+
function validatePositiveInteger(value) {
|
|
86
|
+
if (value !== void 0 && (!Number.isSafeInteger(value) || value <= 0)) throw new AccessDeniedError();
|
|
87
|
+
}
|
|
88
|
+
function validateQueueOptions(config) {
|
|
89
|
+
for (const value of [
|
|
90
|
+
config.recallTimeoutMs,
|
|
91
|
+
config.recallMaxTokens,
|
|
92
|
+
config.retainQueueFlushIntervalMs,
|
|
93
|
+
config.queueMaxItems,
|
|
94
|
+
config.queueMaxBytes
|
|
95
|
+
]) {
|
|
96
|
+
validatePositiveInteger(value);
|
|
97
|
+
}
|
|
98
|
+
if (config.queueMaxAgeMs !== void 0 && (!Number.isSafeInteger(config.queueMaxAgeMs) || config.queueMaxAgeMs !== -1 && config.queueMaxAgeMs <= 0)) {
|
|
99
|
+
throw new AccessDeniedError();
|
|
100
|
+
}
|
|
101
|
+
if (config.queueDir !== void 0 && (typeof config.queueDir !== "string" || !isAbsolute(config.queueDir))) {
|
|
102
|
+
throw new AccessDeniedError();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function loadValidatedManagedConfig(env) {
|
|
106
|
+
const path = env.HINDSIGHT_ROUTER_CONFIG;
|
|
107
|
+
if (!path || !isAbsolute(path)) throw new AccessDeniedError();
|
|
108
|
+
const config = JSON.parse(readFileSync(path, "utf8"));
|
|
109
|
+
const toolPrefix = validatedToolPrefix(config.toolPrefix);
|
|
110
|
+
validateQueueOptions(config);
|
|
111
|
+
return { ...config, toolPrefix };
|
|
112
|
+
}
|
|
113
|
+
function loadManagedConfig(env, principalId) {
|
|
114
|
+
try {
|
|
115
|
+
if (!principalId) throw new AccessDeniedError();
|
|
116
|
+
const config = loadValidatedManagedConfig(env);
|
|
117
|
+
if (!Object.hasOwn(config.principals ?? {}, principalId)) throw new AccessDeniedError();
|
|
118
|
+
const principal = validatedPrincipal(config.principals[principalId]);
|
|
119
|
+
validateQueueOptions(config);
|
|
120
|
+
return { config, principalId, principal };
|
|
121
|
+
} catch {
|
|
122
|
+
throw new AccessDeniedError();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ../../shared/router-transport.ts
|
|
127
|
+
import { createHash } from "crypto";
|
|
128
|
+
|
|
129
|
+
// ../../shared/router-url.ts
|
|
130
|
+
var RouterUrlError = class extends Error {
|
|
131
|
+
constructor(reason) {
|
|
132
|
+
super(`routerUrl rejected: ${reason}`);
|
|
133
|
+
this.name = "RouterUrlError";
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
function validateRouterUrl(value) {
|
|
137
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
138
|
+
throw new RouterUrlError("missing");
|
|
139
|
+
}
|
|
140
|
+
let url;
|
|
141
|
+
try {
|
|
142
|
+
url = new URL(value.trim());
|
|
143
|
+
} catch {
|
|
144
|
+
throw new RouterUrlError("invalid");
|
|
145
|
+
}
|
|
146
|
+
if (url.protocol !== "https:") {
|
|
147
|
+
throw new RouterUrlError("not-https");
|
|
148
|
+
}
|
|
149
|
+
const hostname = url.hostname.replace(/\.$/, "");
|
|
150
|
+
if (hostname === "hindsight.vectorize.io" || hostname.endsWith(".hindsight.vectorize.io")) {
|
|
151
|
+
throw new RouterUrlError("upstream");
|
|
152
|
+
}
|
|
153
|
+
if (url.username !== "" || url.password !== "") {
|
|
154
|
+
throw new RouterUrlError("userinfo");
|
|
155
|
+
}
|
|
156
|
+
return url.toString().replace(/\/$/, "");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ../../shared/router-transport.ts
|
|
160
|
+
var RouterRequestError = class extends Error {
|
|
161
|
+
constructor(statusCode, retryAfterMs2 = 0) {
|
|
162
|
+
super(`memory request failed (${statusCode})`);
|
|
163
|
+
this.statusCode = statusCode;
|
|
164
|
+
this.retryAfterMs = retryAfterMs2;
|
|
165
|
+
this.name = "RouterRequestError";
|
|
166
|
+
}
|
|
167
|
+
statusCode;
|
|
168
|
+
retryAfterMs;
|
|
169
|
+
};
|
|
170
|
+
function boundedRetryAfterMs(header) {
|
|
171
|
+
if (!header?.trim()) return 0;
|
|
172
|
+
const value = header.trim();
|
|
173
|
+
const delay = /^\d+(?:\.\d+)?$/.test(value) ? Number(value) * 1e3 : Date.parse(value) - Date.now();
|
|
174
|
+
return Number.isFinite(delay) ? Math.min(6e4, Math.max(0, delay)) : 0;
|
|
175
|
+
}
|
|
176
|
+
var ROUTING_KEYS = ["bankId", "bank_id", "bank_ids"];
|
|
177
|
+
function assertAuthorizedUrl(url, method, baseUrl, access) {
|
|
178
|
+
const listing = url === `${baseUrl}/v1/default/banks` && method === "GET";
|
|
179
|
+
if (listing || [`${baseUrl}/version`, `${baseUrl}/v1/version`].includes(url) && method === "GET") {
|
|
180
|
+
return listing;
|
|
181
|
+
}
|
|
182
|
+
const prefix = `${baseUrl}/v1/default/banks/`;
|
|
183
|
+
if (!url.startsWith(prefix)) throw new AccessDeniedError();
|
|
184
|
+
const path = url.slice(prefix.length).split("?")[0];
|
|
185
|
+
const split = path.indexOf("/");
|
|
186
|
+
const bank = decodeURIComponent(split < 0 ? path : path.slice(0, split));
|
|
187
|
+
const suffix = split < 0 ? "" : path.slice(split);
|
|
188
|
+
requireBank(access, bank, classifyOperation(method, suffix));
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
async function filterVisibleBanks(response, access) {
|
|
192
|
+
const data = await response.json();
|
|
193
|
+
if (!Array.isArray(data.banks)) throw new RouterRequestError(502);
|
|
194
|
+
const allowed = new Set(visibleBanks(access));
|
|
195
|
+
const banks = data.banks.filter(
|
|
196
|
+
(bank) => bank !== null && typeof bank === "object" && allowed.has(bank.bank_id ?? "")
|
|
197
|
+
);
|
|
198
|
+
return Response.json({ banks, total: banks.length });
|
|
199
|
+
}
|
|
200
|
+
function assertSafeRequest(url, body) {
|
|
201
|
+
const rawPath = /^[a-z][a-z\d+.-]*:\/\/[^/?#]*([^?#]*)/i.exec(url)?.[1] ?? "";
|
|
202
|
+
if (url.includes("#") || /\\/.test(rawPath) || /%(?:2e|2f|5c|25)/i.test(rawPath) || /\/\.\.?(?:\/|$)/.test(rawPath)) {
|
|
203
|
+
throw new AccessDeniedError();
|
|
204
|
+
}
|
|
205
|
+
const parsed = new URL(url);
|
|
206
|
+
if (ROUTING_KEYS.some((key) => parsed.searchParams.has(key))) throw new AccessDeniedError();
|
|
207
|
+
if (typeof body === "string") {
|
|
208
|
+
let parsedBody;
|
|
209
|
+
try {
|
|
210
|
+
parsedBody = JSON.parse(body);
|
|
211
|
+
} catch {
|
|
212
|
+
throw new AccessDeniedError();
|
|
213
|
+
}
|
|
214
|
+
if (parsedBody && typeof parsedBody === "object" && ROUTING_KEYS.some((key) => Object.hasOwn(parsedBody, key))) {
|
|
215
|
+
throw new AccessDeniedError();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (parsed.username || parsed.password) throw new AccessDeniedError();
|
|
219
|
+
return parsed;
|
|
220
|
+
}
|
|
221
|
+
var GLOBAL_DISPATCHER = /* @__PURE__ */ Symbol.for("undici.globalDispatcher.1");
|
|
222
|
+
function signalOnlyDispatcher() {
|
|
223
|
+
Reflect.get(globalThis, "Headers");
|
|
224
|
+
const d = globalThis[GLOBAL_DISPATCHER];
|
|
225
|
+
if (typeof d?.compose !== "function") return void 0;
|
|
226
|
+
return d.compose(
|
|
227
|
+
(dispatch) => (opts, handler) => dispatch({ ...opts, headersTimeout: 0, bodyTimeout: 0 }, handler)
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
var RouterTransport = class {
|
|
231
|
+
baseUrl;
|
|
232
|
+
access;
|
|
233
|
+
#token;
|
|
234
|
+
#send;
|
|
235
|
+
#principalId;
|
|
236
|
+
#tokenPattern;
|
|
237
|
+
#unauthorizedError;
|
|
238
|
+
#deniedToken;
|
|
239
|
+
#deniedError;
|
|
240
|
+
constructor(options) {
|
|
241
|
+
this.baseUrl = validateRouterUrl(options.routerUrl);
|
|
242
|
+
const url = new URL(this.baseUrl);
|
|
243
|
+
if (url.search || url.hash) throw new AccessDeniedError();
|
|
244
|
+
this.access = Object.freeze({
|
|
245
|
+
writeBank: options.access.writeBank,
|
|
246
|
+
additionalReadBanks: Object.freeze([...options.access.additionalReadBanks])
|
|
247
|
+
});
|
|
248
|
+
visibleBanks(this.access);
|
|
249
|
+
this.#principalId = options.principalId;
|
|
250
|
+
this.#tokenPattern = options.tokenPattern ?? TOKEN_FORMAT_PATTERN;
|
|
251
|
+
this.#unauthorizedError = options.unauthorizedError ?? (() => new AccessDeniedError());
|
|
252
|
+
this.#token = options.token;
|
|
253
|
+
this.#send = options.fetch ?? ((input, init) => fetch(input, init));
|
|
254
|
+
this.credential();
|
|
255
|
+
}
|
|
256
|
+
hasCredentials() {
|
|
257
|
+
try {
|
|
258
|
+
this.credential();
|
|
259
|
+
return true;
|
|
260
|
+
} catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
assertAuthorized() {
|
|
265
|
+
this.credential();
|
|
266
|
+
}
|
|
267
|
+
authorizationIdentity() {
|
|
268
|
+
this.credential();
|
|
269
|
+
const principal = typeof this.#principalId === "function" ? this.#principalId() : this.#principalId;
|
|
270
|
+
if (!principal) throw new AccessDeniedError();
|
|
271
|
+
return createHash("sha256").update(principal).digest("hex");
|
|
272
|
+
}
|
|
273
|
+
credential() {
|
|
274
|
+
const token = this.#token();
|
|
275
|
+
if (!token || !this.#tokenPattern.test(token)) throw new AccessDeniedError();
|
|
276
|
+
if (this.#deniedToken === token) throw this.#deniedError ?? new AccessDeniedError();
|
|
277
|
+
if (this.#deniedToken !== void 0) {
|
|
278
|
+
this.#deniedToken = void 0;
|
|
279
|
+
this.#deniedError = void 0;
|
|
280
|
+
}
|
|
281
|
+
return token;
|
|
282
|
+
}
|
|
283
|
+
bankUrl(bank, suffix = "") {
|
|
284
|
+
requireBank(this.access, bank, "read");
|
|
285
|
+
return `${this.baseUrl}/v1/default/banks/${encodeURIComponent(bank)}${suffix}`;
|
|
286
|
+
}
|
|
287
|
+
async request(url, init = {}) {
|
|
288
|
+
const token = this.credential();
|
|
289
|
+
const method = init.method ?? "GET";
|
|
290
|
+
assertSafeRequest(url, init.body);
|
|
291
|
+
const listing = assertAuthorizedUrl(url, method, this.baseUrl, this.access);
|
|
292
|
+
const response = await this.send(url, init, token);
|
|
293
|
+
if (response.status === 401 || response.status === 403) {
|
|
294
|
+
const error = response.status === 401 ? this.#unauthorizedError() : new AccessDeniedError();
|
|
295
|
+
this.#deniedToken = token;
|
|
296
|
+
this.#deniedError = error;
|
|
297
|
+
response.body?.cancel().catch(() => void 0);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
if (!response.ok && (response.status !== 404 || method !== "GET")) {
|
|
301
|
+
response.body?.cancel().catch(() => void 0);
|
|
302
|
+
throw new RouterRequestError(
|
|
303
|
+
response.status,
|
|
304
|
+
response.status === 429 ? boundedRetryAfterMs(response.headers.get("retry-after")) : 0
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (listing && response.ok) {
|
|
308
|
+
return filterVisibleBanks(response, this.access);
|
|
309
|
+
}
|
|
310
|
+
if (response.status === 404) {
|
|
311
|
+
response.body?.cancel().catch(() => void 0);
|
|
312
|
+
return new Response(null, { status: 404 });
|
|
313
|
+
}
|
|
314
|
+
return response;
|
|
315
|
+
}
|
|
316
|
+
async send(url, init, token) {
|
|
317
|
+
try {
|
|
318
|
+
init.signal?.throwIfAborted();
|
|
319
|
+
const signal = init.signal ?? AbortSignal.timeout(15e3);
|
|
320
|
+
const dispatcher = signalOnlyDispatcher();
|
|
321
|
+
return await this.#send(url, {
|
|
322
|
+
...init,
|
|
323
|
+
...dispatcher ? { dispatcher } : {},
|
|
324
|
+
redirect: "error",
|
|
325
|
+
headers: {
|
|
326
|
+
"Content-Type": "application/json",
|
|
327
|
+
"Accept-Encoding": "identity",
|
|
328
|
+
Authorization: `Bearer ${token}`,
|
|
329
|
+
...this.#principalId ? {
|
|
330
|
+
"x-memory-router-agent": typeof this.#principalId === "string" ? this.#principalId : this.#principalId()
|
|
331
|
+
} : {}
|
|
332
|
+
},
|
|
333
|
+
signal
|
|
334
|
+
});
|
|
335
|
+
} catch (error) {
|
|
336
|
+
init.signal?.throwIfAborted();
|
|
337
|
+
process.stderr.write(`memory request failed: ${error instanceof Error ? error.name : typeof error}
|
|
338
|
+
`);
|
|
339
|
+
throw new RouterRequestError(503);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// ../../coding-agents/human-session.ts
|
|
345
|
+
import { createHash as createHash2 } from "crypto";
|
|
346
|
+
import { createRequire } from "module";
|
|
347
|
+
var HUMAN_TOKEN_PATTERN = /^mrh_[0-9a-f]{32}_[0-9a-f]{64}$/;
|
|
348
|
+
var HumanLoginRequiredError = class extends AccessDeniedError {
|
|
349
|
+
constructor() {
|
|
350
|
+
super();
|
|
351
|
+
this.message = "human session unavailable; unlock OS secure storage and run hindsight-coding-agents login; if native support is missing, reinstall from a verified package";
|
|
352
|
+
this.name = "HumanLoginRequiredError";
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
function humanRouterUrl(value) {
|
|
356
|
+
const input = value.trim();
|
|
357
|
+
if (Array.from(input).some((character) => {
|
|
358
|
+
const code = character.codePointAt(0);
|
|
359
|
+
return code !== void 0 && (code <= 31 || code === 127);
|
|
360
|
+
})) {
|
|
361
|
+
throw new HumanLoginRequiredError();
|
|
362
|
+
}
|
|
363
|
+
const parts = /^https:\/\/([^/?#\\@]+)(\/[^?#]*)?$/i.exec(input);
|
|
364
|
+
if (!parts) throw new HumanLoginRequiredError();
|
|
365
|
+
const rawPath = parts[2] ?? "";
|
|
366
|
+
if (input.includes("\\") || /%|\/\.\.?(?:\/|$)|(?:\/{2}$)/.test(rawPath)) throw new HumanLoginRequiredError();
|
|
367
|
+
const canonical2 = validateRouterUrl(input);
|
|
368
|
+
const url = new URL(canonical2);
|
|
369
|
+
if (url.search || url.hash) throw new HumanLoginRequiredError();
|
|
370
|
+
return canonical2;
|
|
371
|
+
}
|
|
372
|
+
function secureEntry(routerUrl, credentialRef) {
|
|
373
|
+
try {
|
|
374
|
+
if (!["darwin", "win32", "linux"].includes(process.platform) || !/^[A-Za-z0-9._-]{1,64}$/.test(credentialRef)) {
|
|
375
|
+
throw new HumanLoginRequiredError();
|
|
376
|
+
}
|
|
377
|
+
const { Entry: NativeEntry } = createRequire(import.meta.url)("@napi-rs/keyring");
|
|
378
|
+
const service = `hindsight-memory-router:${createHash2("sha256").update(humanRouterUrl(routerUrl)).digest("hex")}`;
|
|
379
|
+
return new NativeEntry(service, credentialRef, { linux: { store: "secret-service" } });
|
|
380
|
+
} catch {
|
|
381
|
+
throw new HumanLoginRequiredError();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function validateHumanSession(value, routerUrl, allowExpired = false) {
|
|
385
|
+
if (typeof value !== "object" || value === null) throw new HumanLoginRequiredError();
|
|
386
|
+
const session = value;
|
|
387
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
388
|
+
if (typeof session.token !== "string" || !HUMAN_TOKEN_PATTERN.test(session.token) || typeof session.principal_id !== "string" || !PRINCIPAL_ID_PATTERN.test(session.principal_id) || typeof session.expires_at !== "number" || !Number.isSafeInteger(session.expires_at) || !allowExpired && session.expires_at <= now || session.expires_at > now + 3660 || session.routerUrl !== humanRouterUrl(routerUrl))
|
|
389
|
+
throw new HumanLoginRequiredError();
|
|
390
|
+
return {
|
|
391
|
+
token: session.token,
|
|
392
|
+
principal_id: session.principal_id,
|
|
393
|
+
expires_at: session.expires_at,
|
|
394
|
+
routerUrl: session.routerUrl
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function readHumanSession(routerUrl, credentialRef) {
|
|
398
|
+
try {
|
|
399
|
+
const password = secureEntry(routerUrl, credentialRef).getPassword();
|
|
400
|
+
if (!password || password.length > 4096) throw new HumanLoginRequiredError();
|
|
401
|
+
return validateHumanSession(JSON.parse(password), routerUrl);
|
|
402
|
+
} catch {
|
|
403
|
+
throw new HumanLoginRequiredError();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ../../coding-agents/runtime.ts
|
|
408
|
+
function harnessTransport(harness) {
|
|
409
|
+
return transportForConfig(harness, loadManagedConfig(process.env, harness));
|
|
410
|
+
}
|
|
411
|
+
function transportForConfig(harness, { config, principal }) {
|
|
412
|
+
if (principal.human) {
|
|
413
|
+
const routerUrl = humanRouterUrl(config.routerUrl);
|
|
414
|
+
const reference = principal.human.credentialRef;
|
|
415
|
+
let session = readHumanSession(routerUrl, reference);
|
|
416
|
+
return new RouterTransport({
|
|
417
|
+
routerUrl,
|
|
418
|
+
access: principal,
|
|
419
|
+
principalId: () => session.principal_id,
|
|
420
|
+
tokenPattern: HUMAN_TOKEN_PATTERN,
|
|
421
|
+
unauthorizedError: () => new HumanLoginRequiredError(),
|
|
422
|
+
token: () => {
|
|
423
|
+
session = readHumanSession(routerUrl, reference);
|
|
424
|
+
return session.token;
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
const tokenEnv = principal.tokenEnv;
|
|
429
|
+
if (!tokenEnv) throw new AccessDeniedError();
|
|
430
|
+
return new RouterTransport({
|
|
431
|
+
routerUrl: config.routerUrl,
|
|
432
|
+
access: principal,
|
|
433
|
+
principalId: harness,
|
|
434
|
+
token: () => process.env[tokenEnv]
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
function managedSettings(harness) {
|
|
438
|
+
if (!harness) throw new AccessDeniedError();
|
|
439
|
+
const snapshot = loadManagedConfig(process.env, harness);
|
|
440
|
+
const transport = transportForConfig(harness, snapshot);
|
|
441
|
+
const { config } = snapshot;
|
|
442
|
+
const readOnlySettings = transport.access.writeBank ? {} : {
|
|
443
|
+
retainSessions: false,
|
|
444
|
+
autoSeed: false,
|
|
445
|
+
codebaseSurvey: false,
|
|
446
|
+
gitIngest: "none"
|
|
447
|
+
};
|
|
448
|
+
return {
|
|
449
|
+
harness,
|
|
450
|
+
routerHarness: harness,
|
|
451
|
+
toolPrefix: config.toolPrefix,
|
|
452
|
+
apiUrl: transport.baseUrl,
|
|
453
|
+
apiToken: void 0,
|
|
454
|
+
serverMode: "self-hosted",
|
|
455
|
+
dynamicBankId: false,
|
|
456
|
+
autoUpdate: false,
|
|
457
|
+
optInOnly: true,
|
|
458
|
+
manageBankConfig: false,
|
|
459
|
+
...readOnlySettings
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
function managedBank(harness, directory) {
|
|
463
|
+
return bankForPrincipal(loadManagedConfig(process.env, harness).principal, directory);
|
|
464
|
+
}
|
|
465
|
+
function bankForPrincipal(principal, directory) {
|
|
466
|
+
if (!directory) throw new AccessDeniedError();
|
|
467
|
+
let location;
|
|
468
|
+
try {
|
|
469
|
+
location = realpathSync(directory);
|
|
470
|
+
} catch {
|
|
471
|
+
throw new AccessDeniedError();
|
|
472
|
+
}
|
|
473
|
+
const match = Object.entries(principal.mapPathToBank ?? {}).flatMap(([path, bank]) => {
|
|
474
|
+
if (!isAbsolute2(path)) return [];
|
|
475
|
+
try {
|
|
476
|
+
return [[realpathSync(resolve(path)), bank]];
|
|
477
|
+
} catch {
|
|
478
|
+
return [];
|
|
479
|
+
}
|
|
480
|
+
}).filter(([path]) => isWithinDirectory(location, path)).sort(([a], [b]) => b.length - a.length)[0];
|
|
481
|
+
if (!match) throw new AccessDeniedError();
|
|
482
|
+
requireBank(principal, match[1], "read");
|
|
483
|
+
return match[1];
|
|
484
|
+
}
|
|
485
|
+
function isWithinDirectory(location, directory) {
|
|
486
|
+
return location === directory || location.startsWith(directory.endsWith(sep) ? directory : directory + sep);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/core/bank.ts
|
|
490
|
+
import { existsSync } from "fs";
|
|
491
|
+
import { homedir as homedir2 } from "os";
|
|
492
|
+
import { basename, dirname as dirname3, join as join4, normalize, sep as sep2 } from "path";
|
|
493
|
+
|
|
494
|
+
// src/core/diag.ts
|
|
495
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
496
|
+
import { join as join2 } from "path";
|
|
497
|
+
|
|
498
|
+
// src/core/log.ts
|
|
499
|
+
import { appendFileSync, mkdirSync, renameSync, statSync } from "fs";
|
|
500
|
+
import { homedir } from "os";
|
|
501
|
+
import { dirname, join } from "path";
|
|
502
|
+
function logsDir() {
|
|
503
|
+
return join(homedir(), ".hindsight", "coding-agents-logs");
|
|
504
|
+
}
|
|
505
|
+
var LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
506
|
+
function appendLogLine(file, text) {
|
|
507
|
+
mkdirSync(dirname(file), { recursive: true, mode: 448 });
|
|
508
|
+
try {
|
|
509
|
+
if (statSync(file).size >= LOG_MAX_BYTES) renameSync(file, `${file}.1`);
|
|
510
|
+
} catch {
|
|
511
|
+
}
|
|
512
|
+
appendFileSync(file, text, { mode: 384 });
|
|
513
|
+
}
|
|
514
|
+
var WEIGHT = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
515
|
+
var current = ["debug", "info", "warn", "error"].find(
|
|
516
|
+
(l) => l === process.env.HINDSIGHT_LOG_LEVEL
|
|
517
|
+
) ?? "info";
|
|
518
|
+
function logFilePath() {
|
|
519
|
+
return process.env.HINDSIGHT_LOG_FILE || join(logsDir(), "plugin.log");
|
|
520
|
+
}
|
|
521
|
+
var NUMERIC_FIELDS = /* @__PURE__ */ new Set([
|
|
522
|
+
"ms",
|
|
523
|
+
"chars",
|
|
524
|
+
"count",
|
|
525
|
+
"turns",
|
|
526
|
+
"timeoutMs",
|
|
527
|
+
"pages",
|
|
528
|
+
"newChats",
|
|
529
|
+
"failures",
|
|
530
|
+
"hostCatalog",
|
|
531
|
+
"hindsight",
|
|
532
|
+
"commits",
|
|
533
|
+
"limit"
|
|
534
|
+
]);
|
|
535
|
+
var BOOLEAN_FIELDS = /* @__PURE__ */ new Set(["cold", "wired", "hasSession", "appendSupported", "retry", "settled"]);
|
|
536
|
+
var ERROR_CLASSES = ["timeout", "aborted", "connection_refused", "dns_failure", "operation_failed"];
|
|
537
|
+
function errorClass(value) {
|
|
538
|
+
const seen = /* @__PURE__ */ new Set();
|
|
539
|
+
let error = value;
|
|
540
|
+
while (error && typeof error === "object" && !seen.has(error) && seen.size < 8) {
|
|
541
|
+
seen.add(error);
|
|
542
|
+
const detail = error;
|
|
543
|
+
if (detail.timedOut === true || detail.name === "TimeoutError" || detail.code === "ETIMEDOUT") return "timeout";
|
|
544
|
+
if (detail.name === "AbortError") return "aborted";
|
|
545
|
+
if (detail.code === "ECONNREFUSED") return "connection_refused";
|
|
546
|
+
if (detail.code === "ENOTFOUND" || detail.code === "EAI_AGAIN") return "dns_failure";
|
|
547
|
+
error = detail.cause;
|
|
548
|
+
}
|
|
549
|
+
return "operation_failed";
|
|
550
|
+
}
|
|
551
|
+
function logMetadata(extra = {}) {
|
|
552
|
+
const safe = {};
|
|
553
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
554
|
+
if (NUMERIC_FIELDS.has(key) && typeof value === "number" && Number.isFinite(value)) safe[key] = value;
|
|
555
|
+
else if (key === "status" && typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599) safe[key] = value;
|
|
556
|
+
else if (BOOLEAN_FIELDS.has(key) && typeof value === "boolean") safe[key] = value;
|
|
557
|
+
else if (key === "mode" && (value === "append" || value === "replace" || value === "skip")) safe[key] = value;
|
|
558
|
+
else if (key === "error") safe.errorClass = errorClass(value);
|
|
559
|
+
else if (key === "errorClass" && ERROR_CLASSES.some((known) => known === value)) safe.errorClass = String(value);
|
|
560
|
+
}
|
|
561
|
+
return safe;
|
|
562
|
+
}
|
|
563
|
+
function write(level, scope, msg, extra) {
|
|
564
|
+
if (WEIGHT[level] < WEIGHT[current]) return;
|
|
565
|
+
try {
|
|
566
|
+
const metadata = logMetadata(extra);
|
|
567
|
+
appendLogLine(
|
|
568
|
+
logFilePath(),
|
|
569
|
+
`${(/* @__PURE__ */ new Date()).toISOString()} ${level.toUpperCase().padEnd(5)} [${scope}] ${msg}` + (Object.keys(metadata).length ? ` ${JSON.stringify(metadata)}` : "") + "\n"
|
|
570
|
+
);
|
|
571
|
+
} catch {
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
var log = {
|
|
575
|
+
debug: (scope, msg, extra) => write("debug", scope, msg, extra),
|
|
576
|
+
info: (scope, msg, extra) => write("info", scope, msg, extra),
|
|
577
|
+
warn: (scope, msg, extra) => write("warn", scope, msg, extra),
|
|
578
|
+
error: (scope, msg, extra) => write("error", scope, msg, extra)
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
// src/core/diag.ts
|
|
582
|
+
function diagFilePath() {
|
|
583
|
+
return process.env.HINDSIGHT_DIAG_FILE || join2(logsDir(), "diag.jsonl");
|
|
584
|
+
}
|
|
585
|
+
function diag(harness, event, extra = {}) {
|
|
586
|
+
try {
|
|
587
|
+
const metadata = logMetadata(extra);
|
|
588
|
+
log.debug(harness, `diag:${event}`, metadata);
|
|
589
|
+
appendLogLine(
|
|
590
|
+
diagFilePath(),
|
|
591
|
+
JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), harness, event, ...metadata }) + "\n"
|
|
592
|
+
);
|
|
593
|
+
} catch {
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/core/git-layout.ts
|
|
598
|
+
import { lstatSync, readFileSync as readFileSync3, realpathSync as realpathSync2 } from "fs";
|
|
599
|
+
import { dirname as dirname2, isAbsolute as isAbsolute3, join as join3, resolve as resolve2 } from "path";
|
|
600
|
+
var TRANSIENT = /* @__PURE__ */ new Set(["EAGAIN", "EMFILE", "ENFILE", "EBUSY", "EINTR", "EIO", "ETIMEDOUT"]);
|
|
601
|
+
var ATTEMPTS = 3;
|
|
602
|
+
var BACKOFF_MS = 20;
|
|
603
|
+
function errorCode(error) {
|
|
604
|
+
return error?.code ?? "";
|
|
605
|
+
}
|
|
606
|
+
function sleepSync(ms) {
|
|
607
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
608
|
+
}
|
|
609
|
+
var TransientProbeError = class extends Error {
|
|
610
|
+
};
|
|
611
|
+
function entryKind(path) {
|
|
612
|
+
try {
|
|
613
|
+
const st = lstatSync(path);
|
|
614
|
+
return st.isDirectory() ? "dir" : "file";
|
|
615
|
+
} catch (error) {
|
|
616
|
+
if (TRANSIENT.has(errorCode(error))) throw new TransientProbeError(errorCode(error));
|
|
617
|
+
return "none";
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
function readTextOrNull(path) {
|
|
621
|
+
try {
|
|
622
|
+
return readFileSync3(path, "utf8");
|
|
623
|
+
} catch (error) {
|
|
624
|
+
if (TRANSIENT.has(errorCode(error))) throw new TransientProbeError(errorCode(error));
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
function gitDirFromPointer(text, holder) {
|
|
629
|
+
const match = /^\s*gitdir:\s*(.+?)\s*$/m.exec(text);
|
|
630
|
+
if (!match) return null;
|
|
631
|
+
const target = match[1];
|
|
632
|
+
return isAbsolute3(target) ? target : resolve2(holder, target);
|
|
633
|
+
}
|
|
634
|
+
function commonDirOf(gitDir) {
|
|
635
|
+
const pointer = entryKind(join3(gitDir, "commondir")) === "file" ? readTextOrNull(join3(gitDir, "commondir"))?.trim() : null;
|
|
636
|
+
if (!pointer) return gitDir;
|
|
637
|
+
return isAbsolute3(pointer) ? pointer : resolve2(gitDir, pointer);
|
|
638
|
+
}
|
|
639
|
+
function isBare(commonDir) {
|
|
640
|
+
const config = readTextOrNull(join3(commonDir, "config"));
|
|
641
|
+
return config !== null && /^\s*bare\s*=\s*true\s*$/im.test(config);
|
|
642
|
+
}
|
|
643
|
+
function looksLikeBareRepository(directory) {
|
|
644
|
+
return entryKind(join3(directory, "HEAD")) === "file" && entryKind(join3(directory, "objects")) === "dir" && entryKind(join3(directory, "refs")) === "dir";
|
|
645
|
+
}
|
|
646
|
+
function canonical(path) {
|
|
647
|
+
try {
|
|
648
|
+
return realpathSync2(path);
|
|
649
|
+
} catch {
|
|
650
|
+
return path;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function resolved(commonDir) {
|
|
654
|
+
if (entryKind(commonDir) !== "dir") return null;
|
|
655
|
+
const canonicalDir = canonical(commonDir);
|
|
656
|
+
return { status: "resolved", commonDir: canonicalDir, bare: isBare(canonicalDir) };
|
|
657
|
+
}
|
|
658
|
+
function probeOnce(directory) {
|
|
659
|
+
let current2 = resolve2(directory);
|
|
660
|
+
for (; ; ) {
|
|
661
|
+
const dotGit = join3(current2, ".git");
|
|
662
|
+
const kind = entryKind(dotGit);
|
|
663
|
+
if (kind === "dir") {
|
|
664
|
+
const layout = resolved(commonDirOf(dotGit));
|
|
665
|
+
if (layout) return layout;
|
|
666
|
+
} else if (kind === "file") {
|
|
667
|
+
const text = readTextOrNull(dotGit);
|
|
668
|
+
const gitDir = text ? gitDirFromPointer(text, current2) : null;
|
|
669
|
+
const layout = gitDir ? resolved(commonDirOf(gitDir)) : null;
|
|
670
|
+
if (layout) return layout;
|
|
671
|
+
}
|
|
672
|
+
if (looksLikeBareRepository(current2)) {
|
|
673
|
+
const layout = resolved(current2);
|
|
674
|
+
if (layout) return layout;
|
|
675
|
+
}
|
|
676
|
+
const parent = dirname2(current2);
|
|
677
|
+
if (parent === current2) return { status: "absent" };
|
|
678
|
+
current2 = parent;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function probeGitLayout(directory) {
|
|
682
|
+
if (!directory) return { status: "absent" };
|
|
683
|
+
let last = "";
|
|
684
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
685
|
+
if (attempt) sleepSync(BACKOFF_MS * attempt);
|
|
686
|
+
try {
|
|
687
|
+
return probeOnce(directory);
|
|
688
|
+
} catch (error) {
|
|
689
|
+
last = error instanceof TransientProbeError ? error.message : errorCode(error) || "unknown";
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return { status: "failed", reason: last };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/core/bank.ts
|
|
696
|
+
var BankResolutionError = class extends Error {
|
|
697
|
+
constructor(message) {
|
|
698
|
+
super(message);
|
|
699
|
+
this.name = "BankResolutionError";
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
function resolveProjectRoot(directory) {
|
|
703
|
+
if (!directory) return { status: "absent" };
|
|
704
|
+
const layout = probeGitLayout(directory);
|
|
705
|
+
if (layout.status !== "resolved") return layout;
|
|
706
|
+
const commonDir = layout.commonDir;
|
|
707
|
+
if (basename(commonDir) === ".git") return { status: "resolved", root: dirname3(commonDir) };
|
|
708
|
+
if (basename(commonDir).startsWith(".") && layout.bare) {
|
|
709
|
+
return { status: "resolved", root: dirname3(commonDir) };
|
|
710
|
+
}
|
|
711
|
+
return { status: "resolved", root: commonDir };
|
|
712
|
+
}
|
|
713
|
+
function projectNameOf(directory, sessionRoot) {
|
|
714
|
+
return gitProjectName(directory, true, sessionRoot);
|
|
715
|
+
}
|
|
716
|
+
var PROJECT_ROOT_ENV = ["CLAUDE_PROJECT_DIR"];
|
|
717
|
+
function nearestExistingDir(directory) {
|
|
718
|
+
let current2 = directory;
|
|
719
|
+
while (current2) {
|
|
720
|
+
if (existsSync(current2)) return current2;
|
|
721
|
+
const parent = dirname3(current2);
|
|
722
|
+
if (parent === current2) return "";
|
|
723
|
+
current2 = parent;
|
|
724
|
+
}
|
|
725
|
+
return "";
|
|
726
|
+
}
|
|
727
|
+
function dirName(directory) {
|
|
728
|
+
return directory && basename(directory) || "unknown";
|
|
729
|
+
}
|
|
730
|
+
function mainWorktreeRoot(directory, sessionRoot = "") {
|
|
731
|
+
const candidates = [
|
|
732
|
+
nearestExistingDir(directory),
|
|
733
|
+
sessionRoot,
|
|
734
|
+
...PROJECT_ROOT_ENV.map((v) => process.env[v] || "")
|
|
735
|
+
];
|
|
736
|
+
let failure = null;
|
|
737
|
+
for (const candidate of candidates) {
|
|
738
|
+
if (!candidate) continue;
|
|
739
|
+
const projectRoot = resolveProjectRoot(candidate);
|
|
740
|
+
if (projectRoot.status === "resolved") return projectRoot;
|
|
741
|
+
if (projectRoot.status === "failed") failure = projectRoot;
|
|
742
|
+
}
|
|
743
|
+
return failure ?? { status: "absent" };
|
|
744
|
+
}
|
|
745
|
+
function gitProjectName(directory, resolveWorktrees, sessionRoot = "") {
|
|
746
|
+
if (resolveWorktrees) {
|
|
747
|
+
const projectRoot = mainWorktreeRoot(directory, sessionRoot);
|
|
748
|
+
if (projectRoot.status === "resolved") return basename(projectRoot.root);
|
|
749
|
+
if (projectRoot.status === "failed") {
|
|
750
|
+
throw new BankResolutionError(
|
|
751
|
+
`git probe failed for ${directory} (${projectRoot.reason}) \u2014 refusing to guess a bank id`
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return dirName(sessionRoot || directory);
|
|
756
|
+
}
|
|
757
|
+
function configuredDir(dir) {
|
|
758
|
+
const expanded = dir === "~" || dir.startsWith("~/") ? join4(homedir2(), dir.slice(1)) : dir;
|
|
759
|
+
return normalize(expanded).replace(new RegExp(`\\${sep2}+$`), "");
|
|
760
|
+
}
|
|
761
|
+
function isWithin(directory, configured) {
|
|
762
|
+
return directory === configured || directory.startsWith(configured + sep2);
|
|
763
|
+
}
|
|
764
|
+
function isOptedIn(config, directory) {
|
|
765
|
+
try {
|
|
766
|
+
managedBank(config.routerHarness, directory);
|
|
767
|
+
return true;
|
|
768
|
+
} catch {
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
function mapLookup(map, directory) {
|
|
773
|
+
const cwd = normalize(directory);
|
|
774
|
+
let best;
|
|
775
|
+
for (const [dir, value] of Object.entries(map)) {
|
|
776
|
+
const p = configuredDir(dir);
|
|
777
|
+
if (isWithin(cwd, p)) {
|
|
778
|
+
if (!best || p.length > best.len) best = { len: p.length, value };
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return best?.value;
|
|
782
|
+
}
|
|
783
|
+
function lookupDirectories(config, directory, sessionRoot = "") {
|
|
784
|
+
const directories = [normalize(directory)];
|
|
785
|
+
if (config.resolveWorktrees ?? true) {
|
|
786
|
+
const projectRoot = mainWorktreeRoot(directory, sessionRoot);
|
|
787
|
+
const normalizedRoot = projectRoot.status === "resolved" ? normalize(projectRoot.root) : "";
|
|
788
|
+
if (normalizedRoot && normalizedRoot !== directories[0]) directories.push(normalizedRoot);
|
|
789
|
+
}
|
|
790
|
+
return directories;
|
|
791
|
+
}
|
|
792
|
+
function pathSection(config, paths, directory) {
|
|
793
|
+
if (!directory || !paths) return void 0;
|
|
794
|
+
return lookupDirectories(config, directory).map((candidate) => mapLookup(paths, candidate)).find((section) => section !== void 0);
|
|
795
|
+
}
|
|
796
|
+
function deriveBankId(config, directory, harness = "coding", sessionRoot) {
|
|
797
|
+
return managedBank(harness, directory);
|
|
798
|
+
}
|
|
799
|
+
function deriveBankIdOrSkip(config, directory, harness = "coding", sessionRoot) {
|
|
800
|
+
try {
|
|
801
|
+
return deriveBankId(config, directory, harness, sessionRoot);
|
|
802
|
+
} catch (error) {
|
|
803
|
+
if (!(error instanceof BankResolutionError)) throw error;
|
|
804
|
+
log.warn(harness, "bank unresolved: skipping (repository could not be identified)", {
|
|
805
|
+
directory,
|
|
806
|
+
error
|
|
807
|
+
});
|
|
808
|
+
diag(harness, "bank_unresolved", { directory, error });
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// src/core/config.ts
|
|
814
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
815
|
+
import { homedir as homedir3 } from "os";
|
|
816
|
+
import { join as join7 } from "path";
|
|
817
|
+
|
|
818
|
+
// src/core/seed.ts
|
|
819
|
+
import { spawn as realSpawn } from "child_process";
|
|
820
|
+
import { dirname as dirname4, join as join5 } from "path";
|
|
821
|
+
import { fileURLToPath } from "url";
|
|
822
|
+
var DEFAULT_SEED_LIMIT = 300;
|
|
823
|
+
|
|
824
|
+
// ../../shared/recall-item.ts
|
|
825
|
+
function recallItemText(item) {
|
|
826
|
+
const value = item.text ?? item.content;
|
|
827
|
+
return typeof value === "string" ? value : JSON.stringify(item);
|
|
828
|
+
}
|
|
829
|
+
function formatRecallItem(item) {
|
|
830
|
+
const type = typeof item.type === "string" ? ` [${item.type}]` : "";
|
|
831
|
+
const doc = typeof item.document_id === "string" ? ` [doc:${item.document_id}]` : "";
|
|
832
|
+
return `- ${recallItemText(item)}${type}${doc}`;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ../../shared/request-error.ts
|
|
836
|
+
function isAuthorizationError(error) {
|
|
837
|
+
const status = error?.statusCode;
|
|
838
|
+
return status === 401 || status === 403;
|
|
839
|
+
}
|
|
840
|
+
function isTransientRequestError(error) {
|
|
841
|
+
const status = error?.statusCode;
|
|
842
|
+
if (error instanceof DOMException) {
|
|
843
|
+
return ["AbortError", "NetworkError", "TimeoutError"].includes(error.name);
|
|
844
|
+
}
|
|
845
|
+
if (error instanceof TypeError) {
|
|
846
|
+
if (error.message === "fetch failed") return true;
|
|
847
|
+
const cause = error.cause;
|
|
848
|
+
return error.message === "terminated" && typeof cause?.code === "string" && [
|
|
849
|
+
"UND_ERR_SOCKET",
|
|
850
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
851
|
+
"UND_ERR_RES_CONTENT_LENGTH_MISMATCH",
|
|
852
|
+
"ECONNRESET",
|
|
853
|
+
"ETIMEDOUT",
|
|
854
|
+
"EPIPE"
|
|
855
|
+
].includes(cause.code);
|
|
856
|
+
}
|
|
857
|
+
return status === 408 || status === 429 || typeof status === "number" && status >= 500;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ../../shared/recall-coordinator.ts
|
|
861
|
+
var RecallAuthorizationError = class extends Error {
|
|
862
|
+
statusCode = 403;
|
|
863
|
+
bank;
|
|
864
|
+
constructor(bank) {
|
|
865
|
+
super(`recall authorization denied for bank ${bank}`);
|
|
866
|
+
this.name = "RecallAuthorizationError";
|
|
867
|
+
this.bank = bank;
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
function timeoutAfter(ms, controller) {
|
|
871
|
+
let rejectTimeout;
|
|
872
|
+
const promise = new Promise((_, reject) => {
|
|
873
|
+
rejectTimeout = reject;
|
|
874
|
+
});
|
|
875
|
+
const timer = setTimeout(() => {
|
|
876
|
+
controller.abort();
|
|
877
|
+
rejectTimeout(new DOMException(`recall timed out after ${ms}ms`, "TimeoutError"));
|
|
878
|
+
}, ms);
|
|
879
|
+
return { promise, timer };
|
|
880
|
+
}
|
|
881
|
+
function estimateTokens(item) {
|
|
882
|
+
const characters = Math.max(JSON.stringify({ results: [item] }, null, 2).length, formatRecallItem(item).length + 2);
|
|
883
|
+
return Math.max(1, Math.ceil(characters / 4));
|
|
884
|
+
}
|
|
885
|
+
function validateRecallResults(response) {
|
|
886
|
+
if (typeof response !== "object" || response === null || Array.isArray(response)) {
|
|
887
|
+
throw new RouterRequestError(502);
|
|
888
|
+
}
|
|
889
|
+
const results = "results" in response ? response.results : void 0;
|
|
890
|
+
if (results === void 0) return [];
|
|
891
|
+
if (!Array.isArray(results) || !results.every(
|
|
892
|
+
(item) => typeof item === "object" && item !== null && !Array.isArray(item)
|
|
893
|
+
)) {
|
|
894
|
+
throw new RouterRequestError(502);
|
|
895
|
+
}
|
|
896
|
+
return results;
|
|
897
|
+
}
|
|
898
|
+
function mergeSettledResults(settled, banks) {
|
|
899
|
+
const failedBanks = [];
|
|
900
|
+
const representatives = /* @__PURE__ */ new Map();
|
|
901
|
+
settled.forEach((outcome, index) => {
|
|
902
|
+
const bank = banks[index];
|
|
903
|
+
if (outcome.status === "rejected") {
|
|
904
|
+
if (isAuthorizationError(outcome.reason)) throw new RecallAuthorizationError(bank);
|
|
905
|
+
if (!isTransientRequestError(outcome.reason)) throw outcome.reason;
|
|
906
|
+
failedBanks.push(bank);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
for (const item of outcome.value.results) {
|
|
910
|
+
const key = recallItemText(item);
|
|
911
|
+
const candidate = { bank, item };
|
|
912
|
+
const previous = representatives.get(key);
|
|
913
|
+
if (!previous || compareBankItems(candidate, previous) < 0) {
|
|
914
|
+
representatives.set(key, candidate);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
return { merged: [...representatives.values()], failedBanks };
|
|
919
|
+
}
|
|
920
|
+
function compareBankItems(a, b) {
|
|
921
|
+
const scoreA = typeof a.item.score === "number" ? a.item.score : Number.NEGATIVE_INFINITY;
|
|
922
|
+
const scoreB = typeof b.item.score === "number" ? b.item.score : Number.NEGATIVE_INFINITY;
|
|
923
|
+
if (scoreA !== scoreB) return scoreB - scoreA;
|
|
924
|
+
if (a.bank !== b.bank) return a.bank < b.bank ? -1 : 1;
|
|
925
|
+
const contentA = recallItemText(a.item);
|
|
926
|
+
const contentB = recallItemText(b.item);
|
|
927
|
+
if (contentA === contentB) return 0;
|
|
928
|
+
return contentA < contentB ? -1 : 1;
|
|
929
|
+
}
|
|
930
|
+
function trimToTokenBudget(items, maxTokens) {
|
|
931
|
+
if (maxTokens === void 0) return items;
|
|
932
|
+
const kept = [];
|
|
933
|
+
let spent = 0;
|
|
934
|
+
for (const item of items) {
|
|
935
|
+
const tokens = estimateTokens(item);
|
|
936
|
+
if (spent + tokens <= maxTokens) {
|
|
937
|
+
spent += tokens;
|
|
938
|
+
kept.push(item);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return kept;
|
|
942
|
+
}
|
|
943
|
+
var RecallCoordinator = class {
|
|
944
|
+
async recall(client2, request) {
|
|
945
|
+
const banks = request.banks;
|
|
946
|
+
if (banks.length === 0) {
|
|
947
|
+
return { results: [], partial: false, failedBanks: [] };
|
|
948
|
+
}
|
|
949
|
+
if (!Number.isSafeInteger(request.timeoutMs) || request.timeoutMs <= 0) {
|
|
950
|
+
throw new RangeError("recall timeout must be a positive integer");
|
|
951
|
+
}
|
|
952
|
+
if (request.maxTokens !== void 0 && (!Number.isSafeInteger(request.maxTokens) || request.maxTokens <= 0)) {
|
|
953
|
+
throw new RangeError("recall token budget must be a positive integer");
|
|
954
|
+
}
|
|
955
|
+
const deadline = Date.now() + request.timeoutMs;
|
|
956
|
+
const perBankTokens = request.maxTokens ? Math.max(1, Math.floor(request.maxTokens / banks.length)) : void 0;
|
|
957
|
+
const settled = await Promise.allSettled(
|
|
958
|
+
banks.map(async (bank) => {
|
|
959
|
+
const remaining = deadline - Date.now();
|
|
960
|
+
if (remaining <= 0) {
|
|
961
|
+
throw new DOMException(`recall timed out after ${request.timeoutMs}ms`, "TimeoutError");
|
|
962
|
+
}
|
|
963
|
+
const controller = new AbortController();
|
|
964
|
+
const call = client2.recall(bank, request.query, {
|
|
965
|
+
maxTokens: perBankTokens,
|
|
966
|
+
budget: request.budget,
|
|
967
|
+
types: request.types,
|
|
968
|
+
preferObservations: request.preferObservations,
|
|
969
|
+
signal: controller.signal
|
|
970
|
+
});
|
|
971
|
+
const timeout = timeoutAfter(remaining, controller);
|
|
972
|
+
try {
|
|
973
|
+
const response = await Promise.race([call, timeout.promise]);
|
|
974
|
+
return { bank, results: validateRecallResults(response) };
|
|
975
|
+
} finally {
|
|
976
|
+
clearTimeout(timeout.timer);
|
|
977
|
+
}
|
|
978
|
+
})
|
|
979
|
+
);
|
|
980
|
+
const { merged, failedBanks } = mergeSettledResults(settled, banks);
|
|
981
|
+
merged.sort(compareBankItems);
|
|
982
|
+
const results = trimToTokenBudget(
|
|
983
|
+
merged.map((entry) => entry.item),
|
|
984
|
+
request.maxTokens
|
|
985
|
+
);
|
|
986
|
+
return { results, partial: failedBanks.length > 0, failedBanks };
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
// ../../shared/read-execution.ts
|
|
991
|
+
async function readAcrossBanks(transport, operation, body, options) {
|
|
992
|
+
const suffix = operation === "recall" ? "/memories/recall" : "/reflect";
|
|
993
|
+
const query = typeof body.query === "string" ? body.query : "";
|
|
994
|
+
return new RecallCoordinator().recall(
|
|
995
|
+
{
|
|
996
|
+
retain: async () => {
|
|
997
|
+
throw new Error("read-only execution");
|
|
998
|
+
},
|
|
999
|
+
async recall(bank, _query, request) {
|
|
1000
|
+
const response = await transport.request(transport.bankUrl(bank, suffix), {
|
|
1001
|
+
method: "POST",
|
|
1002
|
+
signal: options.signal && request?.signal ? AbortSignal.any([options.signal, request.signal]) : options.signal ?? request?.signal,
|
|
1003
|
+
body: JSON.stringify({ ...body, max_tokens: request?.maxTokens })
|
|
1004
|
+
});
|
|
1005
|
+
const data = await response.json();
|
|
1006
|
+
if (operation === "reflect") {
|
|
1007
|
+
return { results: data.text ? [{ text: data.text }] : [] };
|
|
1008
|
+
}
|
|
1009
|
+
return { results: data.results };
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
banks: visibleBanks(transport.access),
|
|
1014
|
+
query,
|
|
1015
|
+
timeoutMs: options.timeoutMs,
|
|
1016
|
+
maxTokens: options.maxTokens
|
|
1017
|
+
}
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// src/core/missions.ts
|
|
1022
|
+
import { createHash as createHash3 } from "crypto";
|
|
1023
|
+
var RETAIN_EXTRACTION_MODES = ["concise", "verbose", "verbatim", "chunks"];
|
|
1024
|
+
var DEFAULT_RETAIN_EXTRACTION_MODE = "concise";
|
|
1025
|
+
function pageScopeRule(project) {
|
|
1026
|
+
if (project === void 0)
|
|
1027
|
+
return ` This bank is shared by several projects: attribute each thing you include to the repository or project its sources name, and group by project where that helps. Leave out facts about external tools, libraries and services those projects merely use, configure, deploy or discuss \u2014 they belong to somebody else's codebase \u2014 including any commit SHA or identifier from such a dependency's repository.`;
|
|
1028
|
+
return ` Scope this page to ${project} ITSELF: the bank also holds facts about external tools, libraries and services that ${project} merely uses, configures, deploys or discusses, and those belong to somebody else's codebase. Include something only when its subject is ${project}'s own code, configuration or process; when it is about a dependency, leave it out however well-evidenced it looks \u2014 including any commit SHA or identifier that belongs to that dependency's repository rather than this one.`;
|
|
1029
|
+
}
|
|
1030
|
+
var PAGE_TAXONOMY = [
|
|
1031
|
+
{
|
|
1032
|
+
name: "Component map",
|
|
1033
|
+
source_query: "From this project's commit history and past discussions, what are the main components/modules/subsystems, what is each responsible for, and how do they relate to or depend on one another? Describe the structure and responsibilities.",
|
|
1034
|
+
tags: ["knowledge:component"]
|
|
1035
|
+
},
|
|
1036
|
+
{
|
|
1037
|
+
name: "Core concepts",
|
|
1038
|
+
source_query: "What are the core concepts, domain abstractions, and key entities in this project \u2014 the vocabulary a developer must understand? For each, explain what it represents and its role, drawn from how they are introduced and discussed across the history and conversations.",
|
|
1039
|
+
tags: ["knowledge:concept"]
|
|
1040
|
+
},
|
|
1041
|
+
{
|
|
1042
|
+
name: "Conventions and patterns",
|
|
1043
|
+
source_query: "What conventions, idioms, and recurring patterns does this project follow \u2014 its approach to testing, error handling, naming, structure, and how changes are typically made? Describe how THIS project does things, as evidenced across its history and discussions.",
|
|
1044
|
+
tags: ["knowledge:convention"]
|
|
1045
|
+
},
|
|
1046
|
+
{
|
|
1047
|
+
name: "Key decisions and rationale",
|
|
1048
|
+
source_query: "What are the significant technical decisions made in this project and the rationale behind them \u2014 the durable 'why we do it this way' a developer should know? Summarize the decisions and their reasoning from the commit rationales and past conversations.",
|
|
1049
|
+
tags: ["knowledge:decision"]
|
|
1050
|
+
},
|
|
1051
|
+
{
|
|
1052
|
+
name: "Initiatives and enhancements",
|
|
1053
|
+
source_query: "Based on this repository's commit history, what are the major initiatives, features, and enhancements the project has worked on? Summarize the themes and notable changes over time. When a source memory's context carries a `[[page:<id>]]` link, repeat that link in the summary of what it describes, so each initiative links to its detailed page.",
|
|
1054
|
+
tags: ["knowledge:feature-work"]
|
|
1055
|
+
}
|
|
1056
|
+
];
|
|
1057
|
+
var PAGE_NAMES = PAGE_TAXONOMY.map((page) => page.name);
|
|
1058
|
+
function pagesFor(project, pages = {}, customPages = {}) {
|
|
1059
|
+
const scope = pageScopeRule(project);
|
|
1060
|
+
const byName = new Map(
|
|
1061
|
+
Object.entries(pages).map(([name, override]) => [name.trim().toLowerCase(), override])
|
|
1062
|
+
);
|
|
1063
|
+
const out = [];
|
|
1064
|
+
for (const page of PAGE_TAXONOMY) {
|
|
1065
|
+
const override = byName.get(page.name.toLowerCase());
|
|
1066
|
+
if (override === false) continue;
|
|
1067
|
+
out.push({ ...page, source_query: (override?.source_query || page.source_query) + scope });
|
|
1068
|
+
}
|
|
1069
|
+
for (const [name, page] of Object.entries(customPages)) {
|
|
1070
|
+
out.push({ name: name.trim(), source_query: page.source_query + scope, tags: page.tags ?? [] });
|
|
1071
|
+
}
|
|
1072
|
+
return out;
|
|
1073
|
+
}
|
|
1074
|
+
var PAGE_MAX_TOKENS = 4096;
|
|
1075
|
+
var PAGE_TAGS_MATCH = "all";
|
|
1076
|
+
var PAGE_FACT_TYPES = ["world", "experience", "observation"];
|
|
1077
|
+
var DEFAULT_PAGE_TRIGGER_CRON = "H * * * *";
|
|
1078
|
+
var CRON_FIELD_RANGES = [
|
|
1079
|
+
[0, 59],
|
|
1080
|
+
// minute
|
|
1081
|
+
[0, 23],
|
|
1082
|
+
// hour
|
|
1083
|
+
[1, 31],
|
|
1084
|
+
// day of month
|
|
1085
|
+
[1, 12],
|
|
1086
|
+
// month
|
|
1087
|
+
[0, 6]
|
|
1088
|
+
// day of week
|
|
1089
|
+
];
|
|
1090
|
+
var HASHED_FIELD = /^H(?:\((\d+)-(\d+)\))?$/;
|
|
1091
|
+
function isHashedCron(cron) {
|
|
1092
|
+
return /(^|\s)H/.test(cron);
|
|
1093
|
+
}
|
|
1094
|
+
function parseHashedCron(cron) {
|
|
1095
|
+
const fields = cron.trim().split(/\s+/);
|
|
1096
|
+
if (fields.length !== CRON_FIELD_RANGES.length) return void 0;
|
|
1097
|
+
for (const [i, field] of fields.entries()) {
|
|
1098
|
+
if (!field.startsWith("H")) continue;
|
|
1099
|
+
const m = HASHED_FIELD.exec(field);
|
|
1100
|
+
if (!m) return void 0;
|
|
1101
|
+
if (m[1] === void 0) continue;
|
|
1102
|
+
const [lo, hi] = [Number(m[1]), Number(m[2])];
|
|
1103
|
+
const [min, max] = CRON_FIELD_RANGES[i];
|
|
1104
|
+
if (lo > hi || lo < min || hi > max) return void 0;
|
|
1105
|
+
}
|
|
1106
|
+
return fields;
|
|
1107
|
+
}
|
|
1108
|
+
function hashedValue(seed, field, lo, hi) {
|
|
1109
|
+
const digest = createHash3("sha256").update(`${seed}\0${field}`).digest();
|
|
1110
|
+
return lo + digest.readUInt32BE(0) % (hi - lo + 1);
|
|
1111
|
+
}
|
|
1112
|
+
function expandCronHash(cron, seed) {
|
|
1113
|
+
if (!isHashedCron(cron)) return cron;
|
|
1114
|
+
const fields = parseHashedCron(cron);
|
|
1115
|
+
if (!fields) return cron;
|
|
1116
|
+
return fields.map((field, i) => {
|
|
1117
|
+
const m = HASHED_FIELD.exec(field);
|
|
1118
|
+
if (!m) return field;
|
|
1119
|
+
const [lo, hi] = m[1] === void 0 ? CRON_FIELD_RANGES[i] : [Number(m[1]), Number(m[2])];
|
|
1120
|
+
return String(hashedValue(seed, i, lo, hi));
|
|
1121
|
+
}).join(" ");
|
|
1122
|
+
}
|
|
1123
|
+
function pageTriggerFor(trigger, bank, page) {
|
|
1124
|
+
const cron = trigger.refresh_cron;
|
|
1125
|
+
if (!cron || !isHashedCron(cron)) return trigger;
|
|
1126
|
+
return { ...trigger, refresh_cron: expandCronHash(cron, `${bank}\0${page}`) };
|
|
1127
|
+
}
|
|
1128
|
+
function buildPageTrigger(cfg2 = {}) {
|
|
1129
|
+
const base = { fact_types: PAGE_FACT_TYPES, tags_match: PAGE_TAGS_MATCH };
|
|
1130
|
+
switch (cfg2.pageTriggerType) {
|
|
1131
|
+
case "auto-refresh":
|
|
1132
|
+
return { ...base, refresh_after_consolidation: true };
|
|
1133
|
+
case "manual":
|
|
1134
|
+
return { ...base, refresh_after_consolidation: false };
|
|
1135
|
+
// "cron" and an unset type alike: the default schedule stands in for a missing expression, so
|
|
1136
|
+
// a trigger built from a partial config is never a cron trigger with nothing to fire on.
|
|
1137
|
+
default:
|
|
1138
|
+
return { ...base, refresh_cron: cfg2.pageTriggerCron || DEFAULT_PAGE_TRIGGER_CRON };
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
function pageTriggerDrifted(current2, desired) {
|
|
1142
|
+
return current2.tags_match !== desired.tags_match || (current2.refresh_cron ?? null) !== (desired.refresh_cron ?? null) || Boolean(current2.refresh_after_consolidation) !== Boolean(desired.refresh_after_consolidation);
|
|
1143
|
+
}
|
|
1144
|
+
function pageTriggerPatch(desired) {
|
|
1145
|
+
if (desired.refresh_after_consolidation === false) return { ...desired, refresh_cron: null };
|
|
1146
|
+
return desired;
|
|
1147
|
+
}
|
|
1148
|
+
var PLUGIN_GOVERNED_BANK_FIELDS = [
|
|
1149
|
+
"retain_strategies",
|
|
1150
|
+
"entity_labels",
|
|
1151
|
+
"retain_extraction_mode"
|
|
1152
|
+
];
|
|
1153
|
+
|
|
1154
|
+
// package.json
|
|
1155
|
+
var version = "0.8.7";
|
|
1156
|
+
|
|
1157
|
+
// src/core/util.ts
|
|
1158
|
+
import { accessSync, constants } from "fs";
|
|
1159
|
+
import { delimiter, join as join6 } from "path";
|
|
1160
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1161
|
+
function semverGte(version2, min) {
|
|
1162
|
+
const parts = (v) => {
|
|
1163
|
+
const m = v.trim().match(/^(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
1164
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)] : void 0;
|
|
1165
|
+
};
|
|
1166
|
+
const a = version2 ? parts(version2) : void 0;
|
|
1167
|
+
const b = parts(min);
|
|
1168
|
+
if (!a || !b) return false;
|
|
1169
|
+
for (let i = 0; i < 3; i++) {
|
|
1170
|
+
if (a[i] !== b[i]) return a[i] > b[i];
|
|
1171
|
+
}
|
|
1172
|
+
return true;
|
|
1173
|
+
}
|
|
1174
|
+
async function pool(items, n, fn, onError, onProgress) {
|
|
1175
|
+
let i = 0, done = 0;
|
|
1176
|
+
async function worker() {
|
|
1177
|
+
while (i < items.length) {
|
|
1178
|
+
const idx = i++;
|
|
1179
|
+
try {
|
|
1180
|
+
await fn(items[idx], idx);
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
onError?.(idx, e);
|
|
1183
|
+
}
|
|
1184
|
+
onProgress?.(++done, items.length);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// src/core/hindsight.ts
|
|
1191
|
+
var USER_AGENT = `hindsight-coding-agents/${version}`;
|
|
1192
|
+
function resolveRetainScopes(tags, configured) {
|
|
1193
|
+
if (configured !== "per_source") return configured;
|
|
1194
|
+
const sources = [...new Set((tags ?? []).filter((t) => t.startsWith("source:")))].sort((a, b) => a.localeCompare(b));
|
|
1195
|
+
return [[], ...sources.map((s) => [s])];
|
|
1196
|
+
}
|
|
1197
|
+
var DEFAULT_OBSERVATION_SCOPES = "shared";
|
|
1198
|
+
var MIN_IDEMPOTENT_RETAIN_VERSION = "0.8.6";
|
|
1199
|
+
var RateLimitedError = class extends Error {
|
|
1200
|
+
constructor(retryAfterMs2) {
|
|
1201
|
+
super(`rate limited; retry after ${Math.round(retryAfterMs2 / 1e3)}s`);
|
|
1202
|
+
this.retryAfterMs = retryAfterMs2;
|
|
1203
|
+
this.name = "RateLimitedError";
|
|
1204
|
+
}
|
|
1205
|
+
retryAfterMs;
|
|
1206
|
+
code = "rate_limited";
|
|
1207
|
+
};
|
|
1208
|
+
function retryAfterMs(header) {
|
|
1209
|
+
return boundedRetryAfterMs(header);
|
|
1210
|
+
}
|
|
1211
|
+
var KnowledgePagesUnavailableError = class extends Error {
|
|
1212
|
+
code = "knowledge_pages_unavailable";
|
|
1213
|
+
constructor() {
|
|
1214
|
+
super("Hindsight server does not support knowledge pages");
|
|
1215
|
+
this.name = "KnowledgePagesUnavailableError";
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
async function isEndpointMissing(r) {
|
|
1219
|
+
try {
|
|
1220
|
+
const j = await r.json();
|
|
1221
|
+
return String(j?.detail ?? "").trim().toLowerCase() === "not found";
|
|
1222
|
+
} catch {
|
|
1223
|
+
return false;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "cancelled", "error"]);
|
|
1227
|
+
var ReflectError = class extends Error {
|
|
1228
|
+
constructor(message, status, timedOut, options) {
|
|
1229
|
+
super(message, options);
|
|
1230
|
+
this.status = status;
|
|
1231
|
+
this.timedOut = timedOut;
|
|
1232
|
+
this.name = "ReflectError";
|
|
1233
|
+
}
|
|
1234
|
+
status;
|
|
1235
|
+
timedOut;
|
|
1236
|
+
/** A timeout or a 5xx means reflect's synthesis (the slow LLM path) broke, while the cheap
|
|
1237
|
+
* retrieval endpoints may still answer — worth falling back. A 4xx will fail the same way on
|
|
1238
|
+
* every endpoint (auth, missing bank), so it is not. */
|
|
1239
|
+
get fallbackEligible() {
|
|
1240
|
+
return this.timedOut || this.status !== void 0 && this.status >= 500;
|
|
1241
|
+
}
|
|
1242
|
+
/** A 4xx the next attempt would get again (bad key, missing bank, rejected body) — retrying it
|
|
1243
|
+
* only spends another doomed call (#5207). 408/429 are "not now", so they stay retryable. */
|
|
1244
|
+
get permanent() {
|
|
1245
|
+
const s = this.status;
|
|
1246
|
+
return s !== void 0 && s >= 400 && s < 500 && s !== 408 && s !== 429;
|
|
1247
|
+
}
|
|
1248
|
+
};
|
|
1249
|
+
var DEFAULT_MAX_PARALLEL_RETAINS = 10;
|
|
1250
|
+
var DEFAULT_PAGE_SEARCH_LIMIT = 10;
|
|
1251
|
+
var DEFAULT_RECALL_OPTIONS = {
|
|
1252
|
+
types: ["observation"],
|
|
1253
|
+
budget: "low",
|
|
1254
|
+
max_tokens: 2e3,
|
|
1255
|
+
include: { entities: null }
|
|
1256
|
+
};
|
|
1257
|
+
var POLL_CYCLE_MS = 5e3;
|
|
1258
|
+
var POLL_MAX_SERVER_ERRORS = 12;
|
|
1259
|
+
var RETRY_AFTER_FLOOR_MS = 10 * 1e3;
|
|
1260
|
+
var RETRY_AFTER_CEILING_MS = 60 * 1e3;
|
|
1261
|
+
var RATE_LIMIT_BACKOFF_MS = 1e3;
|
|
1262
|
+
function shapePage(page) {
|
|
1263
|
+
const p = page ?? {};
|
|
1264
|
+
const body = typeof p.body === "string" && p.body.trim() ? p.body : p.markdown;
|
|
1265
|
+
return {
|
|
1266
|
+
id: p.id,
|
|
1267
|
+
name: p.name,
|
|
1268
|
+
...p.description ? { description: p.description } : {},
|
|
1269
|
+
...Array.isArray(p.tags) && p.tags.length ? { tags: p.tags } : {},
|
|
1270
|
+
...p.timestamp ? { last_updated_at: p.timestamp } : {},
|
|
1271
|
+
body
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
var HindsightClient = class _HindsightClient {
|
|
1275
|
+
transport;
|
|
1276
|
+
routerHarness;
|
|
1277
|
+
apiUrl;
|
|
1278
|
+
bank;
|
|
1279
|
+
project;
|
|
1280
|
+
opIds = [];
|
|
1281
|
+
// async operation ids collected by retain(), for drain()
|
|
1282
|
+
/** Tri-state capability probe: unknown until the first page request, then cached. */
|
|
1283
|
+
knowledgePagesSupported;
|
|
1284
|
+
/** Tri-state capability probe: unknown until the first append-mode retain, then cached. */
|
|
1285
|
+
idempotentRetain;
|
|
1286
|
+
log;
|
|
1287
|
+
maxParallelRetains;
|
|
1288
|
+
observationScopes;
|
|
1289
|
+
pageSearchLimit;
|
|
1290
|
+
recallOptions;
|
|
1291
|
+
rateLimitPatienceMs;
|
|
1292
|
+
constructor(o, parent) {
|
|
1293
|
+
this.routerHarness = parent?.routerHarness ?? o.routerHarness;
|
|
1294
|
+
this.transport = parent?.transport ?? harnessTransport(o.routerHarness);
|
|
1295
|
+
this.apiUrl = this.transport.baseUrl;
|
|
1296
|
+
if (o.apiUrl.replace(/\/+$/, "") !== this.apiUrl) throw new AccessDeniedError();
|
|
1297
|
+
this.transport.bankUrl(o.bank);
|
|
1298
|
+
this.bank = o.bank;
|
|
1299
|
+
this.project = o.project;
|
|
1300
|
+
this.log = o.log ?? (() => {
|
|
1301
|
+
});
|
|
1302
|
+
this.maxParallelRetains = o.maxParallelRetains || DEFAULT_MAX_PARALLEL_RETAINS;
|
|
1303
|
+
this.observationScopes = o.observationScopes ?? DEFAULT_OBSERVATION_SCOPES;
|
|
1304
|
+
this.pageSearchLimit = o.pageSearchLimit || DEFAULT_PAGE_SEARCH_LIMIT;
|
|
1305
|
+
this.recallOptions = { ...DEFAULT_RECALL_OPTIONS, ...o.recallOptions };
|
|
1306
|
+
this.rateLimitPatienceMs = o.rateLimitPatienceMs ?? 0;
|
|
1307
|
+
}
|
|
1308
|
+
assertAuthorized() {
|
|
1309
|
+
this.transport.assertAuthorized();
|
|
1310
|
+
}
|
|
1311
|
+
visibleBanks() {
|
|
1312
|
+
return visibleBanks(this.transport.access);
|
|
1313
|
+
}
|
|
1314
|
+
readScope() {
|
|
1315
|
+
return { identity: this.transport.authorizationIdentity(), banks: this.visibleBanks().sort(), endpoint: this.apiUrl, bankId: this.bank };
|
|
1316
|
+
}
|
|
1317
|
+
forBank(bank) {
|
|
1318
|
+
this.assertAuthorized();
|
|
1319
|
+
this.transport.bankUrl(bank);
|
|
1320
|
+
return new _HindsightClient({
|
|
1321
|
+
routerHarness: this.routerHarness,
|
|
1322
|
+
apiUrl: this.apiUrl,
|
|
1323
|
+
bank,
|
|
1324
|
+
project: this.project,
|
|
1325
|
+
maxParallelRetains: this.maxParallelRetains,
|
|
1326
|
+
observationScopes: this.observationScopes,
|
|
1327
|
+
pageSearchLimit: this.pageSearchLimit,
|
|
1328
|
+
recallOptions: this.recallOptions,
|
|
1329
|
+
log: this.log
|
|
1330
|
+
}, this);
|
|
1331
|
+
}
|
|
1332
|
+
get apiToken() {
|
|
1333
|
+
return void 0;
|
|
1334
|
+
}
|
|
1335
|
+
hasCredentials() {
|
|
1336
|
+
return this.transport.hasCredentials();
|
|
1337
|
+
}
|
|
1338
|
+
async fetchWithAuth(url, init, timeoutMs = 15e3) {
|
|
1339
|
+
if (url === this.bankUrl("/memories/recall")) {
|
|
1340
|
+
const body = JSON.parse(String(init.body));
|
|
1341
|
+
const result = await readAcrossBanks(this.transport, "recall", body, { timeoutMs, maxTokens: 4096, signal: init.signal ?? void 0 });
|
|
1342
|
+
if (result.partial) this.log("partial memory read: assigned bank unavailable");
|
|
1343
|
+
return Response.json({ results: result.results });
|
|
1344
|
+
}
|
|
1345
|
+
return this.transport.request(url, init);
|
|
1346
|
+
}
|
|
1347
|
+
bankUrl(suffix = "") {
|
|
1348
|
+
return `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bank)}${suffix}`;
|
|
1349
|
+
}
|
|
1350
|
+
/** `tolerate` adds statuses that are returned to the caller instead of thrown (404 always is). */
|
|
1351
|
+
async req(method, url, body, tolerate = [], timeoutMs = 15e3) {
|
|
1352
|
+
const payload = body ? JSON.stringify(body) : void 0;
|
|
1353
|
+
let r;
|
|
1354
|
+
for (let attempt = 0, waited = 0; ; attempt++) {
|
|
1355
|
+
try {
|
|
1356
|
+
r = await this.fetchWithAuth(url, {
|
|
1357
|
+
method,
|
|
1358
|
+
body: payload,
|
|
1359
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
1360
|
+
}, timeoutMs);
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
if (!(error instanceof RouterRequestError) || error.statusCode !== 404 && error.statusCode !== 429 && !tolerate.includes(error.statusCode)) throw error;
|
|
1363
|
+
r = new Response(null, {
|
|
1364
|
+
status: error.statusCode,
|
|
1365
|
+
...error.statusCode === 429 ? { headers: { "retry-after": String(error.retryAfterMs / 1e3) } } : {}
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
if (r.status !== 429 || tolerate.includes(429)) break;
|
|
1369
|
+
const asked = retryAfterMs(r.headers.get("retry-after"));
|
|
1370
|
+
const backoff = Math.min(RETRY_AFTER_CEILING_MS, RATE_LIMIT_BACKOFF_MS * 2 ** attempt);
|
|
1371
|
+
const wait = Math.min(RETRY_AFTER_CEILING_MS, Math.max(asked, backoff)) * (1 + Math.random() / 2);
|
|
1372
|
+
if (waited + wait > this.rateLimitPatienceMs) throw new RateLimitedError(asked);
|
|
1373
|
+
await sleep(wait);
|
|
1374
|
+
waited += wait;
|
|
1375
|
+
}
|
|
1376
|
+
if (!r.ok && r.status !== 404 && !tolerate.includes(r.status))
|
|
1377
|
+
throw new Error(
|
|
1378
|
+
`memory request failed (${r.status})`
|
|
1379
|
+
);
|
|
1380
|
+
return r;
|
|
1381
|
+
}
|
|
1382
|
+
/** Retain one memory. ALWAYS async: enqueue extraction server-side and collect its op-id for
|
|
1383
|
+
* drain(). Nothing in this plugin can afford to block a coding agent's hook on extraction. */
|
|
1384
|
+
async retain(content, context, documentId, tags, strategy, opts = {}) {
|
|
1385
|
+
const item = {
|
|
1386
|
+
content,
|
|
1387
|
+
context,
|
|
1388
|
+
document_id: documentId,
|
|
1389
|
+
tags,
|
|
1390
|
+
strategy,
|
|
1391
|
+
// Sent on EVERY retain, including the server default `combined`, so the scoping a bank's
|
|
1392
|
+
// observations were built under is a property of the write rather than of whichever server
|
|
1393
|
+
// version happened to process it. Servers older than 0.4.15 ignore the field.
|
|
1394
|
+
observation_scopes: resolveRetainScopes(tags, this.observationScopes)
|
|
1395
|
+
};
|
|
1396
|
+
if (opts.timestamp) item.timestamp = opts.timestamp;
|
|
1397
|
+
if (opts.metadata) item.metadata = opts.metadata;
|
|
1398
|
+
if (opts.updateMode) item.update_mode = opts.updateMode;
|
|
1399
|
+
const body = { items: [item], async: true };
|
|
1400
|
+
if (opts.operationId) body.operation_id = opts.operationId;
|
|
1401
|
+
const r = await this.req("POST", this.bankUrl("/memories"), body);
|
|
1402
|
+
try {
|
|
1403
|
+
const j = await r.json();
|
|
1404
|
+
if (j.operation_id) this.opIds.push(j.operation_id);
|
|
1405
|
+
} catch {
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Whether this server honours `operation_id` on an async retain, and can therefore be appended to
|
|
1410
|
+
* safely (see MIN_IDEMPOTENT_RETAIN_VERSION). Probed once per client via the version endpoint; anything
|
|
1411
|
+
* unreachable, unparseable or older answers "no", which costs efficiency, never correctness.
|
|
1412
|
+
*/
|
|
1413
|
+
async supportsIdempotentRetain() {
|
|
1414
|
+
if (this.idempotentRetain === void 0) {
|
|
1415
|
+
this.idempotentRetain = await this.probeIdempotentRetain();
|
|
1416
|
+
this.log(`server retain idempotency: ${this.idempotentRetain ? "supported" : "unavailable"}`);
|
|
1417
|
+
}
|
|
1418
|
+
return this.idempotentRetain;
|
|
1419
|
+
}
|
|
1420
|
+
/** `/v1/version` first: an ingress that routes only `/v1` to the API (the Helm chart's default)
|
|
1421
|
+
* hands a bare `/version` to the control plane, whose HTML 404 or login redirect read as "no",
|
|
1422
|
+
* so append silently never turned on (#5286). Servers that predate the alias still answer
|
|
1423
|
+
* `/version`, so that stays as the fallback. */
|
|
1424
|
+
async probeIdempotentRetain() {
|
|
1425
|
+
for (const path of ["/v1/version", "/version"]) {
|
|
1426
|
+
try {
|
|
1427
|
+
const r = await this.req("GET", `${this.apiUrl}${path}`);
|
|
1428
|
+
if (!r.ok) continue;
|
|
1429
|
+
const j = await r.json();
|
|
1430
|
+
return semverGte(j.api_version, MIN_IDEMPOTENT_RETAIN_VERSION);
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
if (error instanceof AccessDeniedError) throw error;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
return false;
|
|
1436
|
+
}
|
|
1437
|
+
/**
|
|
1438
|
+
* Whether a session write-back may use `update_mode="append"`: the server must dedupe by
|
|
1439
|
+
* `operation_id` AND the bank must keep document text, or the server rejects the append in the
|
|
1440
|
+
* background (#4613). Only an explicit `store_document_text: false` answers "no" — an unreachable
|
|
1441
|
+
* or unparseable config assumes the default (stored), so a flaky probe never downgrades appends.
|
|
1442
|
+
*/
|
|
1443
|
+
async supportsAppendRetain() {
|
|
1444
|
+
if (!await this.supportsIdempotentRetain()) return false;
|
|
1445
|
+
try {
|
|
1446
|
+
const r = await this.req("GET", this.bankUrl("/config"));
|
|
1447
|
+
if (!r.ok) return true;
|
|
1448
|
+
const j = await r.json();
|
|
1449
|
+
return j.config?.store_document_text !== false;
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
if (error instanceof AccessDeniedError) throw error;
|
|
1452
|
+
return true;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Every document_id currently in the bank under a strategy tag (e.g. `source:git`), paginated into a
|
|
1457
|
+
* Set. Powers the incremental git-sync's "what's already ingested?" check — since git commits are stored
|
|
1458
|
+
* with document_id `git:<sha>`, the returned Set lets a caller diff a ref's commits against memory.
|
|
1459
|
+
*/
|
|
1460
|
+
async listDocumentIds(tag, tagsMatch = "all") {
|
|
1461
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1462
|
+
const limit = 500;
|
|
1463
|
+
for (let offset = 0; ; offset += limit) {
|
|
1464
|
+
const q = `?tags=${encodeURIComponent(tag)}&tags_match=${tagsMatch}&limit=${limit}&offset=${offset}`;
|
|
1465
|
+
const r = await this.req("GET", this.bankUrl(`/documents${q}`));
|
|
1466
|
+
let items = [];
|
|
1467
|
+
let total = 0;
|
|
1468
|
+
try {
|
|
1469
|
+
const j = await r.json();
|
|
1470
|
+
items = j.items || [];
|
|
1471
|
+
total = j.total ?? 0;
|
|
1472
|
+
} catch {
|
|
1473
|
+
break;
|
|
1474
|
+
}
|
|
1475
|
+
for (const it of items) if (it.id) ids.add(it.id);
|
|
1476
|
+
if (items.length < limit || ids.size >= total) break;
|
|
1477
|
+
}
|
|
1478
|
+
return ids;
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* The tags on one document, or undefined when the bank holds none with that id. Read from the
|
|
1482
|
+
* document LISTING narrowed by `q` (a substring match on the id, so the exact id is picked out of
|
|
1483
|
+
* the page) rather than GET /documents/{id}, which also sends the document's full text: the
|
|
1484
|
+
* caller, the git-log freshness check on SessionStart, reads a ~100k-character document for one tag.
|
|
1485
|
+
*/
|
|
1486
|
+
async documentTags(documentId) {
|
|
1487
|
+
const limit = 100;
|
|
1488
|
+
for (let offset = 0; ; offset += limit) {
|
|
1489
|
+
const q = `?q=${encodeURIComponent(documentId)}&limit=${limit}&offset=${offset}`;
|
|
1490
|
+
const r = await this.req("GET", this.bankUrl(`/documents${q}`));
|
|
1491
|
+
const j = await r.json();
|
|
1492
|
+
const items = j.items ?? [];
|
|
1493
|
+
const hit = items.find((it) => it.id === documentId);
|
|
1494
|
+
if (hit) return hit.tags ?? [];
|
|
1495
|
+
if (items.length < limit || offset + limit >= (j.total ?? 0)) return void 0;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
/** Whether the bank holds any document at all — a one-row listing. A bank that does not exist
|
|
1499
|
+
* yet (404) holds none; any other failure throws, so a caller can tell "empty" from "unknown". */
|
|
1500
|
+
async hasDocuments() {
|
|
1501
|
+
const r = await this.req("GET", this.bankUrl("/documents?limit=1"));
|
|
1502
|
+
if (r.status === 404) return false;
|
|
1503
|
+
const j = await r.json();
|
|
1504
|
+
return (j.total ?? j.items?.length ?? 0) > 0;
|
|
1505
|
+
}
|
|
1506
|
+
/** Seed knowledge pages for an operator-provisioned bank. Bank creation, reset and
|
|
1507
|
+
* configuration belong to the router operator; the client only maintains pages. */
|
|
1508
|
+
async configureBank(opts = {}) {
|
|
1509
|
+
if (opts.reset || opts.manage === true) throw new AccessDeniedError();
|
|
1510
|
+
await this.seedPages(opts.pageTrigger, opts.pages, opts.customPages);
|
|
1511
|
+
}
|
|
1512
|
+
/** Explicitly delete one document (and its cascaded memory units/links). Background sync must
|
|
1513
|
+
* never use this as a cleanup primitive: a document id alone does not prove repository ownership. */
|
|
1514
|
+
async deleteDocument(documentId) {
|
|
1515
|
+
await this.req("DELETE", this.bankUrl(`/documents/${encodeURIComponent(documentId)}`));
|
|
1516
|
+
}
|
|
1517
|
+
/** Count of operations still ACTIVE on this bank — the list includes terminal ops (completed/
|
|
1518
|
+
* failed/cancelled), so filter by status. Powers syncStatus's "extractions drained" check. */
|
|
1519
|
+
async activeOperations(timeoutMs = 15e3) {
|
|
1520
|
+
const r = await this.req("GET", this.bankUrl("/operations"), void 0, [429], timeoutMs);
|
|
1521
|
+
if (!r.ok) throw new RouterRequestError(r.status, boundedRetryAfterMs(r.headers.get("retry-after")));
|
|
1522
|
+
try {
|
|
1523
|
+
const body = await r.json();
|
|
1524
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) throw new RouterRequestError(502);
|
|
1525
|
+
const { operations, items } = body;
|
|
1526
|
+
const ops = operations ?? items;
|
|
1527
|
+
if (!Array.isArray(ops)) throw new RouterRequestError(502);
|
|
1528
|
+
let active = 0;
|
|
1529
|
+
for (const op of ops) {
|
|
1530
|
+
if (!op || typeof op !== "object" || !("status" in op) || typeof op.status !== "string" || !op.status) {
|
|
1531
|
+
throw new RouterRequestError(502);
|
|
1532
|
+
}
|
|
1533
|
+
if (!TERMINAL.has(op.status.toLowerCase())) active++;
|
|
1534
|
+
}
|
|
1535
|
+
return active;
|
|
1536
|
+
} catch {
|
|
1537
|
+
throw new RouterRequestError(502);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Poll each enqueued operation by id until terminal. LIST only shows active ops, so per-id GET is reliable.
|
|
1542
|
+
*
|
|
1543
|
+
* Concurrency is capped at `maxParallelRetains` (the API rate-limits bursts, not single
|
|
1544
|
+
* requests — a 200 to a lone GET with 429s under `Promise.all` over every pending op). A 429
|
|
1545
|
+
* leaves the op pending and backs the next cycle off by its `Retry-After` (10s floor) instead
|
|
1546
|
+
* of hammering the next cycle 5s later.
|
|
1547
|
+
*/
|
|
1548
|
+
async drain(ids, label, maxMs = 60 * 60 * 1e3) {
|
|
1549
|
+
if (!ids.length) return;
|
|
1550
|
+
this.log(`[wait] draining ${ids.length} ${label} operations \u2026`);
|
|
1551
|
+
const start = Date.now();
|
|
1552
|
+
const pending = new Set(ids);
|
|
1553
|
+
const serverErrors = /* @__PURE__ */ new Map();
|
|
1554
|
+
let failed = 0;
|
|
1555
|
+
const giveUp = (id, status) => {
|
|
1556
|
+
pending.delete(id);
|
|
1557
|
+
failed++;
|
|
1558
|
+
this.log(` \u2026 ${label} op ${id} -> HTTP ${status}, counted as failed`);
|
|
1559
|
+
};
|
|
1560
|
+
while (pending.size && Date.now() - start < maxMs) {
|
|
1561
|
+
let backoffMs = POLL_CYCLE_MS;
|
|
1562
|
+
await pool([...pending], this.maxParallelRetains, async (id) => {
|
|
1563
|
+
try {
|
|
1564
|
+
const r = await this.fetchWithAuth(this.bankUrl(`/operations/${encodeURIComponent(id)}`), { method: "GET" });
|
|
1565
|
+
if (r.status === 429) {
|
|
1566
|
+
backoffMs = Math.min(
|
|
1567
|
+
RETRY_AFTER_CEILING_MS,
|
|
1568
|
+
Math.max(backoffMs, RETRY_AFTER_FLOOR_MS, retryAfterMs(r.headers.get("retry-after")))
|
|
1569
|
+
);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
if (r.status >= 500) {
|
|
1573
|
+
const n = (serverErrors.get(id) ?? 0) + 1;
|
|
1574
|
+
serverErrors.set(id, n);
|
|
1575
|
+
if (n >= POLL_MAX_SERVER_ERRORS) giveUp(id, r.status);
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
if (!r.ok) return giveUp(id, r.status);
|
|
1579
|
+
serverErrors.delete(id);
|
|
1580
|
+
const st = ((await r.json()).status || "").toLowerCase();
|
|
1581
|
+
if (TERMINAL.has(st)) {
|
|
1582
|
+
pending.delete(id);
|
|
1583
|
+
if (st !== "completed") failed++;
|
|
1584
|
+
}
|
|
1585
|
+
} catch (error) {
|
|
1586
|
+
if (error instanceof AccessDeniedError) throw error;
|
|
1587
|
+
if (error instanceof RouterRequestError) {
|
|
1588
|
+
if (error.statusCode === 429) {
|
|
1589
|
+
backoffMs = Math.min(RETRY_AFTER_CEILING_MS, Math.max(backoffMs, RETRY_AFTER_FLOOR_MS, error.retryAfterMs));
|
|
1590
|
+
} else if (error.statusCode >= 500) {
|
|
1591
|
+
const n = (serverErrors.get(id) ?? 0) + 1;
|
|
1592
|
+
serverErrors.set(id, n);
|
|
1593
|
+
if (n >= POLL_MAX_SERVER_ERRORS) giveUp(id, error.statusCode);
|
|
1594
|
+
} else {
|
|
1595
|
+
giveUp(id, error.statusCode);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}, (_index, error) => {
|
|
1600
|
+
if (error instanceof AccessDeniedError) throw error;
|
|
1601
|
+
});
|
|
1602
|
+
if (pending.size) {
|
|
1603
|
+
this.log(` \u2026 ${pending.size}/${ids.length} ${label} ops pending`);
|
|
1604
|
+
await sleep(backoffMs);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
this.log(
|
|
1608
|
+
`[wait] ${label} drained \u2014 ${ids.length - pending.size} done, ${failed} failed` + (pending.size ? `, ${pending.size} still pending at timeout` : "")
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Reflect: synthesized, root-cause answer over the bank. Bounded so a slow server never hangs a
|
|
1613
|
+
* caller — but `timeoutMs` is REQUIRED, deliberately: the right deadline differs by an order of
|
|
1614
|
+
* magnitude between the automatic hook (20s, to fit the host's window) and the agent-invoked
|
|
1615
|
+
* tool (minutes, on a populated bank). This used to default to 120s, which silently overrode the
|
|
1616
|
+
* tool's configured window and aborted every high-budget synthesis mid-flight (#3590).
|
|
1617
|
+
*/
|
|
1618
|
+
async reflect(query, opts) {
|
|
1619
|
+
const result = await readAcrossBanks(this.transport, "reflect", { query, budget: opts.budget ?? "high" }, { timeoutMs: opts.timeoutMs, maxTokens: 4096 });
|
|
1620
|
+
if (result.failedBanks.length === this.visibleBanks().length) {
|
|
1621
|
+
throw new ReflectError("reflection unavailable for all assigned banks", 503, false);
|
|
1622
|
+
}
|
|
1623
|
+
if (result.partial) this.log("partial memory read: assigned bank unavailable");
|
|
1624
|
+
return result.results.map((item) => item.text ?? item.content ?? "").join("\n\n");
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Raw recall with no LLM in the loop, so it still answers when reflect's synthesis times out or
|
|
1628
|
+
* 5xxs. The body is `recallOptions` (consolidated observations by default) — a bank that grows
|
|
1629
|
+
* no observations widens it rather than getting nothing back. Returns the texts in rank order.
|
|
1630
|
+
*
|
|
1631
|
+
* The name predates `recallOptions` (the observation type used to be hardcoded here) and is
|
|
1632
|
+
* kept deliberately: this is the client's published surface, so renaming it would break
|
|
1633
|
+
* importers for a cosmetic gain. The doc above is the contract, not the name.
|
|
1634
|
+
*/
|
|
1635
|
+
async recallObservations(query, opts) {
|
|
1636
|
+
const r = await this.req(
|
|
1637
|
+
"POST",
|
|
1638
|
+
this.bankUrl("/memories/recall"),
|
|
1639
|
+
// `query` is applied AFTER the spread: everything else is the caller's to override, but a
|
|
1640
|
+
// config that could replace the goal with a fixed string would silently recall for the
|
|
1641
|
+
// wrong question on every turn.
|
|
1642
|
+
{ ...this.recallOptions, query },
|
|
1643
|
+
[],
|
|
1644
|
+
opts.timeoutMs
|
|
1645
|
+
);
|
|
1646
|
+
if (r.status === 404) return [];
|
|
1647
|
+
const j = await r.json();
|
|
1648
|
+
return (j.results ?? []).map((x) => (x.text ?? "").trim()).filter(Boolean);
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* Latch `knowledgePagesSupported = false` iff this response really means the endpoint is absent.
|
|
1652
|
+
* Returns whether it latched. A bank-not-found 404 is NOT a capability verdict — it is the
|
|
1653
|
+
* expected answer before the bank's first retain — so it must never cache a negative (#4607).
|
|
1654
|
+
*
|
|
1655
|
+
* Only 404 is tested: `req` throws on every other non-ok status it was not told to tolerate, so
|
|
1656
|
+
* the 405/501 this used to check for never reach a caller in the first place.
|
|
1657
|
+
*/
|
|
1658
|
+
async pagesUnsupported(r) {
|
|
1659
|
+
if (r.status !== 404 || !await isEndpointMissing(r)) return false;
|
|
1660
|
+
this.knowledgePagesSupported = false;
|
|
1661
|
+
return true;
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* The bank's knowledge-base tree (folders + pages, nested). The tree carries names, source
|
|
1665
|
+
* queries and staleness but NOT synthesized content, so it is cheap enough to poll.
|
|
1666
|
+
*/
|
|
1667
|
+
async tree() {
|
|
1668
|
+
if (this.knowledgePagesSupported === false) throw new KnowledgePagesUnavailableError();
|
|
1669
|
+
const r = await this.req("GET", this.bankUrl("/knowledge-base/tree"));
|
|
1670
|
+
if (await this.pagesUnsupported(r)) throw new KnowledgePagesUnavailableError();
|
|
1671
|
+
if (r.status === 404) return [];
|
|
1672
|
+
this.knowledgePagesSupported = true;
|
|
1673
|
+
try {
|
|
1674
|
+
return (await r.json()).roots ?? [];
|
|
1675
|
+
} catch {
|
|
1676
|
+
return [];
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* List knowledge pages (ids + names only — no synthesized content), flattened out of the tree
|
|
1681
|
+
* and shaped as `{items:[…]}` for `parsePageList`. Folders are dropped: they carry no content
|
|
1682
|
+
* to read, and a caller enumerating pages wants leaves, not structure.
|
|
1683
|
+
*
|
|
1684
|
+
* NOTE the ids are knowledge-base node ids (`kp-…`), NOT the backing mental-model ids. Every
|
|
1685
|
+
* page read/update in this client goes through the same knowledge-base ids, so a page id from
|
|
1686
|
+
* here, from `searchKnowledgePages`, or from a `[[page:<id>]]` link all resolve identically.
|
|
1687
|
+
*/
|
|
1688
|
+
async listPages() {
|
|
1689
|
+
const items = [];
|
|
1690
|
+
const walk = (nodes, folder) => {
|
|
1691
|
+
for (const n of nodes) {
|
|
1692
|
+
if (!n?.id || !n?.name) continue;
|
|
1693
|
+
if (n.kind === "page") {
|
|
1694
|
+
items.push({
|
|
1695
|
+
id: n.id,
|
|
1696
|
+
name: n.name,
|
|
1697
|
+
...n.description ? { description: n.description } : {},
|
|
1698
|
+
...folder ? { folder } : {}
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
if (n.children?.length) walk(n.children, n.kind === "folder" ? n.name : folder);
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
walk(await this.tree());
|
|
1705
|
+
return { items };
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Read one knowledge page's synthesized content by knowledge-base id, shaped by `shapePage` so
|
|
1709
|
+
* the body arrives once. The endpoint omits the internal reflect trace that built
|
|
1710
|
+
* the page — that is 70-95% of the raw bytes and can blow past an MCP host's per-tool-result
|
|
1711
|
+
* token cap.
|
|
1712
|
+
*/
|
|
1713
|
+
async getPage(pageId) {
|
|
1714
|
+
if (this.knowledgePagesSupported === false) throw new KnowledgePagesUnavailableError();
|
|
1715
|
+
const r = await this.req(
|
|
1716
|
+
"GET",
|
|
1717
|
+
this.bankUrl(`/knowledge-base/pages/${encodeURIComponent(pageId)}`)
|
|
1718
|
+
);
|
|
1719
|
+
if (r.status === 404) throw new Error(`knowledge page not found: ${pageId}`);
|
|
1720
|
+
return shapePage(await r.json());
|
|
1721
|
+
}
|
|
1722
|
+
/** Hybrid (BM25 + vector, RRF-fused) server-side search over the bank's knowledge pages.
|
|
1723
|
+
* Returns page-level hits with a relevance snippet — the real search behind
|
|
1724
|
+
* hindsight_search_knowledge_pages. */
|
|
1725
|
+
async searchKnowledgePages(query, opts = {}) {
|
|
1726
|
+
if (this.knowledgePagesSupported === false) throw new KnowledgePagesUnavailableError();
|
|
1727
|
+
const q = `?q=${encodeURIComponent(query)}&limit=${opts.limit ?? this.pageSearchLimit}`;
|
|
1728
|
+
const r = await this.req(
|
|
1729
|
+
"GET",
|
|
1730
|
+
this.bankUrl(`/knowledge-base/search${q}`),
|
|
1731
|
+
void 0,
|
|
1732
|
+
[],
|
|
1733
|
+
opts.timeoutMs
|
|
1734
|
+
);
|
|
1735
|
+
if (await this.pagesUnsupported(r)) throw new KnowledgePagesUnavailableError();
|
|
1736
|
+
if (r.status === 404) return [];
|
|
1737
|
+
const j = await r.json();
|
|
1738
|
+
return (j.results ?? []).map((x) => ({
|
|
1739
|
+
id: x.id,
|
|
1740
|
+
name: x.name,
|
|
1741
|
+
...x.source_query ? { source_query: x.source_query } : {},
|
|
1742
|
+
snippet: x.snippet ?? "",
|
|
1743
|
+
score: x.score ?? 0
|
|
1744
|
+
}));
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Seed the fixed page taxonomy as knowledge-base pages at the tree root, idempotently.
|
|
1748
|
+
*
|
|
1749
|
+
* Matched by NAME, not id: `/knowledge-base/pages` mints its own `kp-…` id, so a stable
|
|
1750
|
+
* client-chosen id isn't available to match on (unlike the old mental-model path, which keyed
|
|
1751
|
+
* off a slug). Names are unique per folder server-side, which makes them a sound key.
|
|
1752
|
+
*
|
|
1753
|
+
* An existing page is PATCHed rather than recreated so a plugin upgrade that rewords a
|
|
1754
|
+
* `source_query` re-syncs onto the live page instead of orphaning its synthesized content —
|
|
1755
|
+
* which is how `pageScopeRule`'s repo name reaches banks seeded by an earlier version.
|
|
1756
|
+
*/
|
|
1757
|
+
async seedPages(pageTrigger = buildPageTrigger(), pagesConfig = {}, customPages = {}) {
|
|
1758
|
+
const pages = pagesFor(this.project, pagesConfig, customPages);
|
|
1759
|
+
const existing = /* @__PURE__ */ new Map();
|
|
1760
|
+
let roots;
|
|
1761
|
+
try {
|
|
1762
|
+
roots = await this.tree();
|
|
1763
|
+
} catch (e) {
|
|
1764
|
+
if (e instanceof KnowledgePagesUnavailableError) {
|
|
1765
|
+
this.log(`[bank] knowledge pages unavailable on ${this.apiUrl}; continuing without pages`);
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
throw e;
|
|
1769
|
+
}
|
|
1770
|
+
for (const n of roots) {
|
|
1771
|
+
if (n.kind === "page" && n.name) existing.set(n.name.toLowerCase(), n);
|
|
1772
|
+
}
|
|
1773
|
+
let created = 0;
|
|
1774
|
+
let updated = 0;
|
|
1775
|
+
let vanished = 0;
|
|
1776
|
+
for (const page of pages) {
|
|
1777
|
+
const hit = existing.get(page.name.toLowerCase());
|
|
1778
|
+
const body = {
|
|
1779
|
+
name: page.name,
|
|
1780
|
+
source_query: page.source_query,
|
|
1781
|
+
tags: page.tags,
|
|
1782
|
+
max_tokens: PAGE_MAX_TOKENS,
|
|
1783
|
+
// Resolved HERE, not in `buildPageTrigger`: a hashed cron (`H`) needs the page's identity,
|
|
1784
|
+
// and one trigger is built per session for all of them.
|
|
1785
|
+
trigger: pageTriggerFor(pageTrigger, this.bank, page.name)
|
|
1786
|
+
};
|
|
1787
|
+
if (!hit) {
|
|
1788
|
+
const r = await this.req("POST", this.bankUrl("/knowledge-base/pages"), body, [409]);
|
|
1789
|
+
if (await this.pagesUnsupported(r)) {
|
|
1790
|
+
this.log(
|
|
1791
|
+
`[bank] knowledge pages unavailable on ${this.apiUrl}; continuing without pages`
|
|
1792
|
+
);
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
if (r.status === 404) {
|
|
1796
|
+
this.log(`[bank] ${this.bank} does not exist yet; pages seed on the next session`);
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
if (r.status !== 409) created++;
|
|
1800
|
+
} else {
|
|
1801
|
+
const sourceDrift = hit.description !== page.source_query;
|
|
1802
|
+
const triggerDrift = hit.trigger != null && pageTriggerDrifted(hit.trigger, body.trigger);
|
|
1803
|
+
if (!sourceDrift && !triggerDrift) continue;
|
|
1804
|
+
const patch = {};
|
|
1805
|
+
if (sourceDrift) {
|
|
1806
|
+
patch.source_query = page.source_query;
|
|
1807
|
+
patch.tags = page.tags;
|
|
1808
|
+
this.log(
|
|
1809
|
+
`[bank] re-syncing "${page.name}" to its configured source_query \u2014 set pages[${JSON.stringify(page.name)}].source_query to keep your own wording`
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
if (triggerDrift) patch.trigger = pageTriggerPatch(body.trigger);
|
|
1813
|
+
const r = await this.req(
|
|
1814
|
+
"PATCH",
|
|
1815
|
+
this.bankUrl(`/knowledge-base/nodes/${encodeURIComponent(hit.id)}`),
|
|
1816
|
+
patch
|
|
1817
|
+
);
|
|
1818
|
+
if (await this.pagesUnsupported(r)) {
|
|
1819
|
+
this.log(
|
|
1820
|
+
`[bank] knowledge pages unavailable on ${this.apiUrl}; continuing without pages`
|
|
1821
|
+
);
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
if (r.status === 404) {
|
|
1825
|
+
vanished++;
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1828
|
+
updated++;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
const initiatives = await this.resyncInitiativeTriggers(roots, pageTrigger);
|
|
1832
|
+
this.log(
|
|
1833
|
+
`[bank] knowledge pages seeded on ${this.bank} (scoped to ${this.project ?? "the shared bank"}): ${created} created, ${updated} re-synced, ${pages.length - created - updated - vanished} unchanged` + (vanished ? `, ${vanished} deleted under us` : "") + (initiatives ? `, ${initiatives} initiative pages re-synced` : "")
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Bring the captured initiative pages onto the same refresh policy as the seeded taxonomy.
|
|
1838
|
+
*
|
|
1839
|
+
* `captureInitiative` stamps this very trigger when it creates a page, so on a bank seeded under
|
|
1840
|
+
* an older default they are the same drift as the taxonomy — and on a real repo they are most of
|
|
1841
|
+
* it: five taxonomy pages against one page per initiative, each an LLM synthesis per
|
|
1842
|
+
* consolidation under the auto-refresh that used to be the default (#3506).
|
|
1843
|
+
*
|
|
1844
|
+
* Their `name` and `source_query` are written once, from the initiative's own title, and
|
|
1845
|
+
* re-stating either would rebuild a page whose question never changed. The one exception is
|
|
1846
|
+
* the scope clause: a page captured while the clause named the BANK is scoped to a project no
|
|
1847
|
+
* memory is about and comes out empty (#4490), so exactly that clause is swapped for the current
|
|
1848
|
+
* one — the rest of the query, the user's wording included, is kept. The tree read above is
|
|
1849
|
+
* reused rather than re-fetched.
|
|
1850
|
+
*/
|
|
1851
|
+
async resyncInitiativeTriggers(roots, pageTrigger) {
|
|
1852
|
+
const folder = roots.find(
|
|
1853
|
+
(n) => n.kind === "folder" && (n.name || "").toLowerCase() === "initiatives"
|
|
1854
|
+
);
|
|
1855
|
+
const bankScope = pageScopeRule(this.bank);
|
|
1856
|
+
let updated = 0;
|
|
1857
|
+
for (const page of folder?.children ?? []) {
|
|
1858
|
+
if (page.kind !== "page") continue;
|
|
1859
|
+
const patch = {};
|
|
1860
|
+
const query = page.description?.replace(bankScope, pageScopeRule(this.project));
|
|
1861
|
+
if (query !== page.description) patch.source_query = query;
|
|
1862
|
+
if (page.trigger != null) {
|
|
1863
|
+
const desired = pageTriggerFor(pageTrigger, this.bank, page.name);
|
|
1864
|
+
if (pageTriggerDrifted(page.trigger, desired)) patch.trigger = pageTriggerPatch(desired);
|
|
1865
|
+
}
|
|
1866
|
+
if (!patch.trigger && !patch.source_query) continue;
|
|
1867
|
+
const r = await this.req(
|
|
1868
|
+
"PATCH",
|
|
1869
|
+
this.bankUrl(`/knowledge-base/nodes/${encodeURIComponent(page.id)}`),
|
|
1870
|
+
patch
|
|
1871
|
+
);
|
|
1872
|
+
if (r.status === 404) continue;
|
|
1873
|
+
updated++;
|
|
1874
|
+
}
|
|
1875
|
+
return updated;
|
|
1876
|
+
}
|
|
1877
|
+
/** URL/id-safe slug: lowercase, non-alphanumerics → "-", trim dashes, cap length; fallback "initiative". */
|
|
1878
|
+
slugify(s) {
|
|
1879
|
+
return s.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/(?:^-+)|(?:-+$)/g, "").slice(0, 60) || "initiative";
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Active-path capture: register a major feature as a per-initiative page + a marker memory.
|
|
1883
|
+
* New initiative → creates a page under the Initiatives folder.
|
|
1884
|
+
* Update (relatesToPageId) → no new page; only a marker accruing to the existing page, so an
|
|
1885
|
+
* enhancement or a mid-work plan change lands on the initiative it belongs to.
|
|
1886
|
+
*
|
|
1887
|
+
* The marker carries its page id in two places, neither of them a tag (#3641):
|
|
1888
|
+
* `metadata.relatedPageId` for provenance (visible on the document, and to a reflect loop that
|
|
1889
|
+
* expands one), and a `[[page:<id>]]` link in the retain CONTEXT, which is the only one of the
|
|
1890
|
+
* three channels a page synthesis actually reads — reflect's search results keep `context` and
|
|
1891
|
+
* `tags` but strip `metadata` (`_UNREAD_RESULT_FIELDS`), and the id has no business in a tag
|
|
1892
|
+
* vocabulary that exists to be matched with exact set-ops.
|
|
1893
|
+
*/
|
|
1894
|
+
async captureInitiative(args) {
|
|
1895
|
+
let pageId = args.relatesToPageId;
|
|
1896
|
+
if (!pageId) {
|
|
1897
|
+
const folderId = await this.ensureFolder("Initiatives");
|
|
1898
|
+
const r = await this.req("POST", this.bankUrl("/knowledge-base/pages"), {
|
|
1899
|
+
name: args.title,
|
|
1900
|
+
source_query: `Summarize the "${args.title}" initiative: what is being built or changed and why, and its current state \u2014 drawn from the project's memory.` + pageScopeRule(this.project),
|
|
1901
|
+
parent_id: folderId,
|
|
1902
|
+
tags: ["knowledge:feature-work"],
|
|
1903
|
+
max_tokens: PAGE_MAX_TOKENS,
|
|
1904
|
+
trigger: pageTriggerFor(args.pageTrigger ?? buildPageTrigger(), this.bank, args.title)
|
|
1905
|
+
});
|
|
1906
|
+
try {
|
|
1907
|
+
const j = await r.json();
|
|
1908
|
+
pageId = j.page_id ?? j.id;
|
|
1909
|
+
} catch {
|
|
1910
|
+
}
|
|
1911
|
+
pageId ||= `initiative-${this.slugify(args.title)}`;
|
|
1912
|
+
}
|
|
1913
|
+
const verb = args.relatesToPageId ? "Update to an existing initiative" : "New initiative";
|
|
1914
|
+
const content = `${verb}: ${args.title}. ${args.summary}`;
|
|
1915
|
+
const markerId = `initiative-marker-${this.slugify(pageId)}${args.relatesToPageId ? "-update" : ""}`;
|
|
1916
|
+
const tags = [.../* @__PURE__ */ new Set([...args.stamp?.tags ?? [], "knowledge:feature-work"])];
|
|
1917
|
+
const context = `initiative marker for [[page:${pageId}]]`;
|
|
1918
|
+
await this.retain(content, context, markerId, tags, "document", {
|
|
1919
|
+
metadata: { ...args.stamp?.metadata ?? {}, relatedPageId: pageId }
|
|
1920
|
+
});
|
|
1921
|
+
return { page_id: pageId };
|
|
1922
|
+
}
|
|
1923
|
+
/** Find a root folder by name (case-insensitive) or create it; returns its id. Fail-open to undefined. */
|
|
1924
|
+
async ensureFolder(name) {
|
|
1925
|
+
try {
|
|
1926
|
+
const tree = await (await this.req("GET", this.bankUrl("/knowledge-base/tree"))).json();
|
|
1927
|
+
const hit = (tree.roots || []).find(
|
|
1928
|
+
(n) => n.kind === "folder" && (n.name || "").toLowerCase() === name.toLowerCase()
|
|
1929
|
+
);
|
|
1930
|
+
if (hit?.id) return hit.id;
|
|
1931
|
+
} catch (error) {
|
|
1932
|
+
if (error instanceof AccessDeniedError) throw error;
|
|
1933
|
+
}
|
|
1934
|
+
try {
|
|
1935
|
+
const r = await this.req("POST", this.bankUrl("/knowledge-base/folders"), { name });
|
|
1936
|
+
return (await r.json()).id;
|
|
1937
|
+
} catch (error) {
|
|
1938
|
+
if (error instanceof AccessDeniedError) throw error;
|
|
1939
|
+
return void 0;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
};
|
|
1943
|
+
|
|
1944
|
+
// src/core/config.ts
|
|
1945
|
+
var CONFIG_PATH = process.env.HINDSIGHT_CONFIG || join7(homedir3(), ".hindsight", "coding-agent.json");
|
|
1946
|
+
var DEFAULT_DAEMON_PORT = 9077;
|
|
1947
|
+
var DEFAULT_DAEMON_PROFILE = "coding-agent";
|
|
1948
|
+
function resolvePageTrigger(raw) {
|
|
1949
|
+
if (raw.pageTriggerType === "manual") return { type: "manual" };
|
|
1950
|
+
if (raw.pageTriggerType === "auto-refresh") return { type: "auto-refresh" };
|
|
1951
|
+
if (raw.pageTriggerType !== void 0 && raw.pageTriggerType !== "cron")
|
|
1952
|
+
log.warn(
|
|
1953
|
+
"config",
|
|
1954
|
+
"ignoring invalid pageTriggerType \u2014 expected cron|auto-refresh|manual"
|
|
1955
|
+
);
|
|
1956
|
+
const cron = raw.pageTriggerCron?.trim();
|
|
1957
|
+
if (!cron) return { type: "cron", cron: DEFAULT_PAGE_TRIGGER_CRON };
|
|
1958
|
+
if (!isHashedCron(cron) || parseHashedCron(cron)) return { type: "cron", cron };
|
|
1959
|
+
log.warn(
|
|
1960
|
+
"config",
|
|
1961
|
+
`pageTriggerCron has a malformed hashed field \u2014 write \`H\` or \`H(<lo>-<hi>)\` within the field's own range, e.g. "H H(0-5) * * *" \u2014 falling back to ${JSON.stringify(DEFAULT_PAGE_TRIGGER_CRON)}`
|
|
1962
|
+
);
|
|
1963
|
+
return { type: "cron", cron: DEFAULT_PAGE_TRIGGER_CRON };
|
|
1964
|
+
}
|
|
1965
|
+
function resolvePages(raw) {
|
|
1966
|
+
const value = raw;
|
|
1967
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
1968
|
+
const out = {};
|
|
1969
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
1970
|
+
if (!PAGE_NAMES.some((known) => known.toLowerCase() === name.trim().toLowerCase())) {
|
|
1971
|
+
log.warn(
|
|
1972
|
+
"config",
|
|
1973
|
+
`ignoring unknown pages entry \u2014 no seeded page has that name; expected one of: ${PAGE_NAMES.join(", ")}`
|
|
1974
|
+
);
|
|
1975
|
+
continue;
|
|
1976
|
+
}
|
|
1977
|
+
if (entry === false) {
|
|
1978
|
+
out[name] = false;
|
|
1979
|
+
continue;
|
|
1980
|
+
}
|
|
1981
|
+
const query = entry && typeof entry === "object" && !Array.isArray(entry) ? entry.source_query : void 0;
|
|
1982
|
+
if (typeof query === "string" && query.trim()) {
|
|
1983
|
+
out[name] = { source_query: query };
|
|
1984
|
+
continue;
|
|
1985
|
+
}
|
|
1986
|
+
log.warn(
|
|
1987
|
+
"config",
|
|
1988
|
+
'ignoring invalid pages entry \u2014 expected false, or {"source_query": "<your question>"}'
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
return out;
|
|
1992
|
+
}
|
|
1993
|
+
function resolveCustomPages(raw) {
|
|
1994
|
+
const value = raw;
|
|
1995
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
1996
|
+
const out = {};
|
|
1997
|
+
for (const [rawName, entry] of Object.entries(value)) {
|
|
1998
|
+
const name = rawName.trim();
|
|
1999
|
+
if (!name) continue;
|
|
2000
|
+
if (PAGE_NAMES.some((known) => known.toLowerCase() === name.toLowerCase())) {
|
|
2001
|
+
log.warn(
|
|
2002
|
+
"config",
|
|
2003
|
+
"ignoring customPages entry with a seeded page name; reword it under `pages` instead"
|
|
2004
|
+
);
|
|
2005
|
+
continue;
|
|
2006
|
+
}
|
|
2007
|
+
const query = entry && typeof entry === "object" && !Array.isArray(entry) ? entry.source_query : void 0;
|
|
2008
|
+
if (typeof query !== "string" || !query.trim()) {
|
|
2009
|
+
log.warn(
|
|
2010
|
+
"config",
|
|
2011
|
+
'ignoring invalid customPages entry \u2014 expected {"source_query": "<your question>"}'
|
|
2012
|
+
);
|
|
2013
|
+
continue;
|
|
2014
|
+
}
|
|
2015
|
+
const rawTags = entry.tags;
|
|
2016
|
+
const tags = Array.isArray(rawTags) ? rawTags.filter((t) => typeof t === "string" && t.trim() !== "") : [];
|
|
2017
|
+
out[name] = tags.length ? { source_query: query, tags } : { source_query: query };
|
|
2018
|
+
}
|
|
2019
|
+
return out;
|
|
2020
|
+
}
|
|
2021
|
+
var DEFAULT_REFLECT_TIMEOUT_MS = 2e4;
|
|
2022
|
+
var DEFAULT_REFLECT_TOOL_TIMEOUT_MS = 33e4;
|
|
2023
|
+
var DEFAULT_INJECT_TIMEOUT_MS = 7e3;
|
|
2024
|
+
var REFLECT_BUDGETS = ["low", "mid", "high"];
|
|
2025
|
+
function resolveReflectBudget(raw) {
|
|
2026
|
+
const value = raw.reflectBudget;
|
|
2027
|
+
if (value === void 0) return "high";
|
|
2028
|
+
if (typeof value === "string" && REFLECT_BUDGETS.includes(value))
|
|
2029
|
+
return value;
|
|
2030
|
+
log.warn("config", "ignoring invalid reflectBudget \u2014 expected low|mid|high");
|
|
2031
|
+
return "high";
|
|
2032
|
+
}
|
|
2033
|
+
var OBSERVATION_SCOPE_MODES = [
|
|
2034
|
+
"shared",
|
|
2035
|
+
"combined",
|
|
2036
|
+
"per_tag",
|
|
2037
|
+
"all_combinations",
|
|
2038
|
+
"per_source"
|
|
2039
|
+
];
|
|
2040
|
+
function resolveObservationScopes(raw) {
|
|
2041
|
+
const value = raw;
|
|
2042
|
+
if (typeof value === "string")
|
|
2043
|
+
return OBSERVATION_SCOPE_MODES.includes(value) ? value : DEFAULT_OBSERVATION_SCOPES;
|
|
2044
|
+
if (Array.isArray(value)) {
|
|
2045
|
+
const scopes = value.filter((scope) => Array.isArray(scope)).map((scope) => scope.filter((t) => typeof t === "string" && t.trim() !== ""));
|
|
2046
|
+
if (scopes.length) return scopes;
|
|
2047
|
+
}
|
|
2048
|
+
return DEFAULT_OBSERVATION_SCOPES;
|
|
2049
|
+
}
|
|
2050
|
+
function resolveDefaultBankConfig(raw) {
|
|
2051
|
+
const value = raw;
|
|
2052
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
2053
|
+
const out = {};
|
|
2054
|
+
for (const [key, v] of Object.entries(value)) {
|
|
2055
|
+
if (PLUGIN_GOVERNED_BANK_FIELDS.includes(key)) {
|
|
2056
|
+
log.warn(
|
|
2057
|
+
"config",
|
|
2058
|
+
`ignoring defaultBankConfig.${key} \u2014 the plugin manages this field itself` + (key === "retain_extraction_mode" ? " (set retainExtractionMode instead)" : "")
|
|
2059
|
+
);
|
|
2060
|
+
continue;
|
|
2061
|
+
}
|
|
2062
|
+
out[key] = v;
|
|
2063
|
+
}
|
|
2064
|
+
return out;
|
|
2065
|
+
}
|
|
2066
|
+
var AUTO_INJECT_MODES = ["reflect", "pages", "recall", "none"];
|
|
2067
|
+
function resolveAutoInject(raw) {
|
|
2068
|
+
if (raw.autoReflect !== void 0) {
|
|
2069
|
+
const replacement = raw.autoReflect === false ? "none" : "reflect";
|
|
2070
|
+
log.warn("config", `autoReflect is deprecated \u2014 use autoInject: "${replacement}" instead`);
|
|
2071
|
+
}
|
|
2072
|
+
if (AUTO_INJECT_MODES.includes(raw.autoInject)) return raw.autoInject;
|
|
2073
|
+
return raw.autoReflect === false ? "none" : "reflect";
|
|
2074
|
+
}
|
|
2075
|
+
function resolveConfig(raw = {}) {
|
|
2076
|
+
const serverMode = ["cloud", "self-hosted", "daemon"].includes(raw.serverMode) ? raw.serverMode : "self-hosted";
|
|
2077
|
+
const apiPort = raw.apiPort || DEFAULT_DAEMON_PORT;
|
|
2078
|
+
const pageTrigger = resolvePageTrigger(raw);
|
|
2079
|
+
return {
|
|
2080
|
+
serverMode,
|
|
2081
|
+
// Only loadConfig can supply the operator-managed router endpoint.
|
|
2082
|
+
apiUrl: raw.apiUrl ?? "",
|
|
2083
|
+
apiToken: raw.apiToken || void 0,
|
|
2084
|
+
apiPort,
|
|
2085
|
+
daemonIdleTimeout: raw.daemonIdleTimeout,
|
|
2086
|
+
daemonProfile: raw.daemonProfile || DEFAULT_DAEMON_PROFILE,
|
|
2087
|
+
embedVersion: raw.embedVersion || void 0,
|
|
2088
|
+
embedPackagePath: raw.embedPackagePath || void 0,
|
|
2089
|
+
bankId: raw.bankId,
|
|
2090
|
+
dynamicBankId: raw.dynamicBankId,
|
|
2091
|
+
bankIdTemplate: raw.bankIdTemplate,
|
|
2092
|
+
mapPathToBank: raw.mapPathToBank,
|
|
2093
|
+
resolveWorktrees: raw.resolveWorktrees,
|
|
2094
|
+
optInOnly: raw.optInOnly ?? false,
|
|
2095
|
+
// Same shape as retainTags: a config typo must not become a path that silently approves nothing.
|
|
2096
|
+
optInPaths: Array.isArray(raw.optInPaths) ? raw.optInPaths.filter((p) => typeof p === "string" && p.trim() !== "") : [],
|
|
2097
|
+
harness: raw.harness ?? "opencode",
|
|
2098
|
+
disabled: raw.disabled ?? false,
|
|
2099
|
+
retainSessions: raw.retainSessions ?? true,
|
|
2100
|
+
// write sessions back by default, every harness
|
|
2101
|
+
manageBankConfig: raw.manageBankConfig ?? true,
|
|
2102
|
+
retainExtractionMode: RETAIN_EXTRACTION_MODES.includes(raw.retainExtractionMode) ? raw.retainExtractionMode : DEFAULT_RETAIN_EXTRACTION_MODE,
|
|
2103
|
+
defaultBankConfig: resolveDefaultBankConfig(raw.defaultBankConfig),
|
|
2104
|
+
maxParallelRetains: raw.maxParallelRetains || 10,
|
|
2105
|
+
reflectTimeoutMs: raw.reflectTimeoutMs || DEFAULT_REFLECT_TIMEOUT_MS,
|
|
2106
|
+
// Inherit an explicitly-raised reflectTimeoutMs (that is what users reaching for a longer
|
|
2107
|
+
// reflect already set), but never let it LOWER the tool below the default — a short window is
|
|
2108
|
+
// set to bound the automatic hook, not to cut off a call the agent is waiting on.
|
|
2109
|
+
reflectToolTimeoutMs: raw.reflectToolTimeoutMs || Math.max(raw.reflectTimeoutMs || 0, DEFAULT_REFLECT_TOOL_TIMEOUT_MS),
|
|
2110
|
+
injectTimeoutMs: raw.injectTimeoutMs || DEFAULT_INJECT_TIMEOUT_MS,
|
|
2111
|
+
reflectBudget: resolveReflectBudget(raw),
|
|
2112
|
+
autoInject: resolveAutoInject(raw),
|
|
2113
|
+
pageSearchLimit: raw.pageSearchLimit || DEFAULT_PAGE_SEARCH_LIMIT,
|
|
2114
|
+
// Same shape as retainMetadata: an object, or nothing. An array would spread into numeric
|
|
2115
|
+
// keys and reach the API as garbage, so it is rejected like any other non-object.
|
|
2116
|
+
recallOptions: raw.recallOptions && typeof raw.recallOptions === "object" && !Array.isArray(raw.recallOptions) ? { ...raw.recallOptions } : {},
|
|
2117
|
+
toolGuideExtra: typeof raw.toolGuideExtra === "string" && raw.toolGuideExtra.trim() ? raw.toolGuideExtra : void 0,
|
|
2118
|
+
// Every turn, not every tenth. The guide is what tells the agent WHEN to reach for memory, and
|
|
2119
|
+
// at a cadence of 10 a normal session is told once, on turn 1, and never again. Measured over
|
|
2120
|
+
// 40 real Claude Code turns, moving this from 10 to 1 took searches from 15% of turns to 32.5%
|
|
2121
|
+
// with no wording change at all. The cost is the guide's ~2KB re-sent per turn, which caches.
|
|
2122
|
+
pageRefreshEveryTurns: raw.pageRefreshEveryTurns || 1,
|
|
2123
|
+
pageTriggerType: pageTrigger.type,
|
|
2124
|
+
pageTriggerCron: pageTrigger.cron,
|
|
2125
|
+
pages: resolvePages(raw.pages),
|
|
2126
|
+
customPages: resolveCustomPages(raw.customPages),
|
|
2127
|
+
autoSeed: raw.autoSeed ?? true,
|
|
2128
|
+
seedLimit: raw.seedLimit || DEFAULT_SEED_LIMIT,
|
|
2129
|
+
codebaseSurvey: raw.codebaseSurvey ?? true,
|
|
2130
|
+
surveyModel: raw.surveyModel || "haiku",
|
|
2131
|
+
surveyBudgetUsd: raw.surveyBudgetUsd || 2,
|
|
2132
|
+
surveyRefreshCommits: raw.surveyRefreshCommits ?? 20,
|
|
2133
|
+
gitIngest: ["message", "full", "none"].includes(raw.gitIngest) ? raw.gitIngest : "message",
|
|
2134
|
+
// Hostile input is a config typo, not an attack: keep only string entries so a stray number or
|
|
2135
|
+
// nested object cannot reach the API as a tag and fail the whole retain.
|
|
2136
|
+
retainTags: Array.isArray(raw.retainTags) ? raw.retainTags.filter((t) => typeof t === "string" && t.trim() !== "") : [],
|
|
2137
|
+
// Undefined, not "", when unset or blank: the retain call distinguishes "not configured" (use
|
|
2138
|
+
// the built-in default) from a configured value, and an empty context would strip the default.
|
|
2139
|
+
retainContext: typeof raw.retainContext === "string" && raw.retainContext.trim() !== "" ? raw.retainContext : void 0,
|
|
2140
|
+
retainMetadata: raw.retainMetadata && typeof raw.retainMetadata === "object" ? Object.fromEntries(
|
|
2141
|
+
Object.entries(raw.retainMetadata).filter(([, v]) => typeof v === "string")
|
|
2142
|
+
) : {},
|
|
2143
|
+
observationScopes: resolveObservationScopes(raw.observationScopes),
|
|
2144
|
+
banks: raw.banks && typeof raw.banks === "object" ? raw.banks : {},
|
|
2145
|
+
paths: raw.paths && typeof raw.paths === "object" ? raw.paths : {},
|
|
2146
|
+
logLevel: ["debug", "info", "warn", "error"].includes(raw.logLevel) ? raw.logLevel : "info",
|
|
2147
|
+
autoUpdate: raw.autoUpdate ?? true
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
function readRaw(path) {
|
|
2151
|
+
try {
|
|
2152
|
+
return JSON.parse(readFileSync4(path, "utf8"));
|
|
2153
|
+
} catch (e) {
|
|
2154
|
+
if (e?.code !== "ENOENT") {
|
|
2155
|
+
console.error(`hindsight: ignoring invalid config at ${path}: ${e?.message || e}`);
|
|
2156
|
+
}
|
|
2157
|
+
return {};
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
function mergeRaw(a, b) {
|
|
2161
|
+
const { harnesses: _drop, ...flat } = b;
|
|
2162
|
+
return {
|
|
2163
|
+
...a,
|
|
2164
|
+
...flat,
|
|
2165
|
+
banks: { ...a.banks ?? {}, ...b.banks ?? {} },
|
|
2166
|
+
paths: { ...a.paths ?? {}, ...b.paths ?? {} }
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
function applyLayer(raw, layer, harness) {
|
|
2170
|
+
let out = mergeRaw(raw, layer);
|
|
2171
|
+
const perHarness = harness ? layer.harnesses?.[harness] : void 0;
|
|
2172
|
+
if (perHarness) out = mergeRaw(out, perHarness);
|
|
2173
|
+
return out;
|
|
2174
|
+
}
|
|
2175
|
+
var ENV_KEYS = {
|
|
2176
|
+
serverMode: "HINDSIGHT_SERVER_MODE",
|
|
2177
|
+
apiUrl: "HINDSIGHT_API_URL",
|
|
2178
|
+
apiToken: "HINDSIGHT_API_TOKEN",
|
|
2179
|
+
// These four keep the names the old per-agent Claude Code plugin used, so a user migrating from
|
|
2180
|
+
// it can carry their existing environment over unchanged.
|
|
2181
|
+
apiPort: "HINDSIGHT_API_PORT",
|
|
2182
|
+
daemonIdleTimeout: "HINDSIGHT_DAEMON_IDLE_TIMEOUT",
|
|
2183
|
+
embedVersion: "HINDSIGHT_EMBED_VERSION",
|
|
2184
|
+
embedPackagePath: "HINDSIGHT_EMBED_PACKAGE_PATH",
|
|
2185
|
+
daemonProfile: "HINDSIGHT_DAEMON_PROFILE",
|
|
2186
|
+
bankId: "HINDSIGHT_BANK_ID",
|
|
2187
|
+
dynamicBankId: "HINDSIGHT_DYNAMIC_BANK_ID",
|
|
2188
|
+
bankIdTemplate: "HINDSIGHT_BANK_ID_TEMPLATE",
|
|
2189
|
+
resolveWorktrees: "HINDSIGHT_RESOLVE_WORKTREES",
|
|
2190
|
+
optInOnly: "HINDSIGHT_OPT_IN_ONLY",
|
|
2191
|
+
optInPaths: "HINDSIGHT_OPT_IN_PATHS",
|
|
2192
|
+
harness: "HINDSIGHT_HARNESS",
|
|
2193
|
+
disabled: "HINDSIGHT_DISABLED",
|
|
2194
|
+
retainSessions: "HINDSIGHT_RETAIN_SESSIONS",
|
|
2195
|
+
maxParallelRetains: "HINDSIGHT_MAX_PARALLEL_RETAINS",
|
|
2196
|
+
reflectTimeoutMs: "HINDSIGHT_REFLECT_TIMEOUT_MS",
|
|
2197
|
+
reflectToolTimeoutMs: "HINDSIGHT_REFLECT_TOOL_TIMEOUT_MS",
|
|
2198
|
+
injectTimeoutMs: "HINDSIGHT_INJECT_TIMEOUT_MS",
|
|
2199
|
+
reflectBudget: "HINDSIGHT_REFLECT_BUDGET",
|
|
2200
|
+
autoInject: "HINDSIGHT_AUTO_INJECT",
|
|
2201
|
+
pageSearchLimit: "HINDSIGHT_PAGE_SEARCH_LIMIT",
|
|
2202
|
+
toolGuideExtra: "HINDSIGHT_TOOL_GUIDE_EXTRA",
|
|
2203
|
+
autoReflect: "HINDSIGHT_AUTO_REFLECT",
|
|
2204
|
+
pageRefreshEveryTurns: "HINDSIGHT_PAGE_REFRESH_EVERY_TURNS",
|
|
2205
|
+
pageTriggerType: "HINDSIGHT_PAGE_TRIGGER_TYPE",
|
|
2206
|
+
pageTriggerCron: "HINDSIGHT_PAGE_TRIGGER_CRON",
|
|
2207
|
+
autoSeed: "HINDSIGHT_AUTO_SEED",
|
|
2208
|
+
seedLimit: "HINDSIGHT_SEED_LIMIT",
|
|
2209
|
+
codebaseSurvey: "HINDSIGHT_CODEBASE_SURVEY",
|
|
2210
|
+
surveyModel: "HINDSIGHT_SURVEY_MODEL",
|
|
2211
|
+
surveyBudgetUsd: "HINDSIGHT_SURVEY_BUDGET_USD",
|
|
2212
|
+
surveyRefreshCommits: "HINDSIGHT_SURVEY_REFRESH_COMMITS",
|
|
2213
|
+
logLevel: "HINDSIGHT_LOG_LEVEL",
|
|
2214
|
+
autoUpdate: "HINDSIGHT_AUTO_UPDATE",
|
|
2215
|
+
gitIngest: "HINDSIGHT_GIT_INGEST",
|
|
2216
|
+
// Scalar modes only ("shared", "combined", "per_tag", "all_combinations"). An explicit scope
|
|
2217
|
+
// list is a list OF lists, which does not survive flattening into one variable — file-only.
|
|
2218
|
+
observationScopes: "HINDSIGHT_OBSERVATION_SCOPES",
|
|
2219
|
+
// Comma-separated, e.g. HINDSIGHT_RETAIN_TAGS="project:{gitProject},env:work". A LIST rather than
|
|
2220
|
+
// a map, so it flattens cleanly; its sibling retainMetadata stays file-only for the reason above.
|
|
2221
|
+
retainTags: "HINDSIGHT_RETAIN_TAGS",
|
|
2222
|
+
manageBankConfig: "HINDSIGHT_MANAGE_BANK_CONFIG",
|
|
2223
|
+
retainExtractionMode: "HINDSIGHT_RETAIN_EXTRACTION_MODE"
|
|
2224
|
+
};
|
|
2225
|
+
var ENV_BOOLEANS = /* @__PURE__ */ new Set([
|
|
2226
|
+
"dynamicBankId",
|
|
2227
|
+
"resolveWorktrees",
|
|
2228
|
+
"optInOnly",
|
|
2229
|
+
"disabled",
|
|
2230
|
+
"retainSessions",
|
|
2231
|
+
"autoReflect",
|
|
2232
|
+
"autoSeed",
|
|
2233
|
+
"codebaseSurvey",
|
|
2234
|
+
"autoUpdate",
|
|
2235
|
+
"manageBankConfig"
|
|
2236
|
+
]);
|
|
2237
|
+
var ENV_LISTS = /* @__PURE__ */ new Set(["retainTags", "optInPaths"]);
|
|
2238
|
+
var ENV_NUMBERS = /* @__PURE__ */ new Set([
|
|
2239
|
+
"apiPort",
|
|
2240
|
+
"daemonIdleTimeout",
|
|
2241
|
+
"maxParallelRetains",
|
|
2242
|
+
"reflectTimeoutMs",
|
|
2243
|
+
"reflectToolTimeoutMs",
|
|
2244
|
+
"injectTimeoutMs",
|
|
2245
|
+
"pageSearchLimit",
|
|
2246
|
+
"pageRefreshEveryTurns",
|
|
2247
|
+
"seedLimit",
|
|
2248
|
+
"surveyBudgetUsd",
|
|
2249
|
+
"surveyRefreshCommits"
|
|
2250
|
+
]);
|
|
2251
|
+
function readEnvConfig(env = process.env) {
|
|
2252
|
+
const out = {};
|
|
2253
|
+
for (const [key, name] of Object.entries(ENV_KEYS)) {
|
|
2254
|
+
const value = env[name];
|
|
2255
|
+
if (value === void 0 || value.trim() === "") continue;
|
|
2256
|
+
if (ENV_BOOLEANS.has(key)) {
|
|
2257
|
+
out[key] = ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
|
2258
|
+
} else if (ENV_LISTS.has(key)) {
|
|
2259
|
+
const items = value.split(",").map((v) => v.trim()).filter(Boolean);
|
|
2260
|
+
if (items.length) out[key] = items;
|
|
2261
|
+
} else if (ENV_NUMBERS.has(key)) {
|
|
2262
|
+
const n = Number(value);
|
|
2263
|
+
if (Number.isFinite(n)) out[key] = n;
|
|
2264
|
+
else console.error(`hindsight: ignoring ${name}=${value} \u2014 not a number`);
|
|
2265
|
+
} else {
|
|
2266
|
+
out[key] = value;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
return out;
|
|
2270
|
+
}
|
|
2271
|
+
function loadConfig(opts = {}) {
|
|
2272
|
+
const o = typeof opts === "string" ? { path: opts } : opts;
|
|
2273
|
+
const withEnv = applyLayer({}, readEnvConfig(), o.harness);
|
|
2274
|
+
const raw = applyLayer(withEnv, readRaw(o.path ?? CONFIG_PATH), o.harness);
|
|
2275
|
+
if (!raw.harness && o.harness) raw.harness = o.harness;
|
|
2276
|
+
const managed = managedSettings(o.harness);
|
|
2277
|
+
if (raw.apiUrl !== void 0 && raw.apiUrl.replace(/\/+$/, "") !== managed.apiUrl || raw.serverMode !== void 0 && raw.serverMode !== "self-hosted") throw new AccessDeniedError();
|
|
2278
|
+
return { ...resolveConfig(raw), ...managed };
|
|
2279
|
+
}
|
|
2280
|
+
var BANK_OVERRIDE_EXCLUDED = [
|
|
2281
|
+
"bankId",
|
|
2282
|
+
"bankIdTemplate",
|
|
2283
|
+
"mapPathToBank",
|
|
2284
|
+
"dynamicBankId",
|
|
2285
|
+
"resolveWorktrees",
|
|
2286
|
+
// Approval is decided BEFORE the bank is resolved, so a `banks.<id>` section naming these could
|
|
2287
|
+
// only ever arrive too late to matter — strip them rather than let them read as effective.
|
|
2288
|
+
"optInOnly",
|
|
2289
|
+
"optInPaths",
|
|
2290
|
+
"harness",
|
|
2291
|
+
"routerHarness",
|
|
2292
|
+
"toolPrefix",
|
|
2293
|
+
"apiUrl",
|
|
2294
|
+
"apiToken",
|
|
2295
|
+
"serverMode",
|
|
2296
|
+
"autoUpdate"
|
|
2297
|
+
];
|
|
2298
|
+
function applyBankConfig(cfg2, resolvedId, directory) {
|
|
2299
|
+
if (directory !== void 0 && !isOptedIn(cfg2, directory))
|
|
2300
|
+
return { cfg: { ...cfg2, ...managedSettings(cfg2.routerHarness), disabled: true }, bankId: resolvedId };
|
|
2301
|
+
const byPath = directory === void 0 ? void 0 : pathSection(cfg2, cfg2.paths, directory);
|
|
2302
|
+
if (byPath) {
|
|
2303
|
+
const { bank: _rename, ...rest } = overrideFields(byPath);
|
|
2304
|
+
cfg2 = { ...cfg2, ...resolvePartial(cfg2, rest) };
|
|
2305
|
+
}
|
|
2306
|
+
const section = cfg2.banks[resolvedId];
|
|
2307
|
+
if (!section) return { cfg: { ...cfg2, ...managedSettings(cfg2.routerHarness) }, bankId: resolvedId };
|
|
2308
|
+
const safe = overrideFields(section);
|
|
2309
|
+
const bankId = resolvedId;
|
|
2310
|
+
delete safe.bank;
|
|
2311
|
+
return { cfg: { ...cfg2, ...resolvePartial(cfg2, safe), ...managedSettings(cfg2.routerHarness) }, bankId };
|
|
2312
|
+
}
|
|
2313
|
+
function overrideFields(section) {
|
|
2314
|
+
const safe = { ...section };
|
|
2315
|
+
for (const k of BANK_OVERRIDE_EXCLUDED) delete safe[k];
|
|
2316
|
+
delete safe.banks;
|
|
2317
|
+
delete safe.paths;
|
|
2318
|
+
return safe;
|
|
2319
|
+
}
|
|
2320
|
+
function resolvePartial(cfg2, patch) {
|
|
2321
|
+
const full = resolveConfig(patch);
|
|
2322
|
+
const out = {};
|
|
2323
|
+
for (const key of Object.keys(patch)) {
|
|
2324
|
+
if (key in full)
|
|
2325
|
+
out[key] = full[key];
|
|
2326
|
+
}
|
|
2327
|
+
if ("autoReflect" in patch && !("autoInject" in patch)) out.autoInject = full.autoInject;
|
|
2328
|
+
if ("serverMode" in patch || "apiUrl" in patch || "apiPort" in patch) {
|
|
2329
|
+
const conn = resolveConfig({
|
|
2330
|
+
serverMode: patch.serverMode ?? cfg2.serverMode,
|
|
2331
|
+
apiPort: patch.apiPort ?? cfg2.apiPort,
|
|
2332
|
+
apiUrl: patch.apiUrl ?? (cfg2.serverMode === "daemon" ? void 0 : cfg2.apiUrl)
|
|
2333
|
+
});
|
|
2334
|
+
out.serverMode = conn.serverMode;
|
|
2335
|
+
out.apiUrl = conn.apiUrl;
|
|
2336
|
+
out.apiPort = conn.apiPort;
|
|
2337
|
+
}
|
|
2338
|
+
return out;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// src/core/status.ts
|
|
2342
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
2343
|
+
|
|
2344
|
+
// src/core/git.ts
|
|
2345
|
+
import { execFileSync } from "child_process";
|
|
2346
|
+
import { resolve as resolve3 } from "path";
|
|
2347
|
+
function git(repo, ...args) {
|
|
2348
|
+
return execFileSync("git", ["-C", repo, "-c", "log.showSignature=false", ...args], {
|
|
2349
|
+
encoding: "utf8",
|
|
2350
|
+
maxBuffer: 1 << 28,
|
|
2351
|
+
windowsHide: true,
|
|
2352
|
+
// execFileSync otherwise forwards failed git stderr into the host's TUI,
|
|
2353
|
+
// even when callers catch the error. Keep diagnostics on the error instead.
|
|
2354
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
function repoNameOf(repo) {
|
|
2358
|
+
return projectNameOf(resolve3(repo));
|
|
2359
|
+
}
|
|
2360
|
+
function commitsSince(dir, sinceSha) {
|
|
2361
|
+
if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(sinceSha)) return null;
|
|
2362
|
+
try {
|
|
2363
|
+
const n = Number.parseInt(git(dir, "rev-list", "--count", `${sinceSha}..HEAD`).trim(), 10);
|
|
2364
|
+
return Number.isFinite(n) ? n : null;
|
|
2365
|
+
} catch {
|
|
2366
|
+
return null;
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// src/core/knowledge-injection.ts
|
|
2371
|
+
function parsePageList(raw) {
|
|
2372
|
+
const items = raw?.items;
|
|
2373
|
+
if (!Array.isArray(items)) return [];
|
|
2374
|
+
const out = [];
|
|
2375
|
+
for (const it of items) {
|
|
2376
|
+
const id = it?.id;
|
|
2377
|
+
const name = it?.name;
|
|
2378
|
+
if (typeof id === "string" && typeof name === "string") out.push({ id, title: name });
|
|
2379
|
+
}
|
|
2380
|
+
return out;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
// src/core/survey.ts
|
|
2384
|
+
import { execFileSync as execFileSync2, spawn as realSpawn3 } from "child_process";
|
|
2385
|
+
import { createHash as createHash4 } from "crypto";
|
|
2386
|
+
import { existsSync as existsSync2 } from "fs";
|
|
2387
|
+
import { homedir as homedir4, tmpdir } from "os";
|
|
2388
|
+
import { dirname as dirname5, join as join9 } from "path";
|
|
2389
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2390
|
+
|
|
2391
|
+
// src/core/survey-lease.ts
|
|
2392
|
+
import { spawn as realSpawn2 } from "child_process";
|
|
2393
|
+
import { randomUUID } from "crypto";
|
|
2394
|
+
import {
|
|
2395
|
+
mkdirSync as mkdirSync2,
|
|
2396
|
+
mkdtempSync,
|
|
2397
|
+
readdirSync,
|
|
2398
|
+
renameSync as renameSync2,
|
|
2399
|
+
rmdirSync,
|
|
2400
|
+
rmSync,
|
|
2401
|
+
statSync as statSync2,
|
|
2402
|
+
unlinkSync,
|
|
2403
|
+
utimesSync,
|
|
2404
|
+
writeFileSync
|
|
2405
|
+
} from "fs";
|
|
2406
|
+
import { join as join8 } from "path";
|
|
2407
|
+
|
|
2408
|
+
// src/core/survey.ts
|
|
2409
|
+
var SURVEY_DOC_IDS = [
|
|
2410
|
+
"repository-component-map",
|
|
2411
|
+
"repository-core-concepts",
|
|
2412
|
+
"repository-conventions-and-patterns",
|
|
2413
|
+
"repository-tech-stack-and-features"
|
|
2414
|
+
];
|
|
2415
|
+
|
|
2416
|
+
// src/core/status.ts
|
|
2417
|
+
function unavailable(error) {
|
|
2418
|
+
if (isAuthorizationError(error)) throw error;
|
|
2419
|
+
return null;
|
|
2420
|
+
}
|
|
2421
|
+
var DEEPEN_DIFF_TARGET = 300;
|
|
2422
|
+
function commitCount(repoDir) {
|
|
2423
|
+
try {
|
|
2424
|
+
const out = execFileSync3("git", ["-C", repoDir, "rev-list", "--count", "HEAD"], {
|
|
2425
|
+
encoding: "utf8",
|
|
2426
|
+
windowsHide: true,
|
|
2427
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2428
|
+
// failed probes must not write into the host TUI
|
|
2429
|
+
});
|
|
2430
|
+
return Number(out.trim()) || 0;
|
|
2431
|
+
} catch {
|
|
2432
|
+
return null;
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
async function syncStatus(client2, bank, repoDir) {
|
|
2436
|
+
const gitIds = await client2.listDocumentIds("source:git", "all_strict");
|
|
2437
|
+
const chatIds = await client2.listDocumentIds("source:chat", "all_strict").catch((error) => {
|
|
2438
|
+
unavailable(error);
|
|
2439
|
+
return /* @__PURE__ */ new Set();
|
|
2440
|
+
});
|
|
2441
|
+
const pages = parsePageList(await client2.listPages().catch(unavailable));
|
|
2442
|
+
const activeOps = await client2.activeOperations().catch(unavailable);
|
|
2443
|
+
const uploads = await client2.listDocumentIds("source:upload", "all_strict").catch((error) => {
|
|
2444
|
+
unavailable(error);
|
|
2445
|
+
return /* @__PURE__ */ new Set();
|
|
2446
|
+
});
|
|
2447
|
+
const surveyDocs = SURVEY_DOC_IDS.filter((id) => uploads.has(id)).length;
|
|
2448
|
+
let surveyBaseline = null;
|
|
2449
|
+
let surveyCommitsBehind = null;
|
|
2450
|
+
if (repoDir) {
|
|
2451
|
+
try {
|
|
2452
|
+
const markers = await client2.listDocumentIds("source:survey-baseline", "all_strict");
|
|
2453
|
+
let best;
|
|
2454
|
+
for (const id of markers) {
|
|
2455
|
+
const sha = id.replace(/^survey-baseline:/, "");
|
|
2456
|
+
const behind = commitsSince(repoDir, sha);
|
|
2457
|
+
if (behind !== null && (!best || behind < best.behind)) best = { sha, behind };
|
|
2458
|
+
}
|
|
2459
|
+
if (best) {
|
|
2460
|
+
surveyBaseline = best.sha;
|
|
2461
|
+
surveyCommitsBehind = best.behind;
|
|
2462
|
+
}
|
|
2463
|
+
} catch (error) {
|
|
2464
|
+
unavailable(error);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
const repoName = repoDir ? repoNameOf(repoDir) : void 0;
|
|
2468
|
+
const gitlogPresent = repoName ? gitIds.has(`gitlog:${repoName}`) : [...gitIds].some((id) => id.startsWith("gitlog:"));
|
|
2469
|
+
const gitDiffDocs = [...gitIds].filter((id) => id.startsWith("git:")).length;
|
|
2470
|
+
const commits = repoDir ? commitCount(repoDir) : null;
|
|
2471
|
+
const gitDiffTarget = commits === null ? null : Math.min(DEEPEN_DIFF_TARGET, commits);
|
|
2472
|
+
return {
|
|
2473
|
+
bank,
|
|
2474
|
+
gitlogPresent,
|
|
2475
|
+
gitDiffDocs,
|
|
2476
|
+
gitDiffTarget,
|
|
2477
|
+
chatDocs: chatIds.size,
|
|
2478
|
+
pagesCount: pages.length,
|
|
2479
|
+
surveyBaseline,
|
|
2480
|
+
surveyDocs,
|
|
2481
|
+
surveyCommitsBehind,
|
|
2482
|
+
activeOps,
|
|
2483
|
+
// Older Hindsight servers have no page surface. They are still fully usable for the
|
|
2484
|
+
// legacy bank/retain pipeline, so page absence must not make syncStatus permanently false.
|
|
2485
|
+
synced: gitlogPresent && (client2.knowledgePagesSupported === false || pages.length > 0) && activeOps === 0
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
// src/status.ts
|
|
2490
|
+
function arg(name) {
|
|
2491
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
2492
|
+
if (i >= 0 && i + 1 < process.argv.length) return process.argv[i + 1];
|
|
2493
|
+
return void 0;
|
|
2494
|
+
}
|
|
2495
|
+
var REPO = arg("repo");
|
|
2496
|
+
var cfg0 = loadConfig({ harness: arg("harness") ?? void 0, path: arg("config") });
|
|
2497
|
+
var BANK = arg("bank") ?? (REPO ? deriveBankIdOrSkip(cfg0, REPO, arg("harness") ?? cfg0.harness) : cfg0.bankId) ?? void 0;
|
|
2498
|
+
if (!BANK) {
|
|
2499
|
+
console.error("usage: node status.js --repo <path> [--bank <id>] [--api-url U]");
|
|
2500
|
+
process.exit(1);
|
|
2501
|
+
}
|
|
2502
|
+
var resolved0 = BANK ? applyBankConfig(cfg0, BANK, REPO) : { cfg: cfg0, bankId: BANK };
|
|
2503
|
+
var cfg = resolved0.cfg;
|
|
2504
|
+
var FINAL_BANK = resolved0.bankId ?? BANK;
|
|
2505
|
+
var client = new HindsightClient({
|
|
2506
|
+
routerHarness: cfg.routerHarness,
|
|
2507
|
+
apiUrl: cfg.apiUrl,
|
|
2508
|
+
apiToken: arg("api-token") ?? cfg.apiToken,
|
|
2509
|
+
bank: FINAL_BANK,
|
|
2510
|
+
maxParallelRetains: cfg.maxParallelRetains,
|
|
2511
|
+
observationScopes: cfg.observationScopes
|
|
2512
|
+
});
|
|
2513
|
+
syncStatus(client, FINAL_BANK, REPO).then((s) => console.log(JSON.stringify(s))).catch((e) => {
|
|
2514
|
+
console.error("status failed:", e.message || e);
|
|
2515
|
+
process.exit(1);
|
|
2516
|
+
});
|