@gamaze/hicortex 0.13.0 → 0.13.2
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 +28 -7
- package/dist/capture-cursors.d.ts +73 -0
- package/dist/capture-cursors.js +133 -0
- package/dist/capture.d.ts +124 -0
- package/dist/capture.js +386 -0
- package/dist/cli.js +13 -1
- package/dist/distiller.d.ts +27 -1
- package/dist/distiller.js +81 -9
- package/dist/hermes-transcript-reader.d.ts +6 -2
- package/dist/hermes-transcript-reader.js +41 -4
- package/dist/init.d.ts +8 -0
- package/dist/init.js +15 -2
- package/dist/llm.d.ts +24 -1
- package/dist/llm.js +119 -2
- package/dist/mcp-server.js +57 -19
- package/dist/nightly-status.js +4 -1
- package/dist/nightly.d.ts +2 -0
- package/dist/nightly.js +224 -168
- package/dist/oc-transcript-reader.d.ts +3 -2
- package/dist/oc-transcript-reader.js +5 -3
- package/dist/pi-transcript-reader.d.ts +5 -8
- package/dist/pi-transcript-reader.js +36 -8
- package/dist/transcript-reader.d.ts +22 -1
- package/dist/transcript-reader.js +47 -14
- package/dist/types.d.ts +20 -0
- package/package.json +1 -1
|
@@ -47,8 +47,12 @@ const NOISE_ROLES = new Set(["tool", "session_meta"]);
|
|
|
47
47
|
/**
|
|
48
48
|
* Read Hermes sessions that ended since `since`, across all profiles.
|
|
49
49
|
* Returns one batch per session, parallel to readCcTranscripts().
|
|
50
|
+
*
|
|
51
|
+
* @param cursors Per-session capture cursors (#189), keyed `hermes:<profile>:<sid>`.
|
|
52
|
+
* The cursor value is the max `messages.id` already captured; a resumed +
|
|
53
|
+
* re-ended session yields only the new rows (`id > cursor`).
|
|
50
54
|
*/
|
|
51
|
-
function readHermesSessions(since, hermesHome = HERMES_HOME) {
|
|
55
|
+
function readHermesSessions(since, hermesHome = HERMES_HOME, cursors = {}) {
|
|
52
56
|
const batches = [];
|
|
53
57
|
const sinceEpoch = since.getTime() / 1000; // Hermes timestamps are unix seconds (REAL)
|
|
54
58
|
for (const { profile, dbPath } of discoverProfileDbs(hermesHome)) {
|
|
@@ -63,15 +67,40 @@ function readHermesSessions(since, hermesHome = HERMES_HOME) {
|
|
|
63
67
|
const sessions = db
|
|
64
68
|
.prepare("SELECT id, ended_at, source FROM sessions WHERE ended_at IS NOT NULL AND ended_at > ? ORDER BY ended_at")
|
|
65
69
|
.all(sinceEpoch);
|
|
66
|
-
|
|
70
|
+
// Cursor is a message id (INTEGER PRIMARY KEY AUTOINCREMENT — strictly
|
|
71
|
+
// increasing, never reused), so `id > ?` returns exactly the rows added
|
|
72
|
+
// since last capture. ORDER BY id (NOT timestamp): id is the capture
|
|
73
|
+
// boundary, so ordering rows by id makes entryCursors monotonic and the
|
|
74
|
+
// last row's id the true max consumed — the segment boundary the packer
|
|
75
|
+
// advances to is then genuinely the largest id, never skipping a
|
|
76
|
+
// lower-id-but-later-timestamp row. Verified safe: id order == timestamp
|
|
77
|
+
// order in production (A1: 0 divergences / 4413 rows), so text ordering is
|
|
78
|
+
// unchanged in practice.
|
|
79
|
+
const msgStmt = db.prepare("SELECT id, role, content, tool_name, timestamp FROM messages WHERE session_id = ? AND id > ? ORDER BY id");
|
|
80
|
+
// Highest id in the session — used only for the shrink guard below.
|
|
81
|
+
const maxIdStmt = db.prepare("SELECT MAX(id) as m FROM messages WHERE session_id = ?");
|
|
67
82
|
for (const s of sessions) {
|
|
68
83
|
// Skip automated (non-primary) sessions — cron runs are not
|
|
69
84
|
// conversations and would pollute memory. Checked before pulling
|
|
70
85
|
// messages so we don't even read them.
|
|
71
86
|
if (NON_PRIMARY_SOURCES.has(s.source))
|
|
72
87
|
continue;
|
|
73
|
-
const
|
|
74
|
-
|
|
88
|
+
const cursorKey = `hermes:${profile}:${s.id}`;
|
|
89
|
+
const pos = cursors[cursorKey] ?? { cursor: 0, gen: 0 };
|
|
90
|
+
let startCursor = pos.cursor;
|
|
91
|
+
let gen = pos.gen;
|
|
92
|
+
// Shrink guard: if the stored cursor exceeds the session's max id (DB
|
|
93
|
+
// reset/restore), re-read from 0 and bump the generation (fix 8). Cheap
|
|
94
|
+
// MAX(id) probe; the common path (cursor <= max) leaves it untouched.
|
|
95
|
+
if (startCursor > 0) {
|
|
96
|
+
const maxId = maxIdStmt.get(s.id).m ?? 0;
|
|
97
|
+
if (startCursor > maxId) {
|
|
98
|
+
startCursor = 0;
|
|
99
|
+
gen = pos.gen + 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const rows = msgStmt.all(s.id, startCursor);
|
|
103
|
+
// Skip only genuinely empty deltas. Do NOT gate on message count —
|
|
75
104
|
// a short 2-message exchange can carry a real decision. Meaningful-
|
|
76
105
|
// content is gated downstream by the post-denoise 200-char check in
|
|
77
106
|
// nightly.ts, so short-but-dense sessions aren't dropped here.
|
|
@@ -84,6 +113,10 @@ function readHermesSessions(since, hermesHome = HERMES_HOME) {
|
|
|
84
113
|
role: NOISE_ROLES.has(r.role) ? "tool_result" : r.role,
|
|
85
114
|
content: r.content ?? "",
|
|
86
115
|
}));
|
|
116
|
+
// entryCursors are the row ids, monotonic under ORDER BY id — the last
|
|
117
|
+
// is the max consumed id, so segment boundaries and the final advance
|
|
118
|
+
// land exactly on it.
|
|
119
|
+
const entryCursors = rows.map((r) => r.id);
|
|
87
120
|
const endTs = s.ended_at ?? rows[rows.length - 1].timestamp;
|
|
88
121
|
batches.push({
|
|
89
122
|
sessionId: s.id,
|
|
@@ -91,6 +124,10 @@ function readHermesSessions(since, hermesHome = HERMES_HOME) {
|
|
|
91
124
|
sourceAgent: `hermes/${profile}`,
|
|
92
125
|
date: new Date(endTs * 1000).toISOString().slice(0, 10),
|
|
93
126
|
entries,
|
|
127
|
+
cursorKey,
|
|
128
|
+
startCursor,
|
|
129
|
+
generation: gen,
|
|
130
|
+
entryCursors,
|
|
94
131
|
});
|
|
95
132
|
}
|
|
96
133
|
}
|
package/dist/init.d.ts
CHANGED
|
@@ -29,6 +29,14 @@ export declare function parseMcpListStatus(mcpListOutput: string): "connected" |
|
|
|
29
29
|
* Exported for testability.
|
|
30
30
|
*/
|
|
31
31
|
export declare function parseEnvFile(content: string): Record<string, string>;
|
|
32
|
+
/**
|
|
33
|
+
* True when an LLM is already persisted and `init` must NOT re-run provider
|
|
34
|
+
* selection: a named/flat backend, a flat baseUrl+apiKey pair, OR a nested-only
|
|
35
|
+
* `models.score` (model or baseUrl). The last clause (0.13.1) stops init from
|
|
36
|
+
* walking a nested-only config back through selection and writing flat keys that
|
|
37
|
+
* a `models.score` would then silently shadow (nested > flat).
|
|
38
|
+
*/
|
|
39
|
+
export declare function isLlmConfigured(config: Record<string, unknown>): boolean;
|
|
32
40
|
/**
|
|
33
41
|
* Generate a random auth token in the format hctx-<32 hex chars>.
|
|
34
42
|
* Exported for testability.
|
package/dist/init.js
CHANGED
|
@@ -20,6 +20,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
20
20
|
exports.GENERIC_DEFAULT_DOMAINS = void 0;
|
|
21
21
|
exports.parseMcpListStatus = parseMcpListStatus;
|
|
22
22
|
exports.parseEnvFile = parseEnvFile;
|
|
23
|
+
exports.isLlmConfigured = isLlmConfigured;
|
|
23
24
|
exports.generateAuthToken = generateAuthToken;
|
|
24
25
|
exports.persistAuthToken = persistAuthToken;
|
|
25
26
|
exports.decideAgentName = decideAgentName;
|
|
@@ -493,6 +494,18 @@ function mergeByKey(candidates) {
|
|
|
493
494
|
}
|
|
494
495
|
return [...seen.values()];
|
|
495
496
|
}
|
|
497
|
+
/**
|
|
498
|
+
* True when an LLM is already persisted and `init` must NOT re-run provider
|
|
499
|
+
* selection: a named/flat backend, a flat baseUrl+apiKey pair, OR a nested-only
|
|
500
|
+
* `models.score` (model or baseUrl). The last clause (0.13.1) stops init from
|
|
501
|
+
* walking a nested-only config back through selection and writing flat keys that
|
|
502
|
+
* a `models.score` would then silently shadow (nested > flat).
|
|
503
|
+
*/
|
|
504
|
+
function isLlmConfigured(config) {
|
|
505
|
+
const modelsScore = config.models?.score;
|
|
506
|
+
const hasModelsScore = Boolean(modelsScore?.model || modelsScore?.baseUrl);
|
|
507
|
+
return Boolean(config.llmBackend || (config.llmApiKey && config.llmBaseUrl) || hasModelsScore);
|
|
508
|
+
}
|
|
496
509
|
/**
|
|
497
510
|
* Detect or ask for LLM config and persist to ~/.hicortex/config.json.
|
|
498
511
|
* The daemon can't inherit shell env vars, so we persist here.
|
|
@@ -508,8 +521,8 @@ async function persistLlmConfig() {
|
|
|
508
521
|
config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
|
|
509
522
|
}
|
|
510
523
|
catch { /* new file */ }
|
|
511
|
-
// Don't overwrite if LLM config already persisted
|
|
512
|
-
if (
|
|
524
|
+
// Don't overwrite if LLM config already persisted (incl. a nested-only config).
|
|
525
|
+
if (isLlmConfigured(config)) {
|
|
513
526
|
console.log(` ✓ LLM config already configured`);
|
|
514
527
|
return;
|
|
515
528
|
}
|
package/dist/llm.d.ts
CHANGED
|
@@ -68,6 +68,25 @@ export declare function resolveExplicitLlmConfig(overrides?: {
|
|
|
68
68
|
* the transition for any lingering call sites — remove after 0.10.0 ships.
|
|
69
69
|
*/
|
|
70
70
|
export declare const resolveLlmConfigForCC: typeof resolveExplicitLlmConfig;
|
|
71
|
+
export type { ModelTierOverride } from "./types.js";
|
|
72
|
+
/**
|
|
73
|
+
* Normalize a nested `models: { <tier>: {model,baseUrl,apiKey,provider} }` block
|
|
74
|
+
* onto the flat `llm*` / `distill*` / `reflect*` / `classify*` keys the resolver
|
|
75
|
+
* already consumes. Nested overrides WIN over any flat key of the same name; every
|
|
76
|
+
* non-mapped key (llmBackend, licenseKey, distillFallback, contextClients, …)
|
|
77
|
+
* is preserved via spread. Pure: returns the SAME reference when there is no
|
|
78
|
+
* `models` key, so this is a provable no-op for every existing install.
|
|
79
|
+
*
|
|
80
|
+
* Robust to a malformed config.json: a config that parses to a scalar, array,
|
|
81
|
+
* or null is returned untouched (matching the pre-0.13.1 optional-chaining
|
|
82
|
+
* tolerance — this function must never throw at server/nightly boot).
|
|
83
|
+
*
|
|
84
|
+
* Fail-explicit (warn + skip, never throw): an invalid `models` value, an
|
|
85
|
+
* unknown tier name, a non-object tier value, a non-string field value, a tier
|
|
86
|
+
* apiKey/provider set without a baseUrl (they are baseUrl-gated downstream), and
|
|
87
|
+
* a dead score apiKey/provider under an ollama base.
|
|
88
|
+
*/
|
|
89
|
+
export declare function applyModelsBlock(saved: Record<string, unknown> | null): Record<string, unknown> | null;
|
|
71
90
|
/**
|
|
72
91
|
* Resolve an LlmConfig from a saved ~/.hicortex/config.json object.
|
|
73
92
|
*
|
|
@@ -79,8 +98,12 @@ export declare const resolveLlmConfigForCC: typeof resolveExplicitLlmConfig;
|
|
|
79
98
|
*
|
|
80
99
|
* Returns `reason: "claude_binary_missing"` when claude-cli is configured but
|
|
81
100
|
* the binary can't be found, so callers can log a context-specific message.
|
|
101
|
+
*
|
|
102
|
+
* `findBinary` is injectable (defaults to the real `findClaudeBinary`) so the
|
|
103
|
+
* claude-cli branch — including the missing-binary passthrough — can be pinned
|
|
104
|
+
* deterministically in tests without depending on the host filesystem.
|
|
82
105
|
*/
|
|
83
|
-
export declare function resolveSavedLlmConfig(savedConfig: Record<string, unknown> | null): {
|
|
106
|
+
export declare function resolveSavedLlmConfig(savedConfig: Record<string, unknown> | null, findBinary?: () => string | null): {
|
|
84
107
|
config: LlmConfig | null;
|
|
85
108
|
reason?: "claude_binary_missing";
|
|
86
109
|
};
|
package/dist/llm.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
18
|
exports.LlmClient = exports.RateLimitError = exports.resolveLlmConfigForCC = void 0;
|
|
19
19
|
exports.resolveExplicitLlmConfig = resolveExplicitLlmConfig;
|
|
20
|
+
exports.applyModelsBlock = applyModelsBlock;
|
|
20
21
|
exports.resolveSavedLlmConfig = resolveSavedLlmConfig;
|
|
21
22
|
exports.resolveClassifyProbeTarget = resolveClassifyProbeTarget;
|
|
22
23
|
exports.findClaudeBinary = findClaudeBinary;
|
|
@@ -70,6 +71,117 @@ function resolveExplicitLlmConfig(overrides) {
|
|
|
70
71
|
* the transition for any lingering call sites — remove after 0.10.0 ships.
|
|
71
72
|
*/
|
|
72
73
|
exports.resolveLlmConfigForCC = resolveExplicitLlmConfig;
|
|
74
|
+
/**
|
|
75
|
+
* Map from a `models.<tier>` name to the flat config keys it feeds. The base
|
|
76
|
+
* tier is `score` — score IS the base model today (completeFast reads
|
|
77
|
+
* config.model), so it lands on the llm* keys and its `provider` is ignored
|
|
78
|
+
* (the base provider comes from llmBackend / detectProvider, not config).
|
|
79
|
+
* Tiers with a `provider` key (distill/reflect/classify) apply their apiKey +
|
|
80
|
+
* provider through a baseUrl-gated overlay downstream; `score` (no provider
|
|
81
|
+
* key) rides the base resolution.
|
|
82
|
+
*/
|
|
83
|
+
const MODELS_TIER_KEYS = {
|
|
84
|
+
score: { model: "llmModel", baseUrl: "llmBaseUrl", apiKey: "llmApiKey" },
|
|
85
|
+
distill: { model: "distillModel", baseUrl: "distillBaseUrl", apiKey: "distillApiKey", provider: "distillProvider" },
|
|
86
|
+
reflect: { model: "reflectModel", baseUrl: "reflectBaseUrl", apiKey: "reflectApiKey", provider: "reflectProvider" },
|
|
87
|
+
classify: { model: "classifyModel", baseUrl: "classifyBaseUrl", apiKey: "classifyApiKey", provider: "classifyProvider" },
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Normalize a nested `models: { <tier>: {model,baseUrl,apiKey,provider} }` block
|
|
91
|
+
* onto the flat `llm*` / `distill*` / `reflect*` / `classify*` keys the resolver
|
|
92
|
+
* already consumes. Nested overrides WIN over any flat key of the same name; every
|
|
93
|
+
* non-mapped key (llmBackend, licenseKey, distillFallback, contextClients, …)
|
|
94
|
+
* is preserved via spread. Pure: returns the SAME reference when there is no
|
|
95
|
+
* `models` key, so this is a provable no-op for every existing install.
|
|
96
|
+
*
|
|
97
|
+
* Robust to a malformed config.json: a config that parses to a scalar, array,
|
|
98
|
+
* or null is returned untouched (matching the pre-0.13.1 optional-chaining
|
|
99
|
+
* tolerance — this function must never throw at server/nightly boot).
|
|
100
|
+
*
|
|
101
|
+
* Fail-explicit (warn + skip, never throw): an invalid `models` value, an
|
|
102
|
+
* unknown tier name, a non-object tier value, a non-string field value, a tier
|
|
103
|
+
* apiKey/provider set without a baseUrl (they are baseUrl-gated downstream), and
|
|
104
|
+
* a dead score apiKey/provider under an ollama base.
|
|
105
|
+
*/
|
|
106
|
+
function applyModelsBlock(saved) {
|
|
107
|
+
// Guard the container itself first — `"models" in saved` throws a TypeError on
|
|
108
|
+
// a truthy non-object (config.json = `true`/`5`/`"x"`); such configs must pass
|
|
109
|
+
// through so the boot path degrades to recall-only exactly as before.
|
|
110
|
+
if (typeof saved !== "object" || saved === null || Array.isArray(saved))
|
|
111
|
+
return saved;
|
|
112
|
+
if (!("models" in saved))
|
|
113
|
+
return saved;
|
|
114
|
+
const models = saved.models;
|
|
115
|
+
if (typeof models !== "object" || models === null || Array.isArray(models)) {
|
|
116
|
+
console.warn(`[hicortex] Ignoring invalid "models" config: expected an object of per-tier overrides, got ${Array.isArray(models) ? "array" : models === null ? "null" : typeof models}`);
|
|
117
|
+
return saved;
|
|
118
|
+
}
|
|
119
|
+
const ollamaBase = saved.llmBackend === "ollama";
|
|
120
|
+
const mapped = {};
|
|
121
|
+
for (const [tier, value] of Object.entries(models)) {
|
|
122
|
+
const keys = MODELS_TIER_KEYS[tier];
|
|
123
|
+
if (!keys) {
|
|
124
|
+
console.warn(`[hicortex] Ignoring unknown "models" tier "${tier}" (expected: score, distill, reflect, classify)`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
128
|
+
console.warn(`[hicortex] Ignoring invalid "models.${tier}" override: expected an object with model/baseUrl/apiKey/provider`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const o = value;
|
|
132
|
+
// Per-field string validation: a non-string value would map verbatim and
|
|
133
|
+
// fail opaquely downstream (e.g. baseUrl: 11434), so drop it with a warning.
|
|
134
|
+
const strField = (name) => {
|
|
135
|
+
const v = o[name];
|
|
136
|
+
if (v === undefined)
|
|
137
|
+
return undefined;
|
|
138
|
+
if (typeof v !== "string") {
|
|
139
|
+
console.warn(`[hicortex] Ignoring non-string "models.${tier}.${name}" (expected a string)`);
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
return v;
|
|
143
|
+
};
|
|
144
|
+
const model = strField("model");
|
|
145
|
+
const baseUrl = strField("baseUrl");
|
|
146
|
+
const apiKey = strField("apiKey");
|
|
147
|
+
const provider = strField("provider");
|
|
148
|
+
if (model !== undefined)
|
|
149
|
+
mapped[keys.model] = model;
|
|
150
|
+
if (baseUrl !== undefined)
|
|
151
|
+
mapped[keys.baseUrl] = baseUrl;
|
|
152
|
+
if (keys.provider) {
|
|
153
|
+
// Overlay tier (distill/reflect/classify): the downstream overlay only
|
|
154
|
+
// consumes apiKey/provider when the tier ALSO sets its own baseUrl.
|
|
155
|
+
// Without one, they would silently bill to the base key — so warn + drop.
|
|
156
|
+
if ((apiKey !== undefined || provider !== undefined) && baseUrl === undefined) {
|
|
157
|
+
console.warn(`[hicortex] Ignoring "models.${tier}" apiKey/provider without a baseUrl: they only take effect when the tier sets its own baseUrl`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
if (apiKey !== undefined)
|
|
161
|
+
mapped[keys.apiKey] = apiKey;
|
|
162
|
+
if (provider !== undefined)
|
|
163
|
+
mapped[keys.provider] = provider;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
// score = base tier: no separate provider key, and apiKey rides llmApiKey.
|
|
168
|
+
if (provider !== undefined) {
|
|
169
|
+
console.warn(`[hicortex] Ignoring "models.score.provider": the base provider comes from llmBackend (or is auto-detected from the endpoint)`);
|
|
170
|
+
}
|
|
171
|
+
if (apiKey !== undefined) {
|
|
172
|
+
if (ollamaBase) {
|
|
173
|
+
// The ollama base path hardcodes an empty api key and never reads
|
|
174
|
+
// llmApiKey, so score.apiKey is dead there.
|
|
175
|
+
console.warn(`[hicortex] Ignoring "models.score.apiKey": the base ollama path sends no api key`);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
mapped[keys.apiKey] = apiKey;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { ...saved, ...mapped };
|
|
184
|
+
}
|
|
73
185
|
/**
|
|
74
186
|
* Resolve an LlmConfig from a saved ~/.hicortex/config.json object.
|
|
75
187
|
*
|
|
@@ -81,11 +193,16 @@ exports.resolveLlmConfigForCC = resolveExplicitLlmConfig;
|
|
|
81
193
|
*
|
|
82
194
|
* Returns `reason: "claude_binary_missing"` when claude-cli is configured but
|
|
83
195
|
* the binary can't be found, so callers can log a context-specific message.
|
|
196
|
+
*
|
|
197
|
+
* `findBinary` is injectable (defaults to the real `findClaudeBinary`) so the
|
|
198
|
+
* claude-cli branch — including the missing-binary passthrough — can be pinned
|
|
199
|
+
* deterministically in tests without depending on the host filesystem.
|
|
84
200
|
*/
|
|
85
|
-
function resolveSavedLlmConfig(savedConfig) {
|
|
201
|
+
function resolveSavedLlmConfig(savedConfig, findBinary = findClaudeBinary) {
|
|
202
|
+
savedConfig = applyModelsBlock(savedConfig);
|
|
86
203
|
let llmConfig = null;
|
|
87
204
|
if (savedConfig?.llmBackend === "claude-cli") {
|
|
88
|
-
const claudePath =
|
|
205
|
+
const claudePath = findBinary();
|
|
89
206
|
if (claudePath) {
|
|
90
207
|
llmConfig = claudeCliConfig(claudePath);
|
|
91
208
|
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -332,7 +332,7 @@ async function startServer(options = {}) {
|
|
|
332
332
|
// Named backends (claude-cli, ollama) → immediate config; everything else
|
|
333
333
|
// goes through resolveExplicitLlmConfig which requires a user-chosen provider.
|
|
334
334
|
// If nothing is configured: start recall-only with an unmissable warning.
|
|
335
|
-
const savedConfig = readConfigFile(stateDir);
|
|
335
|
+
const savedConfig = (0, llm_js_1.applyModelsBlock)(readConfigFile(stateDir));
|
|
336
336
|
if (savedConfig?.llmBackend === "claude-cli") {
|
|
337
337
|
const claudePath = (0, llm_js_1.findClaudeBinary)();
|
|
338
338
|
if (claudePath) {
|
|
@@ -681,11 +681,27 @@ async function startServer(options = {}) {
|
|
|
681
681
|
res.status(400).json({ error: "Provide either 'text' (string) or 'messages' (array)" });
|
|
682
682
|
return;
|
|
683
683
|
}
|
|
684
|
+
// Escape LIKE wildcards — Hermes ids contain "_" (e.g. 20260701_045744_...).
|
|
685
|
+
const escapeLike = (s) => s.replace(/[\\%_]/g, (m) => "\\" + m);
|
|
686
|
+
// Segment-exact dedup (#189): an incremental capture POST carries
|
|
687
|
+
// segment_id "<start>-<end>[.pN]". Skip iff THIS exact segment's chunks are
|
|
688
|
+
// already stored (keys "<sid>#<segment_id>#<i>"). This is what lets a failed
|
|
689
|
+
// segment be safely retried with the same id, and a legacy session-level row
|
|
690
|
+
// (key "<sid>#<i>", no segment) does NOT match — so the #189 recovery
|
|
691
|
+
// re-ingest is never blocked by night-1's whole-session rows.
|
|
692
|
+
if (session_id && segment_id) {
|
|
693
|
+
const likePrefix = `${escapeLike(session_id)}#${escapeLike(segment_id)}#%`;
|
|
694
|
+
const existing = db.prepare("SELECT COUNT(*) as c FROM memories WHERE source_session LIKE ? ESCAPE '\\'").get(likePrefix);
|
|
695
|
+
if (existing.c > 0) {
|
|
696
|
+
res.status(200).json({ skipped: true, existing_count: existing.c });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
684
700
|
// Session-level dedup: when session_id is present and this is a whole-session
|
|
685
|
-
// POST (no segment_id), skip if any chunk of this
|
|
701
|
+
// POST (no segment_id — legacy ≤0.13.1 clients), skip if any chunk of this
|
|
702
|
+
// session is already stored. Unchanged: legacy clients keep exact behaviour.
|
|
686
703
|
if (session_id && !segment_id) {
|
|
687
|
-
|
|
688
|
-
const likePrefix = `${session_id.replace(/[\\%_]/g, (m) => "\\" + m)}#%`;
|
|
704
|
+
const likePrefix = `${escapeLike(session_id)}#%`;
|
|
689
705
|
const existing = db.prepare("SELECT COUNT(*) as c FROM memories WHERE source_session = ? OR source_session LIKE ? ESCAPE '\\'").get(session_id, likePrefix);
|
|
690
706
|
if (existing.c > 0) {
|
|
691
707
|
res.status(200).json({ skipped: true, existing_count: existing.c });
|
|
@@ -717,26 +733,48 @@ async function startServer(options = {}) {
|
|
|
717
733
|
? `${session_id}${segment_id ? `#${segment_id}` : ""}`
|
|
718
734
|
: undefined;
|
|
719
735
|
try {
|
|
720
|
-
|
|
721
|
-
|
|
736
|
+
// Collect gate-dropped entries so they can ride back in the response and
|
|
737
|
+
// land in the caller's file-persisted nightly log (#156 audit trail); the
|
|
738
|
+
// server-side per-entry console.log in distillChunk stays as well.
|
|
739
|
+
const dropped = [];
|
|
740
|
+
const entries = await (0, distiller_js_1.distillSession)(llm, conversationText, project ?? "unknown", date, chunkSize, dropped);
|
|
741
|
+
// Phase 1 — embed every chunk up front (async). If ANY embed fails we
|
|
742
|
+
// never reach the insert, so nothing is stored.
|
|
743
|
+
const createdAt = new Date(date).toISOString();
|
|
744
|
+
const toStore = [];
|
|
722
745
|
for (let i = 0; i < entries.length; i++) {
|
|
723
746
|
const entry = entries[i];
|
|
724
747
|
if (typeof entry !== "string" || !entry.trim())
|
|
725
748
|
continue;
|
|
726
|
-
|
|
727
|
-
const id = storage.insertMemory(db, entry, embedding, {
|
|
728
|
-
sourceAgent: source_agent ?? "unknown",
|
|
729
|
-
// Per-chunk key: "<session_id>#<i>". The prefix matches the nightly
|
|
730
|
-
// dedup check above, so a re-run of the same session is fully idempotent.
|
|
731
|
-
sourceSession: sourcePrefix ? `${sourcePrefix}#${i}` : undefined,
|
|
732
|
-
project: project ?? undefined,
|
|
733
|
-
memoryType: "episode",
|
|
734
|
-
privacy: privacy ?? "WORK",
|
|
735
|
-
createdAt: new Date(date).toISOString(),
|
|
736
|
-
});
|
|
737
|
-
ids.push(id);
|
|
749
|
+
toStore.push({ entry, embedding: await (0, embedder_js_1.embed)(entry), i });
|
|
738
750
|
}
|
|
739
|
-
|
|
751
|
+
// Phase 2 — insert all chunks in ONE transaction (fix 4). A segment's
|
|
752
|
+
// chunks are all-or-nothing: any insert failure rolls back the whole set
|
|
753
|
+
// and returns 500, so the content-blind segment-exact dedup never sees a
|
|
754
|
+
// half-stored segment and the retry re-distills cleanly. (Applies to the
|
|
755
|
+
// legacy whole-session path too — same loop.)
|
|
756
|
+
const insertAll = db.transaction(() => {
|
|
757
|
+
const out = [];
|
|
758
|
+
for (const { entry, embedding, i } of toStore) {
|
|
759
|
+
out.push(storage.insertMemory(db, entry, embedding, {
|
|
760
|
+
sourceAgent: source_agent ?? "unknown",
|
|
761
|
+
// Per-chunk key: "<session_id>[#<segment_id>]#<i>". The prefix
|
|
762
|
+
// matches the dedup checks above, so a re-run is idempotent.
|
|
763
|
+
sourceSession: sourcePrefix ? `${sourcePrefix}#${i}` : undefined,
|
|
764
|
+
project: project ?? undefined,
|
|
765
|
+
memoryType: "episode",
|
|
766
|
+
privacy: privacy ?? "WORK",
|
|
767
|
+
createdAt,
|
|
768
|
+
}));
|
|
769
|
+
}
|
|
770
|
+
return out;
|
|
771
|
+
});
|
|
772
|
+
const ids = insertAll();
|
|
773
|
+
res.status(201).json({
|
|
774
|
+
ids,
|
|
775
|
+
distilled: ids.length,
|
|
776
|
+
dropped: dropped.map((d) => (d.length > 120 ? `${d.slice(0, 120)}…` : d)),
|
|
777
|
+
});
|
|
740
778
|
}
|
|
741
779
|
catch (err) {
|
|
742
780
|
res.status(500).json({ error: "Distillation failed", message: err instanceof Error ? err.message : String(err) });
|
package/dist/nightly-status.js
CHANGED
|
@@ -18,6 +18,7 @@ const node_os_1 = require("node:os");
|
|
|
18
18
|
const node_child_process_1 = require("node:child_process");
|
|
19
19
|
const db_js_1 = require("./db.js");
|
|
20
20
|
const state_js_1 = require("./state.js");
|
|
21
|
+
const llm_js_1 = require("./llm.js");
|
|
21
22
|
const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
|
|
22
23
|
const CONFIG_PATH = (0, node_path_1.join)(HICORTEX_HOME, "config.json");
|
|
23
24
|
async function showNightlyStatus() {
|
|
@@ -36,7 +37,9 @@ async function showNightlyStatus() {
|
|
|
36
37
|
}
|
|
37
38
|
// LLM config
|
|
38
39
|
try {
|
|
39
|
-
|
|
40
|
+
// No `?? {}` coercion: a null/invalid parse must fall through to the catch
|
|
41
|
+
// below (as it did pre-0.13.1) rather than print a fabricated-healthy status.
|
|
42
|
+
const config = (0, llm_js_1.applyModelsBlock)(JSON.parse((0, node_fs_1.readFileSync)(CONFIG_PATH, "utf-8")));
|
|
40
43
|
const backend = config.llmBackend ?? "auto-detect";
|
|
41
44
|
const model = config.llmModel ?? "default";
|
|
42
45
|
const mode = config.mode === "client" ? "client → " + (config.serverUrl ?? "?") : "server (local)";
|
package/dist/nightly.d.ts
CHANGED