nlm-memory 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -34
- package/dist/cli/nlm.js +223 -33
- package/dist/cli/nlm.js.map +1 -1
- package/dist/core/adapters/cursor.d.ts +45 -0
- package/dist/core/adapters/cursor.js +397 -0
- package/dist/core/adapters/cursor.js.map +1 -0
- package/dist/core/adapters/from-source.js +10 -0
- package/dist/core/adapters/from-source.js.map +1 -1
- package/dist/core/adapters/windsurf.d.ts +44 -0
- package/dist/core/adapters/windsurf.js +299 -0
- package/dist/core/adapters/windsurf.js.map +1 -0
- package/dist/core/hook/claude-settings.d.ts +12 -5
- package/dist/core/hook/claude-settings.js +21 -6
- package/dist/core/hook/claude-settings.js.map +1 -1
- package/dist/core/sources/source-registry.d.ts +1 -1
- package/dist/core/sources/source-registry.js +18 -0
- package/dist/core/sources/source-registry.js.map +1 -1
- package/dist/core/storage/sqlite-session-store.d.ts +2 -0
- package/dist/core/storage/sqlite-session-store.js +38 -2
- package/dist/core/storage/sqlite-session-store.js.map +1 -1
- package/dist/hook/hook-auth.d.ts +13 -0
- package/dist/hook/hook-auth.js +19 -0
- package/dist/hook/hook-auth.js.map +1 -0
- package/dist/hook/prompt-recall-hook.js +7 -1
- package/dist/hook/prompt-recall-hook.js.map +1 -1
- package/dist/hook/session-start-hook.js +4 -1
- package/dist/hook/session-start-hook.js.map +1 -1
- package/dist/hook/stop-hook.js +4 -1
- package/dist/hook/stop-hook.js.map +1 -1
- package/dist/http/app.d.ts +2 -0
- package/dist/http/app.js +76 -1
- package/dist/http/app.js.map +1 -1
- package/dist/install/claude-code.js +1 -1
- package/dist/install/claude-code.js.map +1 -1
- package/dist/install/cursor.d.ts +25 -0
- package/dist/install/cursor.js +43 -0
- package/dist/install/cursor.js.map +1 -0
- package/dist/install/nlm-dir-perms.d.ts +19 -0
- package/dist/install/nlm-dir-perms.js +43 -0
- package/dist/install/nlm-dir-perms.js.map +1 -0
- package/dist/install/ollama.d.ts +18 -1
- package/dist/install/ollama.js +62 -7
- package/dist/install/ollama.js.map +1 -1
- package/dist/install/setup.d.ts +4 -0
- package/dist/install/setup.js +141 -18
- package/dist/install/setup.js.map +1 -1
- package/dist/install/windsurf.d.ts +25 -0
- package/dist/install/windsurf.js +43 -0
- package/dist/install/windsurf.js.map +1 -0
- package/dist/mcp/server.js +20 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/types.d.ts +4 -0
- package/dist/ui/assets/{index-BA6IpU8g.css → index-Beo8psd-.css} +1 -1
- package/dist/ui/assets/index-CSPTTeeM.js +69 -0
- package/dist/ui/index.html +2 -2
- package/package.json +26 -1
- package/plugin/scripts/prompt-recall-hook.mjs +55 -4
- package/plugin/scripts/stop-hook.mjs +57 -6
- package/.agents/plugins/marketplace.json +0 -20
- package/.github/workflows/ci.yml +0 -30
- package/dist/ui/assets/index-B_qIVV0k.js +0 -69
- package/docs/methodology/re-derivation-rate.md +0 -112
- package/docs/methodology/useful-hit-rate.md +0 -79
- package/docs/plans/2026-05-20-fts5-lexical-recall.md +0 -1088
- package/docs/plans/2026-05-20-recall-daemon-wedge-fix.md +0 -662
- package/docs/plans/2026-05-20-recall-hook-design.md +0 -131
- package/docs/plans/2026-05-20-recall-hook-implementation.md +0 -1222
- package/docs/plans/desktop-product.md +0 -69
- package/docs/plans/factstore-design.md +0 -236
- package/logs/CHANGELOG/CHANGELOG-2026.md +0 -1389
- package/logs/CHANGELOG/CHANGELOG.md +0 -337
- package/migrations/000_initial_schema.sql +0 -174
- package/migrations/001_entity_type_rename.sql +0 -17
- package/migrations/002_adapter_state_extend.sql +0 -12
- package/migrations/003_session_embeddings.sql +0 -11
- package/migrations/004_facts.sql +0 -46
- package/migrations/005_sources.sql +0 -31
- package/migrations/006_providers.sql +0 -33
- package/migrations/007_source_tokens.sql +0 -17
- package/migrations/008_fts_rebuild.sql +0 -9
- package/migrations/009_session_embedding_chunks.sql +0 -46
- package/migrations/010_sources_opencode.sql +0 -30
- package/migrations/011_sources_hermes_agent.sql +0 -30
- package/migrations/012_sources_aider.sql +0 -30
- package/migrations/013_adapter_state_failure_count.sql +0 -12
- package/plugin-hermes-agent/README.md +0 -49
- package/plugin-hermes-agent/__init__.py +0 -75
- package/plugin-hermes-agent/plugin.yaml +0 -15
- package/scripts/backfill-citations.mjs +0 -0
- package/scripts/build-codex-plugin.mjs +0 -61
- package/scripts/deepseek-probe.mjs +0 -67
- package/scripts/extract-triples.mjs +0 -207
- package/scripts/longmemeval/embedding-cache.ts +0 -77
- package/scripts/longmemeval/fetch-dataset.sh +0 -25
- package/scripts/longmemeval/run-harness.ts +0 -315
- package/scripts/longmemeval/scorer.ts +0 -99
- package/scripts/longmemeval/tsconfig.json +0 -9
- package/scripts/longmemeval/types.ts +0 -35
- package/scripts/nlm-daily-digest.py +0 -239
- package/scripts/nlm-daily-digest.sh +0 -28
- package/src/cli/classify-parity.ts +0 -257
- package/src/cli/launchctl-helpers.ts +0 -49
- package/src/cli/nlm.ts +0 -885
- package/src/core/actions/actions-log.ts +0 -118
- package/src/core/actions/overlay.ts +0 -117
- package/src/core/adapters/aider.ts +0 -205
- package/src/core/adapters/claude-code.ts +0 -293
- package/src/core/adapters/common.ts +0 -54
- package/src/core/adapters/from-source.ts +0 -57
- package/src/core/adapters/hermes-agent.ts +0 -240
- package/src/core/adapters/hermes.ts +0 -277
- package/src/core/adapters/jsonl-generic.ts +0 -208
- package/src/core/adapters/opencode.ts +0 -281
- package/src/core/adapters/pi.ts +0 -264
- package/src/core/classifier/prompt.ts +0 -200
- package/src/core/dataset/build-dataset.ts +0 -463
- package/src/core/embedding/chunk-body.ts +0 -76
- package/src/core/embedding/embed-backfill.ts +0 -210
- package/src/core/embedding/embed-normalize.ts +0 -135
- package/src/core/facts/backfill-facts.ts +0 -254
- package/src/core/facts/extract-facts.ts +0 -50
- package/src/core/hook/citation-detect.ts +0 -124
- package/src/core/hook/cite-memo.ts +0 -68
- package/src/core/hook/claude-settings.ts +0 -166
- package/src/core/hook/gate.ts +0 -25
- package/src/core/hook/hook-log.ts +0 -41
- package/src/core/hook/memo-sweep.ts +0 -164
- package/src/core/hook/memo.ts +0 -67
- package/src/core/hook/pointer-block.ts +0 -26
- package/src/core/hook/select.ts +0 -32
- package/src/core/hook/transcript.ts +0 -121
- package/src/core/ingest/ingest-session.ts +0 -111
- package/src/core/providers/provider-models.ts +0 -100
- package/src/core/providers/provider-registry.ts +0 -196
- package/src/core/recall/citation-log.ts +0 -108
- package/src/core/recall/filter.ts +0 -27
- package/src/core/recall/index.ts +0 -6
- package/src/core/recall/match-fields.ts +0 -40
- package/src/core/recall/query-log.ts +0 -149
- package/src/core/recall/query-shape.ts +0 -66
- package/src/core/recall/recall-service.ts +0 -320
- package/src/core/recall/recent-log.ts +0 -59
- package/src/core/recall/tokenize.ts +0 -18
- package/src/core/recall/useful-scan.ts +0 -336
- package/src/core/recall-facts/fact-query-log.ts +0 -150
- package/src/core/recall-facts/fact-recall-service.ts +0 -327
- package/src/core/scheduler/scan-once.ts +0 -142
- package/src/core/scheduler/scheduler.ts +0 -225
- package/src/core/sources/source-registry.ts +0 -260
- package/src/core/storage/db-restore.ts +0 -133
- package/src/core/storage/live-status.ts +0 -45
- package/src/core/storage/migrate.ts +0 -72
- package/src/core/storage/sqlite-fact-store.ts +0 -304
- package/src/core/storage/sqlite-session-store.ts +0 -765
- package/src/hook/prompt-recall-hook.ts +0 -174
- package/src/hook/session-end-hook.ts +0 -81
- package/src/hook/session-start-hook.ts +0 -165
- package/src/hook/stop-hook.ts +0 -236
- package/src/http/app.ts +0 -1137
- package/src/install/claude-code.ts +0 -128
- package/src/install/codex.ts +0 -367
- package/src/install/hermes-agent.ts +0 -76
- package/src/install/hermes.ts +0 -78
- package/src/install/ollama.ts +0 -211
- package/src/install/setup.ts +0 -368
- package/src/llm/classifier-box.ts +0 -64
- package/src/llm/deepseek-client.ts +0 -150
- package/src/llm/env-autoload.ts +0 -55
- package/src/llm/ollama-client.ts +0 -189
- package/src/mcp/server.ts +0 -534
- package/src/ports/fact-store.ts +0 -102
- package/src/ports/llm-client.ts +0 -52
- package/src/ports/logger.ts +0 -16
- package/src/ports/session-store.ts +0 -45
- package/src/ports/transcript-adapter.ts +0 -55
- package/src/shared/types.ts +0 -145
- package/src/ui/App.tsx +0 -58
- package/src/ui/components/PromoteOpenButton.tsx +0 -65
- package/src/ui/components/SessionDrawer.tsx +0 -136
- package/src/ui/components/SideNav.tsx +0 -162
- package/src/ui/components/Skeleton.tsx +0 -107
- package/src/ui/index.html +0 -13
- package/src/ui/lib/actions.ts +0 -30
- package/src/ui/lib/api.ts +0 -92
- package/src/ui/lib/dataset.ts +0 -141
- package/src/ui/lib/registries.ts +0 -155
- package/src/ui/lib/view-settings.ts +0 -41
- package/src/ui/main.tsx +0 -15
- package/src/ui/pages/Live.tsx +0 -229
- package/src/ui/pages/Pulse.tsx +0 -415
- package/src/ui/pages/Recall.tsx +0 -190
- package/src/ui/pages/River.tsx +0 -308
- package/src/ui/pages/Search.tsx +0 -93
- package/src/ui/pages/Stub.tsx +0 -9
- package/src/ui/pages/Thread.tsx +0 -262
- package/src/ui/pages/settings/Classifier.tsx +0 -227
- package/src/ui/pages/settings/Data.tsx +0 -190
- package/src/ui/pages/settings/Index.tsx +0 -65
- package/src/ui/pages/settings/Labels.tsx +0 -224
- package/src/ui/pages/settings/Providers.tsx +0 -305
- package/src/ui/pages/settings/SettingsSubnav.tsx +0 -28
- package/src/ui/pages/settings/Sources.tsx +0 -326
- package/src/ui/pages/settings/Views.tsx +0 -96
- package/src/ui/styles.css +0 -1766
- package/src/ui/tsconfig.json +0 -21
- package/src/ui/vite.config.ts +0 -19
- package/tests/fixtures/claude_code/short_session.jsonl +0 -2
- package/tests/fixtures/claude_code/standard_iso.jsonl +0 -4
- package/tests/fixtures/claude_code/tool_heavy.jsonl +0 -8
- package/tests/fixtures/claude_code/with_subagent.jsonl +0 -7
- package/tests/fixtures/facts.ts +0 -17
- package/tests/fixtures/golden-corpus.ts +0 -85
- package/tests/fixtures/hermes/paired_request_dump.json +0 -24
- package/tests/fixtures/hermes/paired_session.json +0 -23
- package/tests/fixtures/hermes/request_dump.json +0 -28
- package/tests/fixtures/hermes/session_iso.json +0 -38
- package/tests/fixtures/hermes/session_unix.json +0 -38
- package/tests/fixtures/hermes/system_only.json +0 -18
- package/tests/fixtures/pi/error-connection-abort.jsonl +0 -8
- package/tests/fixtures/pi/short-successful.jsonl +0 -5
- package/tests/fixtures/pi/with-custom-message.jsonl +0 -6
- package/tests/fixtures/sessions.ts +0 -22
- package/tests/integration/backfill-facts.test.ts +0 -362
- package/tests/integration/citation-explicit.test.ts +0 -111
- package/tests/integration/cite-event.test.ts +0 -169
- package/tests/integration/cite-memo.test.ts +0 -87
- package/tests/integration/db-restore.test.ts +0 -153
- package/tests/integration/embed-backfill.test.ts +0 -176
- package/tests/integration/fact-supersedence.test.ts +0 -313
- package/tests/integration/fts-index.test.ts +0 -60
- package/tests/integration/getbyids-sqlite.test.ts +0 -60
- package/tests/integration/hermes-agent-hooks.test.ts +0 -248
- package/tests/integration/hook-claude-settings.test.ts +0 -205
- package/tests/integration/hook-log.test.ts +0 -54
- package/tests/integration/hook-memo.test.ts +0 -68
- package/tests/integration/hook-pre-compact.test.ts +0 -105
- package/tests/integration/hook-subagent-start.test.ts +0 -102
- package/tests/integration/http.test.ts +0 -401
- package/tests/integration/keyword-search-fts.test.ts +0 -66
- package/tests/integration/mcp-recall-logging.test.ts +0 -88
- package/tests/integration/mcp.test.ts +0 -248
- package/tests/integration/memo-sweep.test.ts +0 -91
- package/tests/integration/prompt-recall-hook.test.ts +0 -88
- package/tests/integration/provider-registry.test.ts +0 -107
- package/tests/integration/recall-golden.test.ts +0 -59
- package/tests/integration/recall-sqlite.test.ts +0 -169
- package/tests/integration/scheduler.test.ts +0 -391
- package/tests/integration/session-end-hook.test.ts +0 -48
- package/tests/integration/session-start-hook.test.ts +0 -126
- package/tests/integration/source-registry.test.ts +0 -120
- package/tests/integration/sqlite-fact-store.test.ts +0 -346
- package/tests/integration/stop-hook.test.ts +0 -560
- package/tests/integration/wal-checkpoint.test.ts +0 -49
- package/tests/unit/cli/launchctl-helpers.test.ts +0 -60
- package/tests/unit/core/adapters/aider.test.ts +0 -230
- package/tests/unit/core/adapters/claude-code.test.ts +0 -118
- package/tests/unit/core/adapters/hermes-agent.test.ts +0 -329
- package/tests/unit/core/adapters/hermes.test.ts +0 -81
- package/tests/unit/core/adapters/jsonl-generic.test.ts +0 -142
- package/tests/unit/core/adapters/opencode.test.ts +0 -354
- package/tests/unit/core/adapters/pi.test.ts +0 -110
- package/tests/unit/core/classifier/prompt.test.ts +0 -126
- package/tests/unit/core/embedding/chunk-body.test.ts +0 -100
- package/tests/unit/core/facts/extract-facts.test.ts +0 -117
- package/tests/unit/core/filter.test.ts +0 -40
- package/tests/unit/core/hook/citation-detect-cite-session.test.ts +0 -96
- package/tests/unit/core/hook/citation-detect.test.ts +0 -124
- package/tests/unit/core/hook/gate.test.ts +0 -29
- package/tests/unit/core/hook/pointer-block.test.ts +0 -22
- package/tests/unit/core/hook/select.test.ts +0 -66
- package/tests/unit/core/match-fields.test.ts +0 -39
- package/tests/unit/core/mcp-cite-session.test.ts +0 -51
- package/tests/unit/core/providers/provider-models.test.ts +0 -101
- package/tests/unit/core/query-shape.test.ts +0 -92
- package/tests/unit/core/recall-facts/fact-recall-service.test.ts +0 -258
- package/tests/unit/core/recall-service.test.ts +0 -200
- package/tests/unit/core/storage/live-status.test.ts +0 -54
- package/tests/unit/core/tokenize.test.ts +0 -32
- package/tests/unit/core/useful-scan.test.ts +0 -537
- package/tests/unit/llm/embed.test.ts +0 -93
- package/tests/unit/llm/ollama-client.test.ts +0 -124
- package/tests/unit/scripts/longmemeval-scorer.test.ts +0 -114
- package/tsconfig.json +0 -31
- package/tsconfig.test.json +0 -11
- package/vitest.config.ts +0 -22
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* extractFacts — pure transform from ClassifyResult to Fact[].
|
|
3
|
-
*
|
|
4
|
-
* Lives in core/, has no framework imports, no clock or randomness coupling
|
|
5
|
-
* (id generator and timestamp are injected so tests are deterministic).
|
|
6
|
-
* Phase B.2 — see docs/plans/factstore-design.md Section 3.
|
|
7
|
-
*
|
|
8
|
-
* Confidence policy (Section 3 of the plan): facts inherit the session-level
|
|
9
|
-
* confidence verbatim. Below 0.4 the function returns an empty array — the
|
|
10
|
-
* session still ingests with markers, but its facts are dropped as
|
|
11
|
-
* extraction-quality noise. Between 0.4 and 0.6 facts are written but will
|
|
12
|
-
* be filtered out of recall by the FactStore default `minConfidence: 0.6`.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { randomUUID } from "node:crypto";
|
|
16
|
-
import type { ClassifyResult } from "@ports/llm-client.js";
|
|
17
|
-
import type { Fact } from "@shared/types.js";
|
|
18
|
-
|
|
19
|
-
const CONFIDENCE_FLOOR = 0.4;
|
|
20
|
-
|
|
21
|
-
export interface ExtractFactsOptions {
|
|
22
|
-
/** Generator for fact ids. Defaults to `fact_<randomUUID()>`. */
|
|
23
|
-
readonly idGenerator?: () => string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function extractFacts(
|
|
27
|
-
result: ClassifyResult,
|
|
28
|
-
sessionId: string,
|
|
29
|
-
createdAt: string,
|
|
30
|
-
opts: ExtractFactsOptions = {},
|
|
31
|
-
): Fact[] {
|
|
32
|
-
if (result.confidence < CONFIDENCE_FLOOR) return [];
|
|
33
|
-
const genId = opts.idGenerator ?? (() => `fact_${randomUUID()}`);
|
|
34
|
-
const out: Fact[] = [];
|
|
35
|
-
for (const raw of result.facts) {
|
|
36
|
-
out.push({
|
|
37
|
-
id: genId(),
|
|
38
|
-
kind: raw.kind,
|
|
39
|
-
subject: raw.subject,
|
|
40
|
-
predicate: raw.predicate,
|
|
41
|
-
value: raw.value,
|
|
42
|
-
sourceSessionId: sessionId,
|
|
43
|
-
sourceQuote: raw.sourceQuote ?? null,
|
|
44
|
-
createdAt,
|
|
45
|
-
supersededBy: null,
|
|
46
|
-
confidence: result.confidence,
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
return out;
|
|
50
|
-
}
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Detects which surfaced recall IDs an assistant turn cited.
|
|
3
|
-
*
|
|
4
|
-
* Two channels, ordered by signal strength:
|
|
5
|
-
* - tool_use: the model invoked an MCP NLM tool (get_session, recall_facts,
|
|
6
|
-
* get_fact_history, recall_sessions) whose input references a
|
|
7
|
-
* surfaced ID. This is the strong "the model dug into the
|
|
8
|
-
* surfaced session" signal. Almost no false positives.
|
|
9
|
-
* - prose: the surfaced ID appears as a substring in the response text.
|
|
10
|
-
* Models rarely echo session IDs verbatim, so this channel
|
|
11
|
-
* fires in practice almost never — kept for completeness.
|
|
12
|
-
*
|
|
13
|
-
* Returns both the union of cited IDs and the per-ID channel so the citation
|
|
14
|
-
* log can carry kind metadata. ID minimum length keeps generic short tokens
|
|
15
|
-
* from false-positiving against either channel.
|
|
16
|
-
*
|
|
17
|
-
* This is the training-data substrate for a future learned reranker.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import type { ToolUseBlock } from "./transcript.js";
|
|
21
|
-
|
|
22
|
-
const MIN_ID_LEN = 6;
|
|
23
|
-
|
|
24
|
-
export type CitationKind = "tool_use" | "prose";
|
|
25
|
-
|
|
26
|
-
export interface CitationDetectInput {
|
|
27
|
-
readonly responseText: string;
|
|
28
|
-
readonly toolUses: ReadonlyArray<ToolUseBlock>;
|
|
29
|
-
readonly surfacedIds: Iterable<string>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface DetectedCitation {
|
|
33
|
-
readonly id: string;
|
|
34
|
-
readonly kind: CitationKind;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function detectCitations(input: CitationDetectInput): DetectedCitation[] {
|
|
38
|
-
const surfaced: string[] = [];
|
|
39
|
-
const seen = new Set<string>();
|
|
40
|
-
for (const id of input.surfacedIds) {
|
|
41
|
-
if (id.length < MIN_ID_LEN) continue;
|
|
42
|
-
if (seen.has(id)) continue;
|
|
43
|
-
seen.add(id);
|
|
44
|
-
surfaced.push(id);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const cited: DetectedCitation[] = [];
|
|
48
|
-
const claimedByToolUse = new Set<string>();
|
|
49
|
-
|
|
50
|
-
// Channel A: tool_use. Two sub-cases:
|
|
51
|
-
//
|
|
52
|
-
// A1: cite_session — the model called the explicit citation primitive with
|
|
53
|
-
// the session ID in tu.input.id. Strongest possible signal: structured,
|
|
54
|
-
// deterministic, zero ambiguity. ID must be a surfaced session ID.
|
|
55
|
-
//
|
|
56
|
-
// A2: other NLM tools (get_session, recall_sessions, recall_facts,
|
|
57
|
-
// get_fact_history) — stringify the input and substring-scan for surfaced
|
|
58
|
-
// IDs. These tools accept ids via top-level fields, so the serialization
|
|
59
|
-
// always includes the id when used.
|
|
60
|
-
for (const tu of input.toolUses) {
|
|
61
|
-
if (!isNlmTool(tu.name)) continue;
|
|
62
|
-
if (isCiteSessionTool(tu.name)) {
|
|
63
|
-
// A1: explicit cite_session call — the MCP server handler already wrote
|
|
64
|
-
// this citation directly to the citation log (citeSessionHandler →
|
|
65
|
-
// appendCitation). Detecting it here again would produce a second log
|
|
66
|
-
// entry for the same model action (double-count). Skip so the Stop hook
|
|
67
|
-
// only captures implicit citations the MCP handler didn't see.
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
// A2: other NLM tools — serialize and substring-scan.
|
|
71
|
-
const serialized = safeStringify(tu.input);
|
|
72
|
-
if (!serialized) continue;
|
|
73
|
-
for (const id of surfaced) {
|
|
74
|
-
if (claimedByToolUse.has(id)) continue;
|
|
75
|
-
if (serialized.includes(id)) {
|
|
76
|
-
cited.push({ id, kind: "tool_use" });
|
|
77
|
-
claimedByToolUse.add(id);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Channel B: prose. Only emit if the tool_use channel didn't already
|
|
83
|
-
// claim this id — same id shouldn't double-count.
|
|
84
|
-
if (input.responseText) {
|
|
85
|
-
for (const id of surfaced) {
|
|
86
|
-
if (claimedByToolUse.has(id)) continue;
|
|
87
|
-
if (input.responseText.includes(id)) {
|
|
88
|
-
cited.push({ id, kind: "prose" });
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return cited;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Back-compat: prose-only detector returning a flat id list. */
|
|
96
|
-
export function detectCitedIds(
|
|
97
|
-
responseText: string,
|
|
98
|
-
surfacedIds: Iterable<string>,
|
|
99
|
-
): string[] {
|
|
100
|
-
return detectCitations({
|
|
101
|
-
responseText,
|
|
102
|
-
toolUses: [],
|
|
103
|
-
surfacedIds,
|
|
104
|
-
}).map((c) => c.id);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function isNlmTool(name: string): boolean {
|
|
108
|
-
// Claude Code namespaces MCP tools as `mcp__<server>__<tool>`. The NLM
|
|
109
|
-
// server name is "nlm-memory" in the user's .mcp.json today; accept any
|
|
110
|
-
// server name containing "nlm" so future renames stay covered.
|
|
111
|
-
return /^mcp__[^_]*nlm[^_]*__/.test(name);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function isCiteSessionTool(name: string): boolean {
|
|
115
|
-
return name.endsWith("__cite_session");
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function safeStringify(value: unknown): string {
|
|
119
|
-
try {
|
|
120
|
-
return JSON.stringify(value);
|
|
121
|
-
} catch {
|
|
122
|
-
return "";
|
|
123
|
-
}
|
|
124
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-conversation dedup memo for the Stop hook's citation detector.
|
|
3
|
-
*
|
|
4
|
-
* The Stop hook scans the full transcript every fire, so a long conversation
|
|
5
|
-
* with repeated Stop firings would otherwise re-detect the same tool_use
|
|
6
|
-
* citations every turn and double-count them in the citation log. This memo
|
|
7
|
-
* holds the set of (conversationId, citedId) pairs already posted, so each
|
|
8
|
-
* citation lands exactly once regardless of how many times Stop fires.
|
|
9
|
-
*
|
|
10
|
-
* Storage parallels the surfaced-memo (`memo.ts`): same state directory
|
|
11
|
-
* (`~/.nlm/hook-state/`, overridable via NLM_HOOK_STATE_DIR), filename suffix
|
|
12
|
-
* `.cited.json` to distinguish from the surfaced memo's `.json`. The existing
|
|
13
|
-
* memo-sweep walks the directory by mtime and cleans both files together.
|
|
14
|
-
*
|
|
15
|
-
* Defensive: a missing or corrupt file yields an empty set; a write failure
|
|
16
|
-
* is swallowed. Telemetry path — must never break the hook.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
-
import { homedir } from "node:os";
|
|
21
|
-
import { join } from "node:path";
|
|
22
|
-
|
|
23
|
-
function stateDir(): string {
|
|
24
|
-
return process.env["NLM_HOOK_STATE_DIR"] ?? join(homedir(), ".nlm", "hook-state");
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function memoPath(conversationId: string): string {
|
|
28
|
-
const safe = conversationId.replace(/[^A-Za-z0-9_-]/g, "_") || "unknown";
|
|
29
|
-
return join(stateDir(), `${safe}.cited.json`);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function loadCited(conversationId: string): Set<string> {
|
|
33
|
-
try {
|
|
34
|
-
const path = memoPath(conversationId);
|
|
35
|
-
if (!existsSync(path)) return new Set();
|
|
36
|
-
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
37
|
-
if (!Array.isArray(parsed)) return new Set();
|
|
38
|
-
return new Set(parsed.filter((x): x is string => typeof x === "string"));
|
|
39
|
-
} catch {
|
|
40
|
-
return new Set();
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function recordCited(
|
|
45
|
-
conversationId: string,
|
|
46
|
-
ids: ReadonlyArray<string>,
|
|
47
|
-
): void {
|
|
48
|
-
if (ids.length === 0) return;
|
|
49
|
-
try {
|
|
50
|
-
const merged = loadCited(conversationId);
|
|
51
|
-
for (const id of ids) merged.add(id);
|
|
52
|
-
mkdirSync(stateDir(), { recursive: true });
|
|
53
|
-
writeFileSync(memoPath(conversationId), JSON.stringify([...merged]), "utf8");
|
|
54
|
-
} catch {
|
|
55
|
-
// Memo write failure must never break the hook.
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function clearCited(conversationId: string): boolean {
|
|
60
|
-
try {
|
|
61
|
-
const path = memoPath(conversationId);
|
|
62
|
-
if (!existsSync(path)) return false;
|
|
63
|
-
rmSync(path);
|
|
64
|
-
return true;
|
|
65
|
-
} catch {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Adds/removes NLM hook entries in a Claude Code settings.json.
|
|
3
|
-
*
|
|
4
|
-
* NLM-owned entries are identified by HOOK_SCRIPT_MARKERS. add is idempotent
|
|
5
|
-
* (replaces any prior NLM entry for the same event); remove strips only NLM
|
|
6
|
-
* entries and preserves everything else.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
-
import { dirname } from "node:path";
|
|
11
|
-
import { spawnSync } from "node:child_process";
|
|
12
|
-
|
|
13
|
-
// Every NLM hook script ends in `-hook.js`. We tag entries we own by
|
|
14
|
-
// matching the filename suffix against this list. Add new entries here
|
|
15
|
-
// when a new hook script ships.
|
|
16
|
-
const HOOK_SCRIPT_MARKERS = [
|
|
17
|
-
"prompt-recall-hook.js",
|
|
18
|
-
"session-end-hook.js",
|
|
19
|
-
"stop-hook.js",
|
|
20
|
-
"session-start-hook.js",
|
|
21
|
-
"pre-compact-hook.js",
|
|
22
|
-
"subagent-start-hook.js",
|
|
23
|
-
] as const;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Single-quote a shell argument so paths with spaces or other shell
|
|
27
|
-
* metacharacters survive `sh -c` tokenization. Without this, a path like
|
|
28
|
-
* `/Users/echalupa/Documents/Coding Projects/...` is split on whitespace
|
|
29
|
-
* and node receives the wrong argv — silent hook bricking.
|
|
30
|
-
*/
|
|
31
|
-
export function shellQuote(arg: string): string {
|
|
32
|
-
return `'${arg.replace(/'/g, "'\\''")}'`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function buildHookCommand(
|
|
36
|
-
execPath: string,
|
|
37
|
-
hookJs: string,
|
|
38
|
-
mode: "shadow" | "live",
|
|
39
|
-
): string {
|
|
40
|
-
return `NLM_HOOK_MODE=${mode} ${shellQuote(execPath)} ${shellQuote(hookJs)}`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface SmokeTestResult {
|
|
44
|
-
readonly ok: boolean;
|
|
45
|
-
readonly reason?: string;
|
|
46
|
-
readonly stderr?: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Invoke the wired command exactly the way Claude Code does (sh -c with
|
|
51
|
-
* JSON on stdin) and confirm the hook log gained an entry. Catches the
|
|
52
|
-
* class of failures where settings.json looks valid but the hook fails
|
|
53
|
-
* at startup (path tokenization, missing modules, etc.).
|
|
54
|
-
*/
|
|
55
|
-
export function smokeTestHookCommand(
|
|
56
|
-
command: string,
|
|
57
|
-
hookLogPath: string,
|
|
58
|
-
timeoutMs = 5000,
|
|
59
|
-
): SmokeTestResult {
|
|
60
|
-
const sizeBefore = existsSync(hookLogPath) ? statSync(hookLogPath).size : 0;
|
|
61
|
-
const result = spawnSync("sh", ["-c", command], {
|
|
62
|
-
input: JSON.stringify({ prompt: "smoke test", session_id: "install-smoke" }),
|
|
63
|
-
timeout: timeoutMs,
|
|
64
|
-
encoding: "utf8",
|
|
65
|
-
});
|
|
66
|
-
if (result.error) {
|
|
67
|
-
return { ok: false, reason: `spawn failed: ${result.error.message}` };
|
|
68
|
-
}
|
|
69
|
-
if (result.status !== 0) {
|
|
70
|
-
return {
|
|
71
|
-
ok: false,
|
|
72
|
-
reason: `exit code ${result.status ?? "null"}`,
|
|
73
|
-
stderr: result.stderr,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
const sizeAfter = existsSync(hookLogPath) ? statSync(hookLogPath).size : 0;
|
|
77
|
-
if (sizeAfter <= sizeBefore) {
|
|
78
|
-
return {
|
|
79
|
-
ok: false,
|
|
80
|
-
reason: `no entry appended to ${hookLogPath}`,
|
|
81
|
-
stderr: result.stderr,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
return { ok: true };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export type ClaudeHookEvent =
|
|
88
|
-
| "UserPromptSubmit"
|
|
89
|
-
| "SessionStart"
|
|
90
|
-
| "SessionEnd"
|
|
91
|
-
| "Stop"
|
|
92
|
-
| "PreCompact"
|
|
93
|
-
| "SubagentStart"
|
|
94
|
-
| "PostToolUse"
|
|
95
|
-
| "PreToolUse";
|
|
96
|
-
|
|
97
|
-
interface HookCommand {
|
|
98
|
-
readonly type: string;
|
|
99
|
-
readonly command: string;
|
|
100
|
-
}
|
|
101
|
-
interface HookEntry {
|
|
102
|
-
readonly hooks: ReadonlyArray<HookCommand>;
|
|
103
|
-
}
|
|
104
|
-
interface ClaudeSettings {
|
|
105
|
-
hooks?: Record<string, HookEntry[]>;
|
|
106
|
-
[key: string]: unknown;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function read(path: string): ClaudeSettings {
|
|
110
|
-
if (!existsSync(path)) return {};
|
|
111
|
-
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
112
|
-
if (typeof parsed !== "object" || parsed === null) {
|
|
113
|
-
throw new Error(`Claude settings at ${path} is not a JSON object`);
|
|
114
|
-
}
|
|
115
|
-
return parsed as ClaudeSettings;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function write(path: string, settings: ClaudeSettings): void {
|
|
119
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
120
|
-
writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function isNlmEntry(entry: HookEntry): boolean {
|
|
124
|
-
return entry.hooks.some((h) =>
|
|
125
|
-
HOOK_SCRIPT_MARKERS.some((marker) => h.command.includes(marker)),
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export function addHook(
|
|
130
|
-
settingsPath: string,
|
|
131
|
-
command: string,
|
|
132
|
-
event: ClaudeHookEvent = "UserPromptSubmit",
|
|
133
|
-
): void {
|
|
134
|
-
const settings = read(settingsPath);
|
|
135
|
-
const hooks = settings.hooks ?? {};
|
|
136
|
-
const existing = hooks[event] ?? [];
|
|
137
|
-
const others = existing.filter((e) => !isNlmEntry(e));
|
|
138
|
-
const next: HookEntry[] = [
|
|
139
|
-
...others,
|
|
140
|
-
{ hooks: [{ type: "command", command }] },
|
|
141
|
-
];
|
|
142
|
-
write(settingsPath, { ...settings, hooks: { ...hooks, [event]: next } });
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Remove the NLM-tagged hook entry from one event (default UserPromptSubmit)
|
|
147
|
-
* or every event when `event === "*"`. Leaves unrelated entries untouched.
|
|
148
|
-
*/
|
|
149
|
-
export function removeHook(
|
|
150
|
-
settingsPath: string,
|
|
151
|
-
event: ClaudeHookEvent | "*" = "UserPromptSubmit",
|
|
152
|
-
): void {
|
|
153
|
-
if (!existsSync(settingsPath)) return;
|
|
154
|
-
const settings = read(settingsPath);
|
|
155
|
-
const allHooks = settings.hooks ?? {};
|
|
156
|
-
const events: string[] = event === "*" ? Object.keys(allHooks) : [event];
|
|
157
|
-
const nextHooks: Record<string, HookEntry[]> = { ...allHooks };
|
|
158
|
-
for (const ev of events) {
|
|
159
|
-
const existing = nextHooks[ev];
|
|
160
|
-
if (!existing) continue;
|
|
161
|
-
const kept = existing.filter((e) => !isNlmEntry(e));
|
|
162
|
-
if (kept.length > 0) nextHooks[ev] = kept;
|
|
163
|
-
else delete nextHooks[ev];
|
|
164
|
-
}
|
|
165
|
-
write(settingsPath, { ...settings, hooks: nextHooks });
|
|
166
|
-
}
|
package/src/core/hook/gate.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Prompt gate for the recall hook. Pure — no I/O.
|
|
3
|
-
*
|
|
4
|
-
* A conservative generative *excluder*: the default is "evaluate" (query
|
|
5
|
-
* recall); only high-precision generative openers short-circuit to
|
|
6
|
-
* "generative". A false "generative" wrongly skips recall — the exact
|
|
7
|
-
* failure this feature fixes — so the generative set is deliberately tight.
|
|
8
|
-
* It is calibrated further against shadow-mode logs.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export type PromptClass = "generative" | "evaluate";
|
|
12
|
-
|
|
13
|
-
const LEADING_FILLER =
|
|
14
|
-
/^(please|can you|could you|would you|will you|i need you to|i'd like you to|i want you to|i would like you to|help me|let's|lets|hey|ok|okay)\b[\s,]*/i;
|
|
15
|
-
|
|
16
|
-
const GENERATIVE_OPENER =
|
|
17
|
-
/^(write|draft|create|compose|generate|brainstorm|design|outline|sketch|invent|rename|come up with)\b/i;
|
|
18
|
-
|
|
19
|
-
export function classifyPrompt(prompt: string): PromptClass {
|
|
20
|
-
let p = prompt.trim();
|
|
21
|
-
for (let i = 0; i < 3 && LEADING_FILLER.test(p); i++) {
|
|
22
|
-
p = p.replace(LEADING_FILLER, "");
|
|
23
|
-
}
|
|
24
|
-
return GENERATIVE_OPENER.test(p) ? "generative" : "evaluate";
|
|
25
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Append-only JSONL log for the recall hook. One line per prompt the hook
|
|
3
|
-
* evaluated. This is the dataset the relevance gate (generative patterns +
|
|
4
|
-
* score threshold) is calibrated against during the shadow window.
|
|
5
|
-
*
|
|
6
|
-
* Path defaults to ~/.nlm/hook-log.jsonl, overridable via NLM_HOOK_LOG.
|
|
7
|
-
* appendHookLog swallows its own errors — telemetry must never break the hook.
|
|
8
|
-
* Uses synchronous I/O: the hook is a short-lived per-prompt process, and an
|
|
9
|
-
* async write could be lost if the process exits before it flushes.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { appendFileSync, mkdirSync } from "node:fs";
|
|
13
|
-
import { homedir } from "node:os";
|
|
14
|
-
import { dirname, join } from "node:path";
|
|
15
|
-
import type { PromptClass } from "./gate.js";
|
|
16
|
-
|
|
17
|
-
export interface HookLogEntry {
|
|
18
|
-
readonly ts: string;
|
|
19
|
-
readonly conversationId: string;
|
|
20
|
-
readonly promptPreview: string;
|
|
21
|
-
readonly gate: PromptClass;
|
|
22
|
-
readonly hits: ReadonlyArray<{ readonly id: string; readonly score: number }>;
|
|
23
|
-
readonly wouldInject: ReadonlyArray<string>;
|
|
24
|
-
readonly estTokens: number;
|
|
25
|
-
readonly mode: "shadow" | "live";
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function logPath(): string {
|
|
29
|
-
return process.env["NLM_HOOK_LOG"] ?? join(homedir(), ".nlm", "hook-log.jsonl");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function appendHookLog(entry: HookLogEntry): void {
|
|
33
|
-
try {
|
|
34
|
-
const path = logPath();
|
|
35
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
36
|
-
// Sync I/O: hook is a short-lived process — async write could be lost on exit.
|
|
37
|
-
appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8");
|
|
38
|
-
} catch {
|
|
39
|
-
// Telemetry failure must never break the hook.
|
|
40
|
-
}
|
|
41
|
-
}
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Idle sweep for per-conversation hook memo files.
|
|
3
|
-
*
|
|
4
|
-
* The SessionEnd hook is best-effort — Claude Code doesn't fire it on
|
|
5
|
-
* crashes, kill -9, or IDE force-close. Without a backstop, memo files
|
|
6
|
-
* at ~/.nlm/hook-state/<conv>.json accumulate forever for any session
|
|
7
|
-
* that didn't close cleanly.
|
|
8
|
-
*
|
|
9
|
-
* This sweep is the daemon-side backstop. It runs on a timer, scans the
|
|
10
|
-
* state directory, and deletes any memo whose mtime is older than the
|
|
11
|
-
* dormant threshold. Reuses the same `age > day` threshold the dataset
|
|
12
|
-
* builder uses to mark runtimes as "dormant" so the UI/dataset semantics
|
|
13
|
-
* stay consistent across the system.
|
|
14
|
-
*
|
|
15
|
-
* Hooks are fast-path; this is the always-correct backstop.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
19
|
-
import { homedir } from "node:os";
|
|
20
|
-
import { join } from "node:path";
|
|
21
|
-
|
|
22
|
-
// Mirrors the dormant threshold in build-dataset.ts:
|
|
23
|
-
// age <= hour → "active"
|
|
24
|
-
// age <= day → "idle"
|
|
25
|
-
// age > day → "dormant"
|
|
26
|
-
// We sweep memos that are dormant.
|
|
27
|
-
const DEFAULT_DORMANT_MS = 24 * 60 * 60 * 1000;
|
|
28
|
-
const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
|
|
29
|
-
|
|
30
|
-
export interface MemoSweepOptions {
|
|
31
|
-
/** Directory holding per-conversation memo files. Defaults to ~/.nlm/hook-state/. */
|
|
32
|
-
readonly stateDir?: string;
|
|
33
|
-
/** Age threshold in ms beyond which a memo is swept. Default 24h (dormant). */
|
|
34
|
-
readonly dormantMs?: number;
|
|
35
|
-
/** Tick interval in ms. Default 5 min. */
|
|
36
|
-
readonly intervalMs?: number;
|
|
37
|
-
/** Defaults to console.error. Set to a noop in tests. */
|
|
38
|
-
readonly logger?: (msg: string) => void;
|
|
39
|
-
/** Override for time source — for deterministic tests. */
|
|
40
|
-
readonly now?: () => number;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface SweepReport {
|
|
44
|
-
readonly scanned: number;
|
|
45
|
-
readonly deleted: number;
|
|
46
|
-
readonly kept: number;
|
|
47
|
-
readonly errors: number;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function defaultStateDir(): string {
|
|
51
|
-
return process.env["NLM_HOOK_STATE_DIR"] ?? join(homedir(), ".nlm", "hook-state");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* One-shot sweep. Returns the report; safe to call from tests or one-off
|
|
56
|
-
* CLI invocations without standing up the scheduler.
|
|
57
|
-
*/
|
|
58
|
-
export function sweepMemoDir(opts: MemoSweepOptions = {}): SweepReport {
|
|
59
|
-
const stateDir = opts.stateDir ?? defaultStateDir();
|
|
60
|
-
const dormantMs = opts.dormantMs ?? DEFAULT_DORMANT_MS;
|
|
61
|
-
const now = opts.now ?? Date.now;
|
|
62
|
-
const logger = opts.logger ?? ((msg) => console.error(msg));
|
|
63
|
-
|
|
64
|
-
if (!existsSync(stateDir)) {
|
|
65
|
-
return { scanned: 0, deleted: 0, kept: 0, errors: 0 };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
let entries: ReadonlyArray<string>;
|
|
69
|
-
try {
|
|
70
|
-
entries = readdirSync(stateDir);
|
|
71
|
-
} catch (e) {
|
|
72
|
-
logger(`[memo-sweep] readdir failed for ${stateDir}: ${e instanceof Error ? e.message : String(e)}`);
|
|
73
|
-
return { scanned: 0, deleted: 0, kept: 0, errors: 1 };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const cutoff = now() - dormantMs;
|
|
77
|
-
let deleted = 0;
|
|
78
|
-
let kept = 0;
|
|
79
|
-
let errors = 0;
|
|
80
|
-
|
|
81
|
-
for (const name of entries) {
|
|
82
|
-
if (!name.endsWith(".json")) {
|
|
83
|
-
// Don't touch files we don't own (kept silently, don't even count).
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
const path = join(stateDir, name);
|
|
87
|
-
try {
|
|
88
|
-
const stat = statSync(path);
|
|
89
|
-
if (stat.mtimeMs < cutoff) {
|
|
90
|
-
rmSync(path);
|
|
91
|
-
deleted += 1;
|
|
92
|
-
} else {
|
|
93
|
-
kept += 1;
|
|
94
|
-
}
|
|
95
|
-
} catch (e) {
|
|
96
|
-
errors += 1;
|
|
97
|
-
logger(`[memo-sweep] failed on ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return { scanned: deleted + kept + errors, deleted, kept, errors };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Periodic sweep loop. Mirrors ScanScheduler's start/stop shape so the
|
|
106
|
-
* daemon can manage it the same way. First tick fires immediately on
|
|
107
|
-
* start() — the daemon picking up after a long downtime should sweep
|
|
108
|
-
* accumulated memos right away, not wait an interval.
|
|
109
|
-
*/
|
|
110
|
-
export class MemoSweepScheduler {
|
|
111
|
-
private readonly opts: Required<Omit<MemoSweepOptions, "stateDir" | "now">> & {
|
|
112
|
-
readonly stateDir: string | undefined;
|
|
113
|
-
readonly now: (() => number) | undefined;
|
|
114
|
-
};
|
|
115
|
-
private stopped = true;
|
|
116
|
-
private timer: NodeJS.Timeout | null = null;
|
|
117
|
-
|
|
118
|
-
constructor(opts: MemoSweepOptions = {}) {
|
|
119
|
-
this.opts = {
|
|
120
|
-
stateDir: opts.stateDir,
|
|
121
|
-
dormantMs: opts.dormantMs ?? DEFAULT_DORMANT_MS,
|
|
122
|
-
intervalMs: opts.intervalMs ?? DEFAULT_INTERVAL_MS,
|
|
123
|
-
logger: opts.logger ?? ((msg) => console.error(msg)),
|
|
124
|
-
now: opts.now,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
start(): void {
|
|
129
|
-
if (!this.stopped) return;
|
|
130
|
-
this.stopped = false;
|
|
131
|
-
this.scheduleNext(0);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
stop(): void {
|
|
135
|
-
this.stopped = true;
|
|
136
|
-
if (this.timer) {
|
|
137
|
-
clearTimeout(this.timer);
|
|
138
|
-
this.timer = null;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
tick(): SweepReport {
|
|
143
|
-
return sweepMemoDir({
|
|
144
|
-
dormantMs: this.opts.dormantMs,
|
|
145
|
-
logger: this.opts.logger,
|
|
146
|
-
...(this.opts.stateDir !== undefined ? { stateDir: this.opts.stateDir } : {}),
|
|
147
|
-
...(this.opts.now !== undefined ? { now: this.opts.now } : {}),
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
private scheduleNext(delayMs: number): void {
|
|
152
|
-
if (this.stopped) return;
|
|
153
|
-
this.timer = setTimeout(() => {
|
|
154
|
-
try {
|
|
155
|
-
this.tick();
|
|
156
|
-
} catch (e) {
|
|
157
|
-
this.opts.logger(`[memo-sweep] tick crashed: ${e instanceof Error ? e.message : String(e)}`);
|
|
158
|
-
}
|
|
159
|
-
this.scheduleNext(this.opts.intervalMs);
|
|
160
|
-
}, delayMs);
|
|
161
|
-
// Don't keep the event loop alive just for the sweep.
|
|
162
|
-
if (this.timer && typeof this.timer.unref === "function") this.timer.unref();
|
|
163
|
-
}
|
|
164
|
-
}
|