@gamaze/hicortex 0.13.2 → 0.14.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 +15 -1
- package/dist/cli.js +12 -0
- package/dist/consolidate.d.ts +1 -1
- package/dist/consolidate.js +1 -1
- package/dist/context-cli.js +1 -1
- package/dist/db.js +14 -0
- package/dist/domain-classify.d.ts +3 -3
- package/dist/domain-classify.js +3 -3
- package/dist/hermes-transcript-reader.d.ts +1 -1
- package/dist/hermes-transcript-reader.js +1 -1
- package/dist/init.d.ts +7 -0
- package/dist/init.js +44 -16
- package/dist/lessons-context.d.ts +15 -1
- package/dist/lessons-context.js +5 -2
- package/dist/mcp-server.js +91 -6
- package/dist/nightly.js +5 -0
- package/dist/pi-transcript-reader.d.ts +3 -3
- package/dist/pi-transcript-reader.js +5 -5
- package/dist/recall-hook-cli.d.ts +28 -0
- package/dist/recall-hook-cli.js +77 -0
- package/dist/recall-index.d.ts +54 -0
- package/dist/recall-index.js +163 -0
- package/dist/recall-registry.d.ts +48 -0
- package/dist/recall-registry.js +99 -0
- package/dist/retrieval.d.ts +39 -1
- package/dist/retrieval.js +122 -22
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +27 -1
- package/dist/transcript-reader.d.ts +1 -1
- package/dist/transcript-reader.js +2 -2
- package/dist/types.d.ts +6 -0
- package/domains.example.json +53 -13
- package/hermes-plugin/hicortex/provider.py +4 -4
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hicortex recall-hook` — CC-side client for pushed recall (#192).
|
|
3
|
+
*
|
|
4
|
+
* Installed by init under TWO Claude Code hook events (one command, the CLI
|
|
5
|
+
* dispatches on the payload):
|
|
6
|
+
* - UserPromptSubmit: POST the prompt to the server's /recall-index; print
|
|
7
|
+
* the returned index block to stdout (CC injects hook stdout as context).
|
|
8
|
+
* - SessionStart (startup/resume/clear/compact): POST a reset so the
|
|
9
|
+
* server's per-session shown-set matches the fresh context window.
|
|
10
|
+
*
|
|
11
|
+
* Fail-soft like lessons-context: ANY failure (no config, timeout, non-2xx,
|
|
12
|
+
* parse error) prints nothing and exits 0 — a broken hook must never block or
|
|
13
|
+
* slow a CC session beyond the fetch timeout (1000 ms, owner-set).
|
|
14
|
+
*/
|
|
15
|
+
interface HookPayload {
|
|
16
|
+
session_id?: string;
|
|
17
|
+
hook_event_name?: string;
|
|
18
|
+
prompt?: string;
|
|
19
|
+
source?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build the /recall-index request body from a CC hook payload, or null when
|
|
23
|
+
* there is nothing to send (no session id, or an unhandled event). Exported
|
|
24
|
+
* for tests.
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildHookRequest(payload: HookPayload): Record<string, unknown> | null;
|
|
27
|
+
export declare function runRecallHook(): Promise<void>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `hicortex recall-hook` — CC-side client for pushed recall (#192).
|
|
4
|
+
*
|
|
5
|
+
* Installed by init under TWO Claude Code hook events (one command, the CLI
|
|
6
|
+
* dispatches on the payload):
|
|
7
|
+
* - UserPromptSubmit: POST the prompt to the server's /recall-index; print
|
|
8
|
+
* the returned index block to stdout (CC injects hook stdout as context).
|
|
9
|
+
* - SessionStart (startup/resume/clear/compact): POST a reset so the
|
|
10
|
+
* server's per-session shown-set matches the fresh context window.
|
|
11
|
+
*
|
|
12
|
+
* Fail-soft like lessons-context: ANY failure (no config, timeout, non-2xx,
|
|
13
|
+
* parse error) prints nothing and exits 0 — a broken hook must never block or
|
|
14
|
+
* slow a CC session beyond the fetch timeout (1000 ms, owner-set).
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.buildHookRequest = buildHookRequest;
|
|
18
|
+
exports.runRecallHook = runRecallHook;
|
|
19
|
+
const lessons_context_js_1 = require("./lessons-context.js");
|
|
20
|
+
const FETCH_TIMEOUT_MS = 1000;
|
|
21
|
+
/** Read all of stdin (CC pipes the hook payload JSON). */
|
|
22
|
+
async function readStdin() {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
for await (const chunk of process.stdin) {
|
|
25
|
+
chunks.push(Buffer.from(chunk));
|
|
26
|
+
}
|
|
27
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build the /recall-index request body from a CC hook payload, or null when
|
|
31
|
+
* there is nothing to send (no session id, or an unhandled event). Exported
|
|
32
|
+
* for tests.
|
|
33
|
+
*/
|
|
34
|
+
function buildHookRequest(payload) {
|
|
35
|
+
const sessionId = typeof payload.session_id === "string" && payload.session_id
|
|
36
|
+
? payload.session_id
|
|
37
|
+
: null;
|
|
38
|
+
if (!sessionId)
|
|
39
|
+
return null;
|
|
40
|
+
if (payload.hook_event_name === "SessionStart") {
|
|
41
|
+
return { session_id: sessionId, reset: true };
|
|
42
|
+
}
|
|
43
|
+
const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
|
|
44
|
+
if (!prompt)
|
|
45
|
+
return null;
|
|
46
|
+
return { session_id: sessionId, prompt };
|
|
47
|
+
}
|
|
48
|
+
async function runRecallHook() {
|
|
49
|
+
const cfg = (0, lessons_context_js_1.resolveConfig)();
|
|
50
|
+
if (!cfg)
|
|
51
|
+
return;
|
|
52
|
+
let payload;
|
|
53
|
+
try {
|
|
54
|
+
payload = JSON.parse(await readStdin());
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const body = buildHookRequest(payload);
|
|
60
|
+
if (!body)
|
|
61
|
+
return;
|
|
62
|
+
const headers = { "Content-Type": "application/json" };
|
|
63
|
+
if (cfg.authToken)
|
|
64
|
+
headers["Authorization"] = `Bearer ${cfg.authToken}`;
|
|
65
|
+
const resp = await fetch(`${cfg.serverUrl}/recall-index`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers,
|
|
68
|
+
body: JSON.stringify(body),
|
|
69
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
70
|
+
});
|
|
71
|
+
if (!resp.ok)
|
|
72
|
+
return;
|
|
73
|
+
const data = (await resp.json());
|
|
74
|
+
if (typeof data.block === "string" && data.block.trim() !== "") {
|
|
75
|
+
process.stdout.write(data.block + "\n");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /recall-index — pushed recall index (#192).
|
|
3
|
+
*
|
|
4
|
+
* One recall logic for every harness: CC calls it from a UserPromptSubmit
|
|
5
|
+
* hook, the Hermes/OC plugins can call it per turn. The server searches the
|
|
6
|
+
* corpus with the prompt text and returns a COMPACT INDEX (one line per
|
|
7
|
+
* memory — a menu, not the meal); the agent lazy-loads full content with
|
|
8
|
+
* `hicortex_get(id)` only when a line is actually relevant.
|
|
9
|
+
*
|
|
10
|
+
* Strengthening semantics (the recall/decay alignment):
|
|
11
|
+
* - Appearing in the index = exposure: shown_count + last_accessed refresh
|
|
12
|
+
* (mild, temporary strengthen — the decay clock resets) via
|
|
13
|
+
* storage.touchMemoriesShown. NO access_count bump: hardening, the prune
|
|
14
|
+
* shield, and the adoption metric stay driven by real use.
|
|
15
|
+
* - hicortex_get = use: full strengthen (access_count + 1).
|
|
16
|
+
*
|
|
17
|
+
* Anti-bloat gates: relevance floor (measured cosine, or a real BM25 match),
|
|
18
|
+
* per-session TURN-based dedup (SessionRecallRegistry), short-prompt skip,
|
|
19
|
+
* and a hard item cap. On a prompt with no relevant memories the block is
|
|
20
|
+
* null and the hook prints nothing.
|
|
21
|
+
*/
|
|
22
|
+
import type Database from "better-sqlite3";
|
|
23
|
+
import type { MemorySearchResult } from "./types.js";
|
|
24
|
+
import { SessionRecallRegistry } from "./recall-registry.js";
|
|
25
|
+
export interface RecallIndexOptions {
|
|
26
|
+
/** Minimum measured cosine for vector-only candidates (config
|
|
27
|
+
* `recallMinSimilarity`). FTS-matched candidates pass regardless — a BM25
|
|
28
|
+
* text match is direct evidence of relevance. Default 0.55 (the neutral
|
|
29
|
+
* placeholder similarity is 0.5; anything at/below that is noise). */
|
|
30
|
+
minSimilarity?: number;
|
|
31
|
+
/** Max index lines per response (config `recallMaxItems`). Default 6. */
|
|
32
|
+
maxItems?: number;
|
|
33
|
+
/** Prompts shorter than this are skipped (continuations, "yes", "do it"). */
|
|
34
|
+
minPromptLength?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface RecallIndexResult {
|
|
37
|
+
status: number;
|
|
38
|
+
body: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
/** First content line, de-markdowned and truncated — the index line title. */
|
|
41
|
+
export declare function memoryTitle(content: string, maxLen?: number): string;
|
|
42
|
+
/** Relevance gate: real text match, or measured cosine above the floor. */
|
|
43
|
+
export declare function passesRelevanceGate(r: MemorySearchResult, minSimilarity: number): boolean;
|
|
44
|
+
export interface RecallIndexDeps {
|
|
45
|
+
db: Database.Database;
|
|
46
|
+
registry: SessionRecallRegistry;
|
|
47
|
+
retrieveFn: (query: string, limit: number) => Promise<MemorySearchResult[]>;
|
|
48
|
+
options?: RecallIndexOptions;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Handle a /recall-index request body. Thin Express adapter in mcp-server.ts;
|
|
52
|
+
* all behavior lives here so tests exercise it directly.
|
|
53
|
+
*/
|
|
54
|
+
export declare function handleRecallIndex(deps: RecallIndexDeps, body: unknown): Promise<RecallIndexResult>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* POST /recall-index — pushed recall index (#192).
|
|
4
|
+
*
|
|
5
|
+
* One recall logic for every harness: CC calls it from a UserPromptSubmit
|
|
6
|
+
* hook, the Hermes/OC plugins can call it per turn. The server searches the
|
|
7
|
+
* corpus with the prompt text and returns a COMPACT INDEX (one line per
|
|
8
|
+
* memory — a menu, not the meal); the agent lazy-loads full content with
|
|
9
|
+
* `hicortex_get(id)` only when a line is actually relevant.
|
|
10
|
+
*
|
|
11
|
+
* Strengthening semantics (the recall/decay alignment):
|
|
12
|
+
* - Appearing in the index = exposure: shown_count + last_accessed refresh
|
|
13
|
+
* (mild, temporary strengthen — the decay clock resets) via
|
|
14
|
+
* storage.touchMemoriesShown. NO access_count bump: hardening, the prune
|
|
15
|
+
* shield, and the adoption metric stay driven by real use.
|
|
16
|
+
* - hicortex_get = use: full strengthen (access_count + 1).
|
|
17
|
+
*
|
|
18
|
+
* Anti-bloat gates: relevance floor (measured cosine, or a real BM25 match),
|
|
19
|
+
* per-session TURN-based dedup (SessionRecallRegistry), short-prompt skip,
|
|
20
|
+
* and a hard item cap. On a prompt with no relevant memories the block is
|
|
21
|
+
* null and the hook prints nothing.
|
|
22
|
+
*/
|
|
23
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
24
|
+
if (k2 === undefined) k2 = k;
|
|
25
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
26
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
27
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
28
|
+
}
|
|
29
|
+
Object.defineProperty(o, k2, desc);
|
|
30
|
+
}) : (function(o, m, k, k2) {
|
|
31
|
+
if (k2 === undefined) k2 = k;
|
|
32
|
+
o[k2] = m[k];
|
|
33
|
+
}));
|
|
34
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
35
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
36
|
+
}) : function(o, v) {
|
|
37
|
+
o["default"] = v;
|
|
38
|
+
});
|
|
39
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
40
|
+
var ownKeys = function(o) {
|
|
41
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
42
|
+
var ar = [];
|
|
43
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
44
|
+
return ar;
|
|
45
|
+
};
|
|
46
|
+
return ownKeys(o);
|
|
47
|
+
};
|
|
48
|
+
return function (mod) {
|
|
49
|
+
if (mod && mod.__esModule) return mod;
|
|
50
|
+
var result = {};
|
|
51
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
52
|
+
__setModuleDefault(result, mod);
|
|
53
|
+
return result;
|
|
54
|
+
};
|
|
55
|
+
})();
|
|
56
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
57
|
+
exports.memoryTitle = memoryTitle;
|
|
58
|
+
exports.passesRelevanceGate = passesRelevanceGate;
|
|
59
|
+
exports.handleRecallIndex = handleRecallIndex;
|
|
60
|
+
const storage = __importStar(require("./storage.js"));
|
|
61
|
+
const DEFAULT_MIN_SIMILARITY = 0.55;
|
|
62
|
+
const DEFAULT_MAX_ITEMS = 6;
|
|
63
|
+
const DEFAULT_MIN_PROMPT_LENGTH = 20;
|
|
64
|
+
/** Retrieve more than maxItems so gating + dedup still leave a full menu. */
|
|
65
|
+
const CANDIDATE_MULTIPLIER = 3;
|
|
66
|
+
/** First content line, de-markdowned and truncated — the index line title. */
|
|
67
|
+
function memoryTitle(content, maxLen = 100) {
|
|
68
|
+
const firstLine = content
|
|
69
|
+
.split("\n")
|
|
70
|
+
.map((l) => l.trim())
|
|
71
|
+
.find((l) => l.length > 0) ?? "";
|
|
72
|
+
const title = firstLine
|
|
73
|
+
.replace(/^#+\s*/, "")
|
|
74
|
+
.replace(/^Session Memory:\s*/i, "")
|
|
75
|
+
.replace(/^Lesson:\s*/i, "")
|
|
76
|
+
.trim();
|
|
77
|
+
return title.length > maxLen ? `${title.slice(0, maxLen - 1)}…` : title;
|
|
78
|
+
}
|
|
79
|
+
function formatDate(iso) {
|
|
80
|
+
const d = new Date(iso);
|
|
81
|
+
if (isNaN(d.getTime()))
|
|
82
|
+
return "";
|
|
83
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
84
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
85
|
+
return `${dd}.${mm}.${d.getFullYear()}`;
|
|
86
|
+
}
|
|
87
|
+
function formatIndexLine(r) {
|
|
88
|
+
const meta = [formatDate(r.created_at), r.domain ?? r.project ?? undefined, r.memory_type]
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.join(", ");
|
|
91
|
+
return `- [${r.id}] ${memoryTitle(r.content)}${meta ? ` (${meta})` : ""}`;
|
|
92
|
+
}
|
|
93
|
+
/** Relevance gate: real text match, or measured cosine above the floor. */
|
|
94
|
+
function passesRelevanceGate(r, minSimilarity) {
|
|
95
|
+
if (r.source === "fts" || r.source === "both")
|
|
96
|
+
return true;
|
|
97
|
+
return typeof r.similarity === "number" && r.similarity >= minSimilarity;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Handle a /recall-index request body. Thin Express adapter in mcp-server.ts;
|
|
101
|
+
* all behavior lives here so tests exercise it directly.
|
|
102
|
+
*/
|
|
103
|
+
async function handleRecallIndex(deps, body) {
|
|
104
|
+
const req = (body ?? {});
|
|
105
|
+
const sessionId = typeof req.session_id === "string" && req.session_id ? req.session_id : null;
|
|
106
|
+
if (!sessionId) {
|
|
107
|
+
return { status: 400, body: { error: "Missing 'session_id'" } };
|
|
108
|
+
}
|
|
109
|
+
// Reset: SessionStart (startup/resume/clear/compact) — fresh context, so the
|
|
110
|
+
// shown-set is stale by definition.
|
|
111
|
+
if (req.reset === true) {
|
|
112
|
+
deps.registry.reset(sessionId);
|
|
113
|
+
return { status: 200, body: { ok: true, reset: true } };
|
|
114
|
+
}
|
|
115
|
+
const prompt = typeof req.prompt === "string" ? req.prompt.trim() : "";
|
|
116
|
+
const minPromptLength = deps.options?.minPromptLength ?? DEFAULT_MIN_PROMPT_LENGTH;
|
|
117
|
+
if (prompt.length < minPromptLength) {
|
|
118
|
+
return { status: 200, body: { block: null, skipped: "short-prompt" } };
|
|
119
|
+
}
|
|
120
|
+
const maxItems = clampInt(deps.options?.maxItems, DEFAULT_MAX_ITEMS, 1, 20);
|
|
121
|
+
const minSimilarity = clampNumber(deps.options?.minSimilarity, DEFAULT_MIN_SIMILARITY, 0, 1);
|
|
122
|
+
const turn = deps.registry.beginTurn(sessionId);
|
|
123
|
+
let results;
|
|
124
|
+
try {
|
|
125
|
+
results = await deps.retrieveFn(prompt, maxItems * CANDIDATE_MULTIPLIER);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
return {
|
|
129
|
+
status: 500,
|
|
130
|
+
body: { error: err instanceof Error ? err.message : String(err) },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const picked = results
|
|
134
|
+
.filter((r) => passesRelevanceGate(r, minSimilarity))
|
|
135
|
+
.filter((r) => deps.registry.isShowable(sessionId, r.id))
|
|
136
|
+
.slice(0, maxItems);
|
|
137
|
+
if (picked.length === 0) {
|
|
138
|
+
return { status: 200, body: { block: null, shown: [], turn } };
|
|
139
|
+
}
|
|
140
|
+
const ids = picked.map((r) => r.id);
|
|
141
|
+
deps.registry.markShown(sessionId, ids);
|
|
142
|
+
// Exposure signal: shown_count + last_accessed refresh, NOT access_count.
|
|
143
|
+
storage.touchMemoriesShown(deps.db, ids, new Date().toISOString());
|
|
144
|
+
const lines = picked.map((r) => formatIndexLine(r));
|
|
145
|
+
const block = [
|
|
146
|
+
"## Memory recall (auto)",
|
|
147
|
+
"Possibly relevant long-term memories. Fetch full content with `hicortex_get(id)` ONLY for entries relevant to the current task:",
|
|
148
|
+
...lines,
|
|
149
|
+
].join("\n");
|
|
150
|
+
return { status: 200, body: { block, shown: ids, turn } };
|
|
151
|
+
}
|
|
152
|
+
function clampInt(v, dflt, min, max) {
|
|
153
|
+
const n = Number(v);
|
|
154
|
+
if (!Number.isFinite(n))
|
|
155
|
+
return dflt;
|
|
156
|
+
return Math.max(min, Math.min(max, Math.floor(n)));
|
|
157
|
+
}
|
|
158
|
+
function clampNumber(v, dflt, min, max) {
|
|
159
|
+
const n = Number(v);
|
|
160
|
+
if (!Number.isFinite(n))
|
|
161
|
+
return dflt;
|
|
162
|
+
return Math.max(min, Math.min(max, n));
|
|
163
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionRecallRegistry — per-session, TURN-based dedup for pushed recall
|
|
3
|
+
* (#192, POST /recall-index).
|
|
4
|
+
*
|
|
5
|
+
* Why turn-based, not time-based: suppression must track the session's
|
|
6
|
+
* CONTEXT, not the wall clock. A multi-day CC session with a 1M window can
|
|
7
|
+
* hold hundreds of turns; a shown memory is redundant while it is plausibly
|
|
8
|
+
* still in context and useful again once enough turns have passed (or the
|
|
9
|
+
* context was compacted away). Turn count is the proxy the server can own
|
|
10
|
+
* without clients reporting token volumes.
|
|
11
|
+
*
|
|
12
|
+
* Semantics:
|
|
13
|
+
* - Every non-reset /recall-index call for a session advances its turn
|
|
14
|
+
* counter by one.
|
|
15
|
+
* - A memory id shown at turn T is suppressed until turn T + reshowTurns.
|
|
16
|
+
* - reset(sessionId) clears the session's shown-set (fired by the CC
|
|
17
|
+
* SessionStart hook — which includes source=compact, i.e. after
|
|
18
|
+
* compaction the fresh context may legitimately re-receive everything).
|
|
19
|
+
*
|
|
20
|
+
* Purely in-memory: a server restart forgets shown-state, worst case a few
|
|
21
|
+
* early re-shows (~15 tokens each) — harmless by design. Sessions are pruned
|
|
22
|
+
* LRU beyond maxSessions so long-running servers don't accumulate state.
|
|
23
|
+
*/
|
|
24
|
+
export interface RecallRegistryOptions {
|
|
25
|
+
/** Turns a shown id stays suppressed. Config `recallReshowTurns`, default 30. */
|
|
26
|
+
reshowTurns?: number;
|
|
27
|
+
/** Max tracked sessions before LRU eviction. */
|
|
28
|
+
maxSessions?: number;
|
|
29
|
+
}
|
|
30
|
+
export declare const DEFAULT_RESHOW_TURNS = 30;
|
|
31
|
+
export declare class SessionRecallRegistry {
|
|
32
|
+
private readonly reshowTurns;
|
|
33
|
+
private readonly maxSessions;
|
|
34
|
+
private readonly sessions;
|
|
35
|
+
constructor(options?: RecallRegistryOptions);
|
|
36
|
+
/** Advance the session's turn counter (one call = one turn). */
|
|
37
|
+
beginTurn(sessionId: string): number;
|
|
38
|
+
/** True when the id has not been shown within the last reshowTurns turns. */
|
|
39
|
+
isShowable(sessionId: string, memoryId: string): boolean;
|
|
40
|
+
/** Record ids as shown at the session's current turn. */
|
|
41
|
+
markShown(sessionId: string, memoryIds: string[]): void;
|
|
42
|
+
/** Forget a session's shown-set (SessionStart / compaction). */
|
|
43
|
+
reset(sessionId: string): void;
|
|
44
|
+
/** Number of tracked sessions (for /recall-index introspection + tests). */
|
|
45
|
+
size(): number;
|
|
46
|
+
private getOrCreate;
|
|
47
|
+
private evictIfNeeded;
|
|
48
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* SessionRecallRegistry — per-session, TURN-based dedup for pushed recall
|
|
4
|
+
* (#192, POST /recall-index).
|
|
5
|
+
*
|
|
6
|
+
* Why turn-based, not time-based: suppression must track the session's
|
|
7
|
+
* CONTEXT, not the wall clock. A multi-day CC session with a 1M window can
|
|
8
|
+
* hold hundreds of turns; a shown memory is redundant while it is plausibly
|
|
9
|
+
* still in context and useful again once enough turns have passed (or the
|
|
10
|
+
* context was compacted away). Turn count is the proxy the server can own
|
|
11
|
+
* without clients reporting token volumes.
|
|
12
|
+
*
|
|
13
|
+
* Semantics:
|
|
14
|
+
* - Every non-reset /recall-index call for a session advances its turn
|
|
15
|
+
* counter by one.
|
|
16
|
+
* - A memory id shown at turn T is suppressed until turn T + reshowTurns.
|
|
17
|
+
* - reset(sessionId) clears the session's shown-set (fired by the CC
|
|
18
|
+
* SessionStart hook — which includes source=compact, i.e. after
|
|
19
|
+
* compaction the fresh context may legitimately re-receive everything).
|
|
20
|
+
*
|
|
21
|
+
* Purely in-memory: a server restart forgets shown-state, worst case a few
|
|
22
|
+
* early re-shows (~15 tokens each) — harmless by design. Sessions are pruned
|
|
23
|
+
* LRU beyond maxSessions so long-running servers don't accumulate state.
|
|
24
|
+
*/
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.SessionRecallRegistry = exports.DEFAULT_RESHOW_TURNS = void 0;
|
|
27
|
+
exports.DEFAULT_RESHOW_TURNS = 30;
|
|
28
|
+
const DEFAULT_MAX_SESSIONS = 500;
|
|
29
|
+
class SessionRecallRegistry {
|
|
30
|
+
reshowTurns;
|
|
31
|
+
maxSessions;
|
|
32
|
+
sessions = new Map();
|
|
33
|
+
constructor(options) {
|
|
34
|
+
const turns = Number(options?.reshowTurns);
|
|
35
|
+
this.reshowTurns =
|
|
36
|
+
Number.isFinite(turns) && turns > 0 ? Math.floor(turns) : exports.DEFAULT_RESHOW_TURNS;
|
|
37
|
+
const max = Number(options?.maxSessions);
|
|
38
|
+
this.maxSessions =
|
|
39
|
+
Number.isFinite(max) && max > 0 ? Math.floor(max) : DEFAULT_MAX_SESSIONS;
|
|
40
|
+
}
|
|
41
|
+
/** Advance the session's turn counter (one call = one turn). */
|
|
42
|
+
beginTurn(sessionId) {
|
|
43
|
+
const s = this.getOrCreate(sessionId);
|
|
44
|
+
s.turn += 1;
|
|
45
|
+
s.lastUsedAt = Date.now();
|
|
46
|
+
return s.turn;
|
|
47
|
+
}
|
|
48
|
+
/** True when the id has not been shown within the last reshowTurns turns. */
|
|
49
|
+
isShowable(sessionId, memoryId) {
|
|
50
|
+
const s = this.sessions.get(sessionId);
|
|
51
|
+
if (!s)
|
|
52
|
+
return true;
|
|
53
|
+
const shownAt = s.shown.get(memoryId);
|
|
54
|
+
if (shownAt === undefined)
|
|
55
|
+
return true;
|
|
56
|
+
return s.turn - shownAt >= this.reshowTurns;
|
|
57
|
+
}
|
|
58
|
+
/** Record ids as shown at the session's current turn. */
|
|
59
|
+
markShown(sessionId, memoryIds) {
|
|
60
|
+
if (memoryIds.length === 0)
|
|
61
|
+
return;
|
|
62
|
+
const s = this.getOrCreate(sessionId);
|
|
63
|
+
for (const id of memoryIds)
|
|
64
|
+
s.shown.set(id, s.turn);
|
|
65
|
+
s.lastUsedAt = Date.now();
|
|
66
|
+
}
|
|
67
|
+
/** Forget a session's shown-set (SessionStart / compaction). */
|
|
68
|
+
reset(sessionId) {
|
|
69
|
+
this.sessions.delete(sessionId);
|
|
70
|
+
}
|
|
71
|
+
/** Number of tracked sessions (for /recall-index introspection + tests). */
|
|
72
|
+
size() {
|
|
73
|
+
return this.sessions.size;
|
|
74
|
+
}
|
|
75
|
+
getOrCreate(sessionId) {
|
|
76
|
+
let s = this.sessions.get(sessionId);
|
|
77
|
+
if (!s) {
|
|
78
|
+
this.evictIfNeeded();
|
|
79
|
+
s = { turn: 0, shown: new Map(), lastUsedAt: Date.now() };
|
|
80
|
+
this.sessions.set(sessionId, s);
|
|
81
|
+
}
|
|
82
|
+
return s;
|
|
83
|
+
}
|
|
84
|
+
evictIfNeeded() {
|
|
85
|
+
if (this.sessions.size < this.maxSessions)
|
|
86
|
+
return;
|
|
87
|
+
let oldestId = null;
|
|
88
|
+
let oldestAt = Infinity;
|
|
89
|
+
for (const [id, s] of this.sessions) {
|
|
90
|
+
if (s.lastUsedAt < oldestAt) {
|
|
91
|
+
oldestAt = s.lastUsedAt;
|
|
92
|
+
oldestId = id;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (oldestId)
|
|
96
|
+
this.sessions.delete(oldestId);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
exports.SessionRecallRegistry = SessionRecallRegistry;
|
package/dist/retrieval.d.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* score = similarity * 0.4 + effective_strength * 0.3 + connection_score * 0.2 + recency * 0.1
|
|
7
7
|
*
|
|
8
8
|
* Decay model (B+E+D):
|
|
9
|
-
* base_decay =
|
|
9
|
+
* base_decay = derived from decayHalfLifeDays (config; default 365 → ~1-year
|
|
10
|
+
* half-life at importance 0.5, importance-scaled either way)
|
|
10
11
|
* decay_rate = 1 - base_decay * (1 - importance)
|
|
11
12
|
* decay_rate = 1 - (1 - decay_rate) * 0.7^access_count
|
|
12
13
|
* decay_rate = 1 - (1 - decay_rate) * 0.7^link_count
|
|
@@ -15,6 +16,39 @@
|
|
|
15
16
|
*/
|
|
16
17
|
import type Database from "better-sqlite3";
|
|
17
18
|
import type { Memory, MemorySearchResult } from "./types.js";
|
|
19
|
+
/** Default decay half-life (days) at importance 0.5. #192: was 0.0005/h
|
|
20
|
+
* (~115-day half-life at base 0.5) — aggressive enough to bury the long tail
|
|
21
|
+
* in ranking. Long-term remembering is the product; time preference stays,
|
|
22
|
+
* but mild. */
|
|
23
|
+
export declare const DEFAULT_DECAY_HALF_LIFE_DAYS = 365;
|
|
24
|
+
/**
|
|
25
|
+
* Derive the per-hour base decay constant from a half-life target: for the
|
|
26
|
+
* decayable portion, retention^hours = 0.5 at `days`, evaluated at the
|
|
27
|
+
* reference importance 0.5 (the model scales the rate by (1 − importance)).
|
|
28
|
+
* decay_rate = 1 − λ(1 − imp) ⇒ half-life ≈ ln2 / (λ·(1 − imp)), so
|
|
29
|
+
* λ = ln2 / (24·days·0.5).
|
|
30
|
+
*/
|
|
31
|
+
export declare function decayConstantForHalfLife(days: number): number;
|
|
32
|
+
/**
|
|
33
|
+
* Configure the decay speed from config (`decayHalfLifeDays`). Called at boot
|
|
34
|
+
* by the server and the nightly so both processes score with the same clock.
|
|
35
|
+
* Invalid/absent values keep the default. Exported value for tests.
|
|
36
|
+
*/
|
|
37
|
+
export declare function configureDecay(options?: {
|
|
38
|
+
halfLifeDays?: unknown;
|
|
39
|
+
}): number;
|
|
40
|
+
interface RecallDefaults {
|
|
41
|
+
searchLimit: number;
|
|
42
|
+
recentLimit: number;
|
|
43
|
+
recentWindowDays: number;
|
|
44
|
+
coldExposureSlots: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Configure recall breadth from config. Called at boot next to
|
|
48
|
+
* configureDecay(); invalid/absent values keep the shipped defaults.
|
|
49
|
+
* Returns the resolved values (for logging + tests).
|
|
50
|
+
*/
|
|
51
|
+
export declare function configureRecall(config?: Record<string, unknown> | null): RecallDefaults;
|
|
18
52
|
/**
|
|
19
53
|
* Convert an L2 distance (as returned by sqlite-vec's vec0 `distance`) to
|
|
20
54
|
* cosine similarity. Valid because our embeddings are L2-normalized
|
|
@@ -51,6 +85,9 @@ export declare function retrieve(db: Database.Database, embedFn: EmbedFn, query:
|
|
|
51
85
|
project?: string | null;
|
|
52
86
|
privacy?: string[];
|
|
53
87
|
sourceAgent?: string;
|
|
88
|
+
/** #192: skip access strengthening — for pushed recall (/recall-index),
|
|
89
|
+
* where appearing in results must not count as use. */
|
|
90
|
+
noStrengthen?: boolean;
|
|
54
91
|
}): Promise<MemorySearchResult[]>;
|
|
55
92
|
/**
|
|
56
93
|
* Get recent context, optionally filtered by project and privacy.
|
|
@@ -60,3 +97,4 @@ export declare function searchRecent(db: Database.Database, options?: {
|
|
|
60
97
|
limit?: number;
|
|
61
98
|
privacy?: string[];
|
|
62
99
|
}): MemorySearchResult[];
|
|
100
|
+
export {};
|