hippo-memory 1.57.0 → 1.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/agent-memories/claude-code.js +1 -1
- package/dist/agent-memories/gemini.js +1 -1
- package/dist/api-errors.d.ts +27 -0
- package/dist/api-errors.js +37 -0
- package/dist/api.d.ts +5 -5
- package/dist/api.js +40 -47
- package/dist/audit.d.ts +4 -0
- package/dist/audit.js +11 -0
- package/dist/autolearn.d.ts +1 -1
- package/dist/autolearn.js +7 -5
- package/dist/capture-contract.d.ts +47 -0
- package/dist/capture-contract.js +49 -0
- package/dist/capture-error.js +2 -1
- package/dist/capture.d.ts +0 -13
- package/dist/capture.js +5 -66
- package/dist/cli/shared.js +10 -6
- package/dist/cli.js +100 -39
- package/dist/client.js +9 -0
- package/dist/codex-patch.js +1 -1
- package/dist/compaction-record.d.ts +1 -1
- package/dist/compaction-record.js +3 -2
- package/dist/config.d.ts +5 -0
- package/dist/config.js +17 -0
- package/dist/connectors/github/dlq.js +5 -2
- package/dist/connectors/github/octokit-client.js +4 -2
- package/dist/connectors/slack/dlq.js +6 -2
- package/dist/connectors/slack/web-client.js +7 -5
- package/dist/consolidate.d.ts +10 -0
- package/dist/consolidate.js +36 -34
- package/dist/customer-notes.js +14 -13
- package/dist/dag.js +3 -2
- package/dist/dashboard.js +1 -1
- package/dist/db.d.ts +12 -0
- package/dist/db.js +62 -1
- package/dist/decisions.js +9 -8
- package/dist/doctor.js +5 -0
- package/dist/embedding-provider.js +3 -3
- package/dist/embeddings.d.ts +4 -4
- package/dist/embeddings.js +72 -16
- package/dist/extract.js +3 -2
- package/dist/http-retry.d.ts +21 -0
- package/dist/http-retry.js +50 -0
- package/dist/http-util.d.ts +8 -0
- package/dist/http-util.js +10 -0
- package/dist/importers.d.ts +2 -0
- package/dist/importers.js +16 -5
- package/dist/incidents.js +11 -10
- package/dist/judgment.js +10 -17
- package/dist/log.d.ts +25 -0
- package/dist/log.js +48 -0
- package/dist/mcp/server.js +52 -24
- package/dist/mcp/tool-args.d.ts +21 -0
- package/dist/mcp/tool-args.js +80 -0
- package/dist/memory.js +3 -2
- package/dist/overlap-index.d.ts +7 -0
- package/dist/overlap-index.js +38 -0
- package/dist/pilot-arm.d.ts +9 -0
- package/dist/pilot-arm.js +47 -0
- package/dist/policies.js +12 -11
- package/dist/predictions.js +9 -8
- package/dist/processes.js +14 -13
- package/dist/project-briefs.js +16 -15
- package/dist/project-identity.d.ts +1 -1
- package/dist/project-identity.js +25 -1
- package/dist/raw-archive.js +7 -6
- package/dist/recall-scope.d.ts +2 -1
- package/dist/recall-scope.js +2 -1
- package/dist/refine-llm.js +3 -2
- package/dist/reject-flow.js +6 -9
- package/dist/rejection.d.ts +2 -1
- package/dist/rejection.js +2 -1
- package/dist/search.js +14 -2
- package/dist/secret-detect.d.ts +13 -1
- package/dist/secret-detect.js +33 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +180 -409
- package/dist/session-digest.js +2 -1
- package/dist/shared.js +7 -6
- package/dist/skills.js +15 -14
- package/dist/store.js +4 -4
- package/dist/token-ledger.d.ts +4 -2
- package/dist/token-ledger.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/dist/connectors/slack/ratelimit.d.ts +0 -9
- package/dist/connectors/slack/ratelimit.js +0 -18
package/dist/embeddings.js
CHANGED
|
@@ -13,6 +13,7 @@ import { initializeParticle, savePhysicsState, loadPhysicsState, resetAllPhysics
|
|
|
13
13
|
import { loadConfig } from './config.js';
|
|
14
14
|
import { resolveEmbeddingProvider } from './embedding-provider.js';
|
|
15
15
|
import { redactSecretsStrict } from './secret-detect.js';
|
|
16
|
+
import { log } from './log.js';
|
|
16
17
|
// Use createRequire for synchronous module resolution check in ESM
|
|
17
18
|
const _require = createRequire(import.meta.url);
|
|
18
19
|
// Cached availability check
|
|
@@ -270,6 +271,9 @@ async function rebuildEmbeddingIndex(entries, provider) {
|
|
|
270
271
|
if (vec && vec.length > 0) {
|
|
271
272
|
rebuilt[entries[i].id] = vec;
|
|
272
273
|
}
|
|
274
|
+
else {
|
|
275
|
+
noteSkippedEmbedding(entries[i].id);
|
|
276
|
+
}
|
|
273
277
|
}
|
|
274
278
|
return rebuilt;
|
|
275
279
|
}
|
|
@@ -283,10 +287,15 @@ function resetPhysicsFromIndex(hippoRoot, entries, index) {
|
|
|
283
287
|
closeHippoDb(db);
|
|
284
288
|
}
|
|
285
289
|
}
|
|
286
|
-
catch {
|
|
287
|
-
//
|
|
290
|
+
catch (err) {
|
|
291
|
+
// Best effort: retrieval still falls back without physics state.
|
|
292
|
+
log.warn(`physics reset after reindex failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
288
293
|
}
|
|
289
294
|
}
|
|
295
|
+
/** A provider's `[]` row is a swallowed per-item failure; name the memory so the gap can be traced. */
|
|
296
|
+
function noteSkippedEmbedding(id) {
|
|
297
|
+
log.warn('memory not embedded; the next embed run retries it', { id });
|
|
298
|
+
}
|
|
290
299
|
/**
|
|
291
300
|
* Get an embedding vector for a piece of text.
|
|
292
301
|
* Returns an empty array if transformers is not available or fails.
|
|
@@ -314,7 +323,9 @@ export async function getEmbedding(text, model = DEFAULT_EMBEDDING_MODEL, role)
|
|
|
314
323
|
// pipeline's documented tensor output shape.
|
|
315
324
|
return Array.from(output.data);
|
|
316
325
|
}
|
|
317
|
-
catch {
|
|
326
|
+
catch (err) {
|
|
327
|
+
// The caller sees `[]` and names the memory; the reason only shows at debug.
|
|
328
|
+
log.debug(`local embedding failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
318
329
|
return [];
|
|
319
330
|
}
|
|
320
331
|
}
|
|
@@ -340,23 +351,66 @@ export function cosineSimilarity(a, b) {
|
|
|
340
351
|
return Math.min(1, Math.max(-1, dot / denom));
|
|
341
352
|
}
|
|
342
353
|
const EMBEDDINGS_FILE = 'embeddings.json';
|
|
354
|
+
// Never equal to a real index identity, so the next embed run treats it as a model change and rebuilds every vector.
|
|
355
|
+
const QUARANTINED_INDEX_IDENTITY = 'quarantined-corrupt-index';
|
|
356
|
+
function isErrnoCode(err, code) {
|
|
357
|
+
return err instanceof Error && 'code' in err && err.code === code;
|
|
358
|
+
}
|
|
359
|
+
function parseEmbeddingIndex(raw) {
|
|
360
|
+
try {
|
|
361
|
+
const parsed = JSON.parse(raw);
|
|
362
|
+
// SAFETY: saveEmbeddingIndex is the only writer and always writes this shape; anything that is not an object is corrupt.
|
|
363
|
+
return parsed instanceof Object && !Array.isArray(parsed) ? parsed : null;
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Move a corrupt index aside and flag a full rebuild, so no later save can write over the only copy of those bytes. */
|
|
370
|
+
function quarantineCorruptIndex(hippoRoot, fp) {
|
|
371
|
+
const aside = `${fp}.corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`;
|
|
372
|
+
try {
|
|
373
|
+
fs.renameSync(fp, aside);
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
if (isErrnoCode(err, 'ENOENT'))
|
|
377
|
+
return;
|
|
378
|
+
// A rename blocked by an open handle (Windows) still gets a copy kept; if the copy fails too, the throw stops the save.
|
|
379
|
+
fs.copyFileSync(fp, aside, fs.constants.COPYFILE_EXCL);
|
|
380
|
+
}
|
|
381
|
+
log.error(`${EMBEDDINGS_FILE} could not be parsed; kept it as ${path.basename(aside)} and the next embed rebuilds the index`, { hippoRoot });
|
|
382
|
+
try {
|
|
383
|
+
const db = openHippoDb(hippoRoot);
|
|
384
|
+
try {
|
|
385
|
+
setMeta(db, EMBEDDING_MODEL_META_KEY, QUARANTINED_INDEX_IDENTITY);
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
closeHippoDb(db);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
log.warn(`could not flag the embedding index for rebuild; run 'hippo embed' to restore vectors (${err instanceof Error ? err.message : String(err)})`, { hippoRoot });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
343
395
|
/**
|
|
344
|
-
* Load the cached embedding index
|
|
345
|
-
* Returns an empty object if the file doesn't exist or is corrupt.
|
|
396
|
+
* Load the cached embedding index; `{}` when the file is missing. A corrupt file is moved aside and rebuilt on the next embed; any other read error throws, so nothing saves over an index it could not read.
|
|
346
397
|
*/
|
|
347
398
|
export function loadEmbeddingIndex(hippoRoot) {
|
|
348
399
|
const fp = path.join(hippoRoot, EMBEDDINGS_FILE);
|
|
349
|
-
|
|
350
|
-
return {};
|
|
400
|
+
let raw;
|
|
351
401
|
try {
|
|
352
|
-
|
|
353
|
-
// this exact shape; a corrupt or foreign file is caught by the try/catch
|
|
354
|
-
// below and treated as an empty index.
|
|
355
|
-
return JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
402
|
+
raw = fs.readFileSync(fp, 'utf8');
|
|
356
403
|
}
|
|
357
|
-
catch {
|
|
358
|
-
|
|
404
|
+
catch (err) {
|
|
405
|
+
if (isErrnoCode(err, 'ENOENT'))
|
|
406
|
+
return {};
|
|
407
|
+
throw err;
|
|
359
408
|
}
|
|
409
|
+
const index = parseEmbeddingIndex(raw);
|
|
410
|
+
if (index)
|
|
411
|
+
return index;
|
|
412
|
+
quarantineCorruptIndex(hippoRoot, fp);
|
|
413
|
+
return {};
|
|
360
414
|
}
|
|
361
415
|
/**
|
|
362
416
|
* Save the embedding index to disk.
|
|
@@ -543,10 +597,9 @@ export async function embedMemory(hippoRoot, entry, model) {
|
|
|
543
597
|
/**
|
|
544
598
|
* Embed all entries in hippoRoot that don't already have cached vectors.
|
|
545
599
|
* Prunes orphaned embeddings for memories that no longer exist.
|
|
546
|
-
* Returns the count of newly embedded entries.
|
|
600
|
+
* Returns the count of newly embedded entries. `provider` defaults to the store's configured one.
|
|
547
601
|
*/
|
|
548
|
-
export async function embedAll(hippoRoot, model) {
|
|
549
|
-
const provider = resolveEmbeddingProvider(hippoRoot, { model });
|
|
602
|
+
export async function embedAll(hippoRoot, model, provider = resolveEmbeddingProvider(hippoRoot, { model })) {
|
|
550
603
|
if (!provider.isAvailable()) {
|
|
551
604
|
// A configured (non-disabled) API provider with a missing key is a
|
|
552
605
|
// misconfiguration, not a no-op: surface it so programmatic callers of the
|
|
@@ -618,6 +671,9 @@ export async function embedAll(hippoRoot, model) {
|
|
|
618
671
|
dirty = true;
|
|
619
672
|
chunkDirty = true;
|
|
620
673
|
}
|
|
674
|
+
else {
|
|
675
|
+
noteSkippedEmbedding(chunk[j].id);
|
|
676
|
+
}
|
|
621
677
|
}
|
|
622
678
|
if (chunkDirty)
|
|
623
679
|
saveEmbeddingIndex(hippoRoot, index);
|
package/dist/extract.js
CHANGED
|
@@ -3,6 +3,7 @@ import { writeEntry } from './store.js';
|
|
|
3
3
|
import { loadConfig } from './config.js';
|
|
4
4
|
import { RejectedValueError } from './rejection.js';
|
|
5
5
|
import { redactSecrets } from './secret-detect.js';
|
|
6
|
+
import { fetchWithRetry, llmTimeoutMs } from './http-retry.js';
|
|
6
7
|
import { neverAutoShareTags } from './shared.js';
|
|
7
8
|
function isJsonString(value) {
|
|
8
9
|
return typeof value === 'string';
|
|
@@ -24,7 +25,7 @@ export async function extractFacts(text, opts) {
|
|
|
24
25
|
const fetchFn = opts.fetcher ?? fetch;
|
|
25
26
|
let res;
|
|
26
27
|
try {
|
|
27
|
-
res = await
|
|
28
|
+
res = await fetchWithRetry('https://api.anthropic.com/v1/messages', {
|
|
28
29
|
method: 'POST',
|
|
29
30
|
headers: {
|
|
30
31
|
'content-type': 'application/json',
|
|
@@ -36,7 +37,7 @@ export async function extractFacts(text, opts) {
|
|
|
36
37
|
max_tokens: 1200,
|
|
37
38
|
messages: [{ role: 'user', content: EXTRACTION_PROMPT + redactSecrets(text) }],
|
|
38
39
|
}),
|
|
39
|
-
});
|
|
40
|
+
}, { timeoutMs: llmTimeoutMs(), fetchFn });
|
|
40
41
|
}
|
|
41
42
|
catch (err) {
|
|
42
43
|
opts.onError?.(`request failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** One retry policy for outbound HTTP: a timeout on every attempt, and backoff on 429 and 5xx only. */
|
|
2
|
+
export interface RetryPolicy {
|
|
3
|
+
/** Per-attempt limit; a stalled peer ends as a thrown `TimeoutError`, never a hang. */
|
|
4
|
+
timeoutMs: number;
|
|
5
|
+
/** Total attempts including the first. */
|
|
6
|
+
attempts?: number;
|
|
7
|
+
baseDelayMs?: number;
|
|
8
|
+
/** A Retry-After longer than this hands the response back, so a caller with its own long pause keeps it. */
|
|
9
|
+
maxDelayMs?: number;
|
|
10
|
+
fetchFn?: typeof fetch;
|
|
11
|
+
sleep?: (ms: number) => Promise<void>;
|
|
12
|
+
random?: () => number;
|
|
13
|
+
}
|
|
14
|
+
/** LLM calls (consolidation refine, DAG summaries, fact extraction) share one budget; `HIPPO_LLM_TIMEOUT_MS` overrides it. */
|
|
15
|
+
export declare function llmTimeoutMs(): number;
|
|
16
|
+
export declare function isRetryableStatus(status: number): boolean;
|
|
17
|
+
/** Retry-After as milliseconds: delta-seconds or an HTTP date; null when absent or unreadable. */
|
|
18
|
+
export declare function parseRetryAfterMs(header: string | null, now?: number): number | null;
|
|
19
|
+
/** `fetch` with a per-attempt timeout and up to `attempts` tries on 429 and 5xx; the last response comes back as is and transport errors throw at once. */
|
|
20
|
+
export declare function fetchWithRetry(url: string | URL, init: RequestInit, policy: RetryPolicy): Promise<Response>;
|
|
21
|
+
//# sourceMappingURL=http-retry.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** One retry policy for outbound HTTP: a timeout on every attempt, and backoff on 429 and 5xx only. */
|
|
2
|
+
const DEFAULT_ATTEMPTS = 3;
|
|
3
|
+
const DEFAULT_BASE_DELAY_MS = 250;
|
|
4
|
+
const DEFAULT_MAX_DELAY_MS = 8_000;
|
|
5
|
+
const DEFAULT_LLM_TIMEOUT_MS = 60_000;
|
|
6
|
+
/** LLM calls (consolidation refine, DAG summaries, fact extraction) share one budget; `HIPPO_LLM_TIMEOUT_MS` overrides it. */
|
|
7
|
+
export function llmTimeoutMs() {
|
|
8
|
+
const parsed = Number.parseInt(process.env.HIPPO_LLM_TIMEOUT_MS ?? '', 10);
|
|
9
|
+
return parsed > 0 ? parsed : DEFAULT_LLM_TIMEOUT_MS;
|
|
10
|
+
}
|
|
11
|
+
export function isRetryableStatus(status) {
|
|
12
|
+
return status === 429 || (status >= 500 && status <= 599);
|
|
13
|
+
}
|
|
14
|
+
/** Retry-After as milliseconds: delta-seconds or an HTTP date; null when absent or unreadable. */
|
|
15
|
+
export function parseRetryAfterMs(header, now = Date.now()) {
|
|
16
|
+
if (header === null || header.trim() === '')
|
|
17
|
+
return null;
|
|
18
|
+
const seconds = Number(header);
|
|
19
|
+
if (Number.isFinite(seconds))
|
|
20
|
+
return Math.max(0, seconds * 1000);
|
|
21
|
+
const at = Date.parse(header);
|
|
22
|
+
return Number.isNaN(at) ? null : Math.max(0, at - now);
|
|
23
|
+
}
|
|
24
|
+
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
|
+
/** `fetch` with a per-attempt timeout and up to `attempts` tries on 429 and 5xx; the last response comes back as is and transport errors throw at once. */
|
|
26
|
+
export async function fetchWithRetry(url, init, policy) {
|
|
27
|
+
const fetchFn = policy.fetchFn ?? fetch;
|
|
28
|
+
const attempts = Math.max(1, policy.attempts ?? DEFAULT_ATTEMPTS);
|
|
29
|
+
const baseDelayMs = policy.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
30
|
+
const maxDelayMs = policy.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
31
|
+
const sleep = policy.sleep ?? realSleep;
|
|
32
|
+
const random = policy.random ?? Math.random;
|
|
33
|
+
for (let attempt = 1;; attempt++) {
|
|
34
|
+
const timeout = AbortSignal.timeout(policy.timeoutMs);
|
|
35
|
+
const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
|
|
36
|
+
const res = await fetchFn(url, { ...init, signal });
|
|
37
|
+
if (!isRetryableStatus(res.status) || attempt >= attempts)
|
|
38
|
+
return res;
|
|
39
|
+
const retryAfter = parseRetryAfterMs(res.headers.get('retry-after'));
|
|
40
|
+
if (retryAfter !== null && retryAfter > maxDelayMs)
|
|
41
|
+
return res;
|
|
42
|
+
// Jitter over the upper half keeps parallel callers from retrying in lockstep.
|
|
43
|
+
const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
|
|
44
|
+
const delay = retryAfter ?? ceiling / 2 + random() * (ceiling / 2);
|
|
45
|
+
// Frees the pooled socket before the next attempt.
|
|
46
|
+
await res.body?.cancel();
|
|
47
|
+
await sleep(delay);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=http-retry.js.map
|
package/dist/http-util.d.ts
CHANGED
|
@@ -13,6 +13,14 @@ export declare class HttpError extends Error {
|
|
|
13
13
|
}
|
|
14
14
|
export declare class BodyTooLargeError extends Error {
|
|
15
15
|
}
|
|
16
|
+
/** The status and client-facing message for one failed request. */
|
|
17
|
+
export interface ApiErrorReply {
|
|
18
|
+
status: number;
|
|
19
|
+
message: string;
|
|
20
|
+
}
|
|
21
|
+
export declare const INTERNAL_ERROR_MESSAGE = "internal server error";
|
|
22
|
+
/** Maps by class so rewording a message never moves a status; an untyped error is a 500 whose text stays in the server log. */
|
|
23
|
+
export declare function mapApiError<E>(err: E): ApiErrorReply;
|
|
16
24
|
export declare function sendJson<T>(res: ServerResponse, status: number, body: T): void;
|
|
17
25
|
/**
|
|
18
26
|
* Read the entire request body into a Buffer. Caps at MAX_BODY_BYTES to keep
|
package/dist/http-util.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ApiError } from './api-errors.js';
|
|
1
2
|
export function isJsonObjectRecord(value) {
|
|
2
3
|
return value !== undefined && value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
3
4
|
}
|
|
@@ -19,6 +20,15 @@ export class HttpError extends Error {
|
|
|
19
20
|
}
|
|
20
21
|
export class BodyTooLargeError extends Error {
|
|
21
22
|
}
|
|
23
|
+
export const INTERNAL_ERROR_MESSAGE = 'internal server error';
|
|
24
|
+
/** Maps by class so rewording a message never moves a status; an untyped error is a 500 whose text stays in the server log. */
|
|
25
|
+
export function mapApiError(err) {
|
|
26
|
+
if (err instanceof HttpError || err instanceof ApiError)
|
|
27
|
+
return { status: err.status, message: err.message };
|
|
28
|
+
if (err instanceof BodyTooLargeError)
|
|
29
|
+
return { status: 413, message: err.message };
|
|
30
|
+
return { status: 500, message: INTERNAL_ERROR_MESSAGE };
|
|
31
|
+
}
|
|
22
32
|
export function sendJson(res, status, body) {
|
|
23
33
|
res.writeHead(status, JSON_HEADERS);
|
|
24
34
|
res.end(JSON.stringify(body));
|
package/dist/importers.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface ImportResult {
|
|
|
20
20
|
/** K1 vault import: rows archived this run (changed + source-deleted). In a
|
|
21
21
|
* dryRun this is the would-be count (a true deletion-sync preview). */
|
|
22
22
|
archived?: number;
|
|
23
|
+
/** Entries stored with secret-shaped text redacted; optional for the same published-surface reason as `rejected`. */
|
|
24
|
+
redacted?: number;
|
|
23
25
|
entries: MemoryEntry[];
|
|
24
26
|
}
|
|
25
27
|
export interface ImportOptions {
|
package/dist/importers.js
CHANGED
|
@@ -13,6 +13,7 @@ import { remember, archiveRaw, isPrivateScope } from './api.js';
|
|
|
13
13
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
14
14
|
import { RejectedValueError, checkRejectionGuard } from './rejection.js';
|
|
15
15
|
import { loadConfig } from './config.js';
|
|
16
|
+
import { vetSecrets } from './secret-detect.js';
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
// Shared core: dedup + write
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
@@ -33,6 +34,7 @@ export function importEntries(chunks, source, tags, options) {
|
|
|
33
34
|
let imported = 0;
|
|
34
35
|
let skipped = 0;
|
|
35
36
|
let rejected = 0;
|
|
37
|
+
let redacted = 0;
|
|
36
38
|
const entries = [];
|
|
37
39
|
// AT1 P2 fix: a dry-run preview never called writeEntry, so it never
|
|
38
40
|
// checked tombstones either — every non-duplicate chunk counted as
|
|
@@ -42,7 +44,9 @@ export function importEntries(chunks, source, tags, options) {
|
|
|
42
44
|
const dryRunDb = options.dryRun ? openHippoDb(targetRoot) : null;
|
|
43
45
|
try {
|
|
44
46
|
for (const raw of chunks) {
|
|
45
|
-
const
|
|
47
|
+
const original = raw.trim();
|
|
48
|
+
const trimmed = vetSecrets(original, allTags, true).content;
|
|
49
|
+
const wasRedacted = trimmed !== original;
|
|
46
50
|
if (trimmed.length > 1000) {
|
|
47
51
|
console.error(`Warning: imported memory truncated from ${trimmed.length} to 1000 chars`);
|
|
48
52
|
}
|
|
@@ -109,8 +113,10 @@ export function importEntries(chunks, source, tags, options) {
|
|
|
109
113
|
}
|
|
110
114
|
entries.push(entry);
|
|
111
115
|
imported++;
|
|
116
|
+
if (wasRedacted)
|
|
117
|
+
redacted++;
|
|
112
118
|
}
|
|
113
|
-
return { total, imported, skipped, rejected, entries };
|
|
119
|
+
return { total, imported, skipped, rejected, redacted, entries };
|
|
114
120
|
}
|
|
115
121
|
finally {
|
|
116
122
|
if (dryRunDb)
|
|
@@ -430,6 +436,7 @@ export function importMarkdown(filePath, options) {
|
|
|
430
436
|
// AT1 P2 fix: `rejected` is now optional on ImportResult (compat) — tolerate
|
|
431
437
|
// undefined on either side of the accumulation.
|
|
432
438
|
rejected: (totalResult.rejected ?? 0) + (result.rejected ?? 0),
|
|
439
|
+
redacted: (totalResult.redacted ?? 0) + (result.redacted ?? 0),
|
|
433
440
|
entries: [...totalResult.entries, ...result.entries],
|
|
434
441
|
};
|
|
435
442
|
}
|
|
@@ -695,6 +702,7 @@ export function importVault(folderPath, options) {
|
|
|
695
702
|
let skipped = 0;
|
|
696
703
|
let rejected = 0;
|
|
697
704
|
let archived = 0;
|
|
705
|
+
let redacted = 0;
|
|
698
706
|
const entries = [];
|
|
699
707
|
const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
|
|
700
708
|
// AT1 P2 fix: dry-run never called remember(), so it never probed
|
|
@@ -796,7 +804,8 @@ export function importVault(folderPath, options) {
|
|
|
796
804
|
// remember() owns the actual write. We build an `echo` of the SAME content +
|
|
797
805
|
// tags via createMemory purely for the ImportResult, then reconcile its id to
|
|
798
806
|
// remember()'s real row id so entries[] reflects the row that landed.
|
|
799
|
-
const
|
|
807
|
+
const content = vetSecrets(body, tags, true).content;
|
|
808
|
+
const echo = createMemory(content, {
|
|
800
809
|
kind: 'raw',
|
|
801
810
|
tags,
|
|
802
811
|
scope,
|
|
@@ -815,7 +824,7 @@ export function importVault(folderPath, options) {
|
|
|
815
824
|
// loud each time via the rejected count.
|
|
816
825
|
try {
|
|
817
826
|
const result = remember(ctx, {
|
|
818
|
-
content
|
|
827
|
+
content,
|
|
819
828
|
kind: 'raw',
|
|
820
829
|
artifactRef,
|
|
821
830
|
owner: 'agent:vault-import',
|
|
@@ -848,6 +857,8 @@ export function importVault(folderPath, options) {
|
|
|
848
857
|
}
|
|
849
858
|
entries.push(echo);
|
|
850
859
|
imported++;
|
|
860
|
+
if (content !== body)
|
|
861
|
+
redacted++;
|
|
851
862
|
}
|
|
852
863
|
}
|
|
853
864
|
finally {
|
|
@@ -867,7 +878,7 @@ export function importVault(folderPath, options) {
|
|
|
867
878
|
archiveRaw(ctx, row.id, `source_deleted:${artifactRef}`);
|
|
868
879
|
}
|
|
869
880
|
}
|
|
870
|
-
return { total, imported, skipped, rejected, archived, entries };
|
|
881
|
+
return { total, imported, skipped, rejected, archived, redacted, entries };
|
|
871
882
|
}
|
|
872
883
|
/** Local tolerant JSON-array parse for the loader's `tags_json` column. The
|
|
873
884
|
* store's own `parseJsonArray` is not exported; this matches its contract
|
package/dist/incidents.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* the row, default `[]`. On save, every id must exist in the SAME tenant; a
|
|
26
26
|
* cross-tenant or nonexistent id is rejected (throw) before the insert.
|
|
27
27
|
*/
|
|
28
|
+
import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
|
|
28
29
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
29
30
|
import { writeEntry } from './store.js';
|
|
30
31
|
import { assertTenantId } from './tenant.js';
|
|
@@ -89,7 +90,7 @@ const INCIDENT_COLS = `
|
|
|
89
90
|
export function saveIncident(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
90
91
|
assertTenantId('saveIncident', tenantId);
|
|
91
92
|
if (!opts.incidentText)
|
|
92
|
-
throw new
|
|
93
|
+
throw new BadRequestError('saveIncident: incidentText is required');
|
|
93
94
|
const now = new Date().toISOString();
|
|
94
95
|
const content = opts.context
|
|
95
96
|
? `${opts.incidentText}\n\nContext: ${opts.context}`
|
|
@@ -118,7 +119,7 @@ export function saveIncident(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
118
119
|
// SAFETY: row shape matches the single `id` column named in the SELECT above.
|
|
119
120
|
const exists = db.prepare(`SELECT id FROM memories WHERE id = ? AND tenant_id = ?`).get(linkId, tenantId);
|
|
120
121
|
if (!exists) {
|
|
121
|
-
throw new
|
|
122
|
+
throw new NotFoundError(`saveIncident: linked memory ${linkId} not found for tenant ${tenantId}`);
|
|
122
123
|
}
|
|
123
124
|
validated.push(linkId);
|
|
124
125
|
}
|
|
@@ -164,7 +165,7 @@ export function saveIncident(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
164
165
|
export function resolveIncident(hippoRoot, tenantId, id, resolutionText, actor = 'cli') {
|
|
165
166
|
assertTenantId('resolveIncident', tenantId);
|
|
166
167
|
if (!resolutionText || !resolutionText.trim()) {
|
|
167
|
-
throw new
|
|
168
|
+
throw new BadRequestError('resolveIncident: resolutionText is required (non-empty)');
|
|
168
169
|
}
|
|
169
170
|
const now = new Date().toISOString();
|
|
170
171
|
const db = openHippoDb(hippoRoot);
|
|
@@ -180,15 +181,15 @@ export function resolveIncident(hippoRoot, tenantId, id, resolutionText, actor =
|
|
|
180
181
|
// SAFETY: row shape matches the single `status` column named in the SELECT above.
|
|
181
182
|
const existing = db.prepare(`SELECT status FROM incidents WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
|
|
182
183
|
if (!existing) {
|
|
183
|
-
throw new
|
|
184
|
+
throw new NotFoundError(`resolveIncident: incident ${id} not found for tenant ${tenantId}`);
|
|
184
185
|
}
|
|
185
|
-
throw new
|
|
186
|
+
throw new ConflictError(`resolveIncident: incident ${id} is not open (status='${existing.status}'); only open incidents can be resolved.`);
|
|
186
187
|
}
|
|
187
188
|
// SAFETY: row's shape matches the columns named in INCIDENT_COLS above.
|
|
188
189
|
const row = db.prepare(`SELECT ${INCIDENT_COLS} FROM incidents WHERE id = ? AND tenant_id = ?`)
|
|
189
190
|
.get(id, tenantId);
|
|
190
191
|
if (!row)
|
|
191
|
-
throw new
|
|
192
|
+
throw new NotFoundError(`resolveIncident: incident ${id} not found after UPDATE`);
|
|
192
193
|
appendAuditEvent(db, {
|
|
193
194
|
tenantId,
|
|
194
195
|
actor,
|
|
@@ -235,15 +236,15 @@ export function closeIncident(hippoRoot, tenantId, id, actor = 'cli') {
|
|
|
235
236
|
// SAFETY: row shape matches the single `status` column named in the SELECT above.
|
|
236
237
|
const existing = db.prepare(`SELECT status FROM incidents WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
|
|
237
238
|
if (!existing) {
|
|
238
|
-
throw new
|
|
239
|
+
throw new NotFoundError(`closeIncident: incident ${id} not found for tenant ${tenantId}`);
|
|
239
240
|
}
|
|
240
|
-
throw new
|
|
241
|
+
throw new ConflictError(`closeIncident: incident ${id} is already closed (status='${existing.status}'); only open or resolved incidents can be closed.`);
|
|
241
242
|
}
|
|
242
243
|
// SAFETY: row's shape matches the columns named in INCIDENT_COLS above.
|
|
243
244
|
const row = db.prepare(`SELECT ${INCIDENT_COLS} FROM incidents WHERE id = ? AND tenant_id = ?`)
|
|
244
245
|
.get(id, tenantId);
|
|
245
246
|
if (!row)
|
|
246
|
-
throw new
|
|
247
|
+
throw new NotFoundError(`closeIncident: incident ${id} not found after UPDATE`);
|
|
247
248
|
appendAuditEvent(db, {
|
|
248
249
|
tenantId,
|
|
249
250
|
actor,
|
|
@@ -289,7 +290,7 @@ export function loadIncidents(hippoRoot, tenantId, opts = {}) {
|
|
|
289
290
|
let rows;
|
|
290
291
|
if (opts.status) {
|
|
291
292
|
if (!VALID_INCIDENT_STATES.has(opts.status)) {
|
|
292
|
-
throw new
|
|
293
|
+
throw new BadRequestError(`loadIncidents: status must be one of ${Array.from(VALID_INCIDENT_STATES).join('|')}; got ${opts.status}`);
|
|
293
294
|
}
|
|
294
295
|
// SAFETY: rows' shape matches the columns named in INCIDENT_COLS above.
|
|
295
296
|
rows = db.prepare(`
|
package/dist/judgment.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/** Typed judgment over capture candidates via TypeSafe's Jev (System One).
|
|
2
2
|
* Regex picks WHAT is a candidate; it cannot say what is worth keeping, so
|
|
3
3
|
* every captured memory currently lands on a flat schema_fit of 0.5. */
|
|
4
|
+
import { fetchWithRetry } from './http-retry.js';
|
|
4
5
|
const ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
|
|
5
6
|
const DEFAULT_MODEL = 'jev-1.13.0';
|
|
6
7
|
const MAX_CONCURRENCY = 8;
|
|
7
|
-
const RETRY_STATUS = new Set([429, 529]);
|
|
8
8
|
const QUESTIONS = {
|
|
9
9
|
durable: {
|
|
10
10
|
type: 'noul',
|
|
@@ -48,11 +48,12 @@ function toConfidenceTier(kindConfidence) {
|
|
|
48
48
|
return 'observed';
|
|
49
49
|
return 'inferred';
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
/** Capture-time judging must not hold a write for long: one budget per attempt, shorter than the LLM calls. */
|
|
52
|
+
const JUDGE_TIMEOUT_MS = 15_000;
|
|
53
|
+
async function post(content, opts) {
|
|
53
54
|
let res;
|
|
54
55
|
try {
|
|
55
|
-
res = await
|
|
56
|
+
res = await fetchWithRetry(ENDPOINT, {
|
|
56
57
|
method: 'POST',
|
|
57
58
|
headers: {
|
|
58
59
|
'content-type': 'application/json',
|
|
@@ -63,16 +64,12 @@ async function postOnce(content, opts) {
|
|
|
63
64
|
model: opts.model ?? DEFAULT_MODEL,
|
|
64
65
|
questions: QUESTIONS,
|
|
65
66
|
}),
|
|
66
|
-
});
|
|
67
|
+
}, { timeoutMs: JUDGE_TIMEOUT_MS, fetchFn: opts.fetcher });
|
|
67
68
|
}
|
|
68
69
|
catch {
|
|
69
70
|
return null;
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
-
return { retryable: true };
|
|
73
|
-
if (!res.ok)
|
|
74
|
-
return null;
|
|
75
|
-
return { res };
|
|
72
|
+
return res.ok ? res : null;
|
|
76
73
|
}
|
|
77
74
|
/** `null` on any failure, so a Jev outage degrades capture to today's
|
|
78
75
|
* behaviour instead of blocking the write. */
|
|
@@ -80,19 +77,15 @@ export async function judge(content, opts) {
|
|
|
80
77
|
const trimmed = content.trim();
|
|
81
78
|
if (trimmed.length < 3)
|
|
82
79
|
return null;
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
86
|
-
attempt = await postOnce(trimmed, opts);
|
|
87
|
-
}
|
|
88
|
-
if (!attempt || 'retryable' in attempt)
|
|
80
|
+
const res = await post(trimmed, opts);
|
|
81
|
+
if (!res)
|
|
89
82
|
return null;
|
|
90
83
|
let data;
|
|
91
84
|
try {
|
|
92
85
|
// SAFETY: the documented Jev response is `{ answers: { <name>: Answer } }`
|
|
93
86
|
// keyed by the question names posted above; every field read below is
|
|
94
87
|
// optional-chained and range-checked before use, so a lie here returns null.
|
|
95
|
-
data = await
|
|
88
|
+
data = await res.json();
|
|
96
89
|
}
|
|
97
90
|
catch {
|
|
98
91
|
return null;
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Leveled stderr logger. `HIPPO_LOG` picks the threshold (error, warn, info, debug); unset or unknown means warn. */
|
|
2
|
+
export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
|
|
3
|
+
/** Extra key=value pairs appended to the line; `requestId` ties a line to one HTTP request. */
|
|
4
|
+
export interface LogFields {
|
|
5
|
+
requestId?: string;
|
|
6
|
+
[key: string]: string | number | boolean | undefined;
|
|
7
|
+
}
|
|
8
|
+
/** The active threshold, read on every call so a test or a long-lived server can change it without a restart. */
|
|
9
|
+
export declare function logThreshold(): LogLevel;
|
|
10
|
+
export declare function isLevelEnabled(level: LogLevel): boolean;
|
|
11
|
+
/** Same `[hippo] ` prefix the existing stderr lines use, so default-level output reads as before. */
|
|
12
|
+
export declare function formatLogLine(level: LogLevel, message: string, fields?: LogFields): string;
|
|
13
|
+
/** Write `message` at `level` the first time `key` is seen in this process; later calls are dropped. */
|
|
14
|
+
declare function once(key: string, level: LogLevel, message: string, fields?: LogFields): void;
|
|
15
|
+
export declare const log: {
|
|
16
|
+
readonly error: (message: string, fields?: LogFields) => void;
|
|
17
|
+
readonly warn: (message: string, fields?: LogFields) => void;
|
|
18
|
+
readonly info: (message: string, fields?: LogFields) => void;
|
|
19
|
+
readonly debug: (message: string, fields?: LogFields) => void;
|
|
20
|
+
readonly once: typeof once;
|
|
21
|
+
};
|
|
22
|
+
/** Test hook: forget which once-keys have fired. */
|
|
23
|
+
export declare function resetLogOnce(): void;
|
|
24
|
+
export {};
|
|
25
|
+
//# sourceMappingURL=log.d.ts.map
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Leveled stderr logger. `HIPPO_LOG` picks the threshold (error, warn, info, debug); unset or unknown means warn. */
|
|
2
|
+
const RANK = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
3
|
+
function isLogLevel(value) {
|
|
4
|
+
return Object.hasOwn(RANK, value);
|
|
5
|
+
}
|
|
6
|
+
/** The active threshold, read on every call so a test or a long-lived server can change it without a restart. */
|
|
7
|
+
export function logThreshold() {
|
|
8
|
+
const raw = process.env.HIPPO_LOG?.trim().toLowerCase() ?? '';
|
|
9
|
+
return isLogLevel(raw) ? raw : 'warn';
|
|
10
|
+
}
|
|
11
|
+
export function isLevelEnabled(level) {
|
|
12
|
+
return RANK[level] <= RANK[logThreshold()];
|
|
13
|
+
}
|
|
14
|
+
// A field value is caller data; flattening newlines keeps one event on one line.
|
|
15
|
+
const oneLine = (value) => value.replace(/[\r\n]+/g, ' ');
|
|
16
|
+
/** Same `[hippo] ` prefix the existing stderr lines use, so default-level output reads as before. */
|
|
17
|
+
export function formatLogLine(level, message, fields = {}) {
|
|
18
|
+
const extras = Object.entries(fields)
|
|
19
|
+
.filter(([, v]) => v !== undefined)
|
|
20
|
+
.map(([k, v]) => ` ${k}=${oneLine(String(v))}`)
|
|
21
|
+
.join('');
|
|
22
|
+
return `[hippo] ${level}: ${oneLine(message)}${extras}`;
|
|
23
|
+
}
|
|
24
|
+
function write(level, message, fields) {
|
|
25
|
+
if (!isLevelEnabled(level))
|
|
26
|
+
return;
|
|
27
|
+
process.stderr.write(`${formatLogLine(level, message, fields)}\n`);
|
|
28
|
+
}
|
|
29
|
+
const onceKeys = new Set();
|
|
30
|
+
/** Write `message` at `level` the first time `key` is seen in this process; later calls are dropped. */
|
|
31
|
+
function once(key, level, message, fields) {
|
|
32
|
+
if (onceKeys.has(key))
|
|
33
|
+
return;
|
|
34
|
+
onceKeys.add(key);
|
|
35
|
+
write(level, message, fields);
|
|
36
|
+
}
|
|
37
|
+
export const log = {
|
|
38
|
+
error: (message, fields) => write('error', message, fields),
|
|
39
|
+
warn: (message, fields) => write('warn', message, fields),
|
|
40
|
+
info: (message, fields) => write('info', message, fields),
|
|
41
|
+
debug: (message, fields) => write('debug', message, fields),
|
|
42
|
+
once,
|
|
43
|
+
};
|
|
44
|
+
/** Test hook: forget which once-keys have fired. */
|
|
45
|
+
export function resetLogOnce() {
|
|
46
|
+
onceKeys.clear();
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=log.js.map
|