recallnest 1.4.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/.claude-plugin/CLAUDE.md +29 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/.env.example +3 -0
- package/.github/workflows/ci.yml +28 -0
- package/.mcp.json +9 -0
- package/CHANGELOG.md +51 -0
- package/CLAUDE.md +42 -0
- package/DEMO.md +94 -0
- package/EVAL.md +89 -0
- package/FAILURES.md +75 -0
- package/LICENSE +21 -0
- package/OPERATIONS.md +59 -0
- package/README.md +427 -0
- package/README_CN.md +425 -0
- package/ROADMAP.md +198 -0
- package/assets/recallnest-workbench.png +0 -0
- package/assets/ui/app.js +807 -0
- package/assets/ui/index.html +154 -0
- package/assets/ui/styles.css +605 -0
- package/assets/wechat-qr.jpg +0 -0
- package/config.json.example +38 -0
- package/daily-recall.sh +87 -0
- package/daily-reflections/2026-03-05.md +15 -0
- package/daily-reflections/2026-03-06.md +15 -0
- package/daily-reflections/2026-03-07.md +7 -0
- package/daily-reflections/2026-03-08.md +34 -0
- package/daily-reflections/2026-03-09.md +35 -0
- package/daily-reflections/2026-03-10.md +36 -0
- package/daily-reflections/2026-03-11.md +35 -0
- package/daily-reflections/2026-03-12.md +36 -0
- package/daily-reflections/2026-03-13.md +35 -0
- package/daily-reflections/2026-03-14.md +35 -0
- package/daily-reflections/2026-03-15.md +35 -0
- package/daily-reflections/2026-03-16.md +41 -0
- package/daily-reflections/2026-03-17.md +10 -0
- package/daily-reflections/2026-03-18.md +10 -0
- package/daily-reflections/2026-03-19.md +10 -0
- package/daily-reflections/2026-03-20.md +10 -0
- package/daily-reflections/2026-03-21.md +10 -0
- package/daily-reflections/2026-03-22.md +10 -0
- package/daily-reflections/2026-03-23.md +10 -0
- package/daily-reflections/2026-03-24.md +10 -0
- package/daily-reflections/2026-03-25.md +32 -0
- package/daily-reflections/2026-03-26.md +37 -0
- package/daily-reflections/2026-03-27.md +35 -0
- package/daily-reflections/2026-03-28.md +35 -0
- package/daily-reflections/2026-03-29.md +35 -0
- package/daily-reflections/2026-03-30.md +36 -0
- package/daily-reflections/2026-03-31.md +35 -0
- package/daily-reflections/2026-04-01.md +35 -0
- package/docs/api-reference.md +1291 -0
- package/docs/architecture.md +168 -0
- package/docs/memory-boundary-contract.md +226 -0
- package/docs/memory-categories.md +119 -0
- package/docs/pipeline-audit-ultramemory.md +177 -0
- package/docs/plans/2026-03-08-query-expansion.md +270 -0
- package/docs/plans/2026-03-16-continuity-layer.md +196 -0
- package/docs/plans/mcp-tiering-plan.md +126 -0
- package/docs/research/recall-quality-audit-2026-03-22.md +153 -0
- package/docs/self-evolution.md +198 -0
- package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +782 -0
- package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +77 -0
- package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +95 -0
- package/eval/cases.json +85 -0
- package/eval/continuity/case-seeds.json +90 -0
- package/eval/continuity/cases.json +302 -0
- package/eval/continuity/memory-seeds.json +26 -0
- package/eval/continuity/pattern-seeds.json +100 -0
- package/eval/continuity/results-2026-03-16.md +117 -0
- package/eval/continuity/results-2026-03-19.md +453 -0
- package/eval/continuity/runbook.md +110 -0
- package/eval/continuity/seed-cases.md +54 -0
- package/eval/reports/2026-03-06-abstract-query-baseline.md +96 -0
- package/eval/reports/2026-03-06-aws-ssh-baseline.md +82 -0
- package/eval/reports/2026-03-06-baseline.md +82 -0
- package/eval/reports/query-expansion.md +138 -0
- package/integrations/README.md +57 -0
- package/integrations/claude-code/README.md +133 -0
- package/integrations/claude-code/claude-md-snippet.md +31 -0
- package/integrations/claude-code/setup.sh +66 -0
- package/integrations/claude-code/smoke-continuity.sh +452 -0
- package/integrations/codex/README.md +49 -0
- package/integrations/codex/agents-md-snippet.md +31 -0
- package/integrations/codex/setup.sh +58 -0
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +182 -0
- package/integrations/examples/langchain/memory-chain.py +111 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +109 -0
- package/integrations/gemini-cli/README.md +57 -0
- package/integrations/gemini-cli/gemini-md-snippet.md +31 -0
- package/integrations/gemini-cli/setup.sh +64 -0
- package/integrations/shared/install-managed-block.sh +51 -0
- package/lm +19 -0
- package/package.json +50 -0
- package/plan-v1.2-distributable.md +105 -0
- package/scripts/archive-distilled-junk.ts +116 -0
- package/scripts/backfill-l0l1l2.ts +98 -0
- package/scripts/blind-scan.ts +588 -0
- package/scripts/cleanup-step1-exact-dups.ts +117 -0
- package/scripts/cleanup-step2-template-dups.ts +113 -0
- package/scripts/cleanup-step3-stale-content.ts +149 -0
- package/scripts/cleanup-step4-orphan-scopes.ts +131 -0
- package/scripts/delete-distilled-junk.ts +88 -0
- package/scripts/distill-facts.ts +305 -0
- package/scripts/health-check.ts +343 -0
- package/scripts/incremental-ingest.sh +71 -0
- package/scripts/mlp-health-check.ts +382 -0
- package/scripts/pull-claude-desktop.ts +515 -0
- package/scripts/re-ingest-cc.sh +66 -0
- package/scripts/rebalance-memory-health.ts +181 -0
- package/scripts/run-desktop-ingest.sh +11 -0
- package/scripts/start-server.sh +12 -0
- package/scripts/viz-dashboard.html +423 -0
- package/scripts/viz-export.ts +703 -0
- package/src/__tests__/access-tracker-novelty.test.ts +228 -0
- package/src/__tests__/auto-consolidation.test.ts +168 -0
- package/src/__tests__/auto-recall.test.ts +131 -0
- package/src/__tests__/batch-dedup.test.ts +65 -0
- package/src/__tests__/capture-engine.test.ts +1142 -0
- package/src/__tests__/capture-heuristic.test.ts +171 -0
- package/src/__tests__/cascade-forget.test.ts +210 -0
- package/src/__tests__/category-min-scores.test.ts +33 -0
- package/src/__tests__/confidence-tracker.test.ts +196 -0
- package/src/__tests__/conflict-advisor.test.ts +206 -0
- package/src/__tests__/conflict-escalation.test.ts +149 -0
- package/src/__tests__/conflict-lifecycle.test.ts +83 -0
- package/src/__tests__/conflict-output.test.ts +85 -0
- package/src/__tests__/conflict-store.test.ts +374 -0
- package/src/__tests__/consolidation-engine.test.ts +181 -0
- package/src/__tests__/context-composer-pins.test.ts +64 -0
- package/src/__tests__/context-composer-task-selection.test.ts +24 -0
- package/src/__tests__/context-renderer.test.ts +91 -0
- package/src/__tests__/continuity-eval.test.ts +275 -0
- package/src/__tests__/continuity-schema.test.ts +97 -0
- package/src/__tests__/core-recall-eval.test.ts +235 -0
- package/src/__tests__/core-summary.test.ts +109 -0
- package/src/__tests__/dedup-actions.test.ts +58 -0
- package/src/__tests__/deterministic-id.test.ts +39 -0
- package/src/__tests__/doctor.test.ts +303 -0
- package/src/__tests__/drill-down.test.ts +108 -0
- package/src/__tests__/embedder.test.ts +122 -0
- package/src/__tests__/entity-resolver.test.ts +58 -0
- package/src/__tests__/event-segmenter.test.ts +153 -0
- package/src/__tests__/hotness-blend.test.ts +76 -0
- package/src/__tests__/ingest.test.ts +360 -0
- package/src/__tests__/kg-extractor.test.ts +184 -0
- package/src/__tests__/kg-store.test.ts +198 -0
- package/src/__tests__/llm-consolidation.test.ts +226 -0
- package/src/__tests__/memory-boundaries.test.ts +237 -0
- package/src/__tests__/memory-health-rebalance.test.ts +144 -0
- package/src/__tests__/memory-output.test.ts +215 -0
- package/src/__tests__/multi-vector.test.ts +242 -0
- package/src/__tests__/ppr-traversal.test.ts +256 -0
- package/src/__tests__/preference-guard-smoke.test.ts +211 -0
- package/src/__tests__/preference-matcher.test.ts +230 -0
- package/src/__tests__/preference-slots.test.ts +106 -0
- package/src/__tests__/prospective-memory.test.ts +283 -0
- package/src/__tests__/query-entity-detector.test.ts +171 -0
- package/src/__tests__/query-expander.test.ts +34 -0
- package/src/__tests__/recall-mode.test.ts +64 -0
- package/src/__tests__/result-synthesizer.test.ts +198 -0
- package/src/__tests__/resume-context.test.ts +4743 -0
- package/src/__tests__/retrieval-trace.test.ts +213 -0
- package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
- package/src/__tests__/retroactive-boost.test.ts +223 -0
- package/src/__tests__/scope-inventory.test.ts +200 -0
- package/src/__tests__/scope-policy.test.ts +69 -0
- package/src/__tests__/session-checkpoint.test.ts +259 -0
- package/src/__tests__/session-gc.test.ts +215 -0
- package/src/__tests__/store-scope-audit.test.ts +81 -0
- package/src/__tests__/temporal-parser.test.ts +241 -0
- package/src/__tests__/term-registry.test.ts +121 -0
- package/src/__tests__/tool-output-compressor.test.ts +133 -0
- package/src/__tests__/version-manager.test.ts +213 -0
- package/src/__tests__/workflow-observation.test.ts +400 -0
- package/src/access-tracker.ts +316 -0
- package/src/adaptive-retrieval.ts +64 -0
- package/src/api-server.ts +667 -0
- package/src/asset-sync.ts +79 -0
- package/src/auto-consolidation.ts +133 -0
- package/src/auto-gc.ts +124 -0
- package/src/auto-recall.ts +95 -0
- package/src/capture-engine.ts +931 -0
- package/src/capture-heuristic.ts +177 -0
- package/src/cascade-forget.ts +135 -0
- package/src/chunker.ts +253 -0
- package/src/cli.ts +1917 -0
- package/src/confidence-tracker.ts +166 -0
- package/src/conflict-advisor.ts +433 -0
- package/src/conflict-engine.ts +181 -0
- package/src/conflict-escalation.ts +138 -0
- package/src/conflict-lifecycle.ts +67 -0
- package/src/conflict-output.ts +218 -0
- package/src/conflict-schema.ts +130 -0
- package/src/conflict-store.ts +127 -0
- package/src/consolidation-engine.ts +256 -0
- package/src/context-composer-pins.ts +183 -0
- package/src/context-composer-queries.ts +121 -0
- package/src/context-composer-scope.ts +56 -0
- package/src/context-composer-stable-selection.ts +200 -0
- package/src/context-composer-stable.ts +217 -0
- package/src/context-composer-task-fallbacks.ts +36 -0
- package/src/context-composer-task-ranking.ts +197 -0
- package/src/context-composer-task-results.ts +137 -0
- package/src/context-composer-task-selection.ts +342 -0
- package/src/context-composer-text.ts +57 -0
- package/src/context-composer.ts +300 -0
- package/src/context-renderer.ts +179 -0
- package/src/decay-engine.ts +189 -0
- package/src/doctor.ts +543 -0
- package/src/embedder.ts +557 -0
- package/src/entity-resolver.ts +131 -0
- package/src/eval.ts +632 -0
- package/src/event-segmenter.ts +216 -0
- package/src/ingest.ts +1822 -0
- package/src/kg-extractor.ts +217 -0
- package/src/kg-store.ts +353 -0
- package/src/legacy-scope-review.ts +53 -0
- package/src/llm-client.ts +629 -0
- package/src/llm-consolidation.ts +168 -0
- package/src/mcp-server.ts +1337 -0
- package/src/memory-assets.ts +475 -0
- package/src/memory-boundaries.ts +354 -0
- package/src/memory-health-rebalance.ts +220 -0
- package/src/memory-output.ts +474 -0
- package/src/memory-schema.ts +184 -0
- package/src/multi-vector.ts +234 -0
- package/src/noise-filter.ts +155 -0
- package/src/ppr-traversal.ts +279 -0
- package/src/preference-matcher.ts +174 -0
- package/src/preference-slots.ts +259 -0
- package/src/prospective-memory.ts +198 -0
- package/src/query-entity-detector.ts +207 -0
- package/src/query-expander.ts +98 -0
- package/src/result-synthesizer.ts +114 -0
- package/src/retrieval-profiles.ts +101 -0
- package/src/retrieval-trace.ts +94 -0
- package/src/retriever.ts +1338 -0
- package/src/retroactive-boost.ts +154 -0
- package/src/rif.ts +73 -0
- package/src/runtime-config.ts +172 -0
- package/src/schema-utils.ts +56 -0
- package/src/scope-inventory.ts +310 -0
- package/src/scope-policy.ts +142 -0
- package/src/session-engine.ts +168 -0
- package/src/session-output.ts +96 -0
- package/src/session-schema.ts +60 -0
- package/src/session-store.ts +293 -0
- package/src/stderr-log.ts +16 -0
- package/src/store.ts +903 -0
- package/src/temporal-parser.ts +318 -0
- package/src/term-registry.ts +791 -0
- package/src/tool-output-compressor.ts +115 -0
- package/src/tracker.ts +57 -0
- package/src/ui-server.ts +430 -0
- package/src/version-manager.ts +172 -0
- package/src/workflow-observation-engine.ts +316 -0
- package/src/workflow-observation-managed.ts +83 -0
- package/src/workflow-observation-output.ts +86 -0
- package/src/workflow-observation-schema.ts +87 -0
- package/src/workflow-observation-scope-review.ts +409 -0
- package/src/workflow-observation-store.ts +69 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Output Compressor
|
|
3
|
+
*
|
|
4
|
+
* Pre-processes conversation text before chunking/embedding to compress
|
|
5
|
+
* tool output noise (git boilerplate, passing test logs, base64 images, etc.).
|
|
6
|
+
* Reduces embedding token costs and improves extraction quality.
|
|
7
|
+
*
|
|
8
|
+
* Design principles:
|
|
9
|
+
* - Zero LLM overhead: pure regex/pattern matching
|
|
10
|
+
* - Never modifies user dialog or AI reasoning
|
|
11
|
+
* - Sits before chunking in the ingest pipeline
|
|
12
|
+
* - Inspired by CortexReach/memory-lancedb-pro PR #308
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
interface CompressionRule {
|
|
20
|
+
/** Human-readable name for logging */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Match the tool output block content */
|
|
23
|
+
match: RegExp;
|
|
24
|
+
/** Return compressed replacement. Receives the full matched block. */
|
|
25
|
+
compress: (block: string) => string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Compression Rules
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
const RULES: CompressionRule[] = [
|
|
33
|
+
// Git push/pull/fetch boilerplate → one-liner
|
|
34
|
+
{
|
|
35
|
+
name: "git-push-pull",
|
|
36
|
+
match: /Enumerating objects:.*?(?:-> |Branch ')[^\n]*/s,
|
|
37
|
+
compress: (block) => {
|
|
38
|
+
const branch = block.match(/-> (\S+)/)?.[1]
|
|
39
|
+
|| block.match(/Branch '([^']+)'/)?.[1]
|
|
40
|
+
|| "";
|
|
41
|
+
const failed = /error|rejected|fatal/i.test(block);
|
|
42
|
+
return failed ? block : `[git: ok${branch ? " " + branch : ""}]`;
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Test results: all-pass → one-liner, failures preserved
|
|
47
|
+
{
|
|
48
|
+
name: "test-summary",
|
|
49
|
+
match: /(?:running \d+ tests?|test result:|Tests?:.*(?:pass|fail)|PASS|FAIL|✓|✕)/,
|
|
50
|
+
compress: (block) => {
|
|
51
|
+
if (/fail|error|FAIL|✕/i.test(block)) return block; // Keep failures
|
|
52
|
+
const summary = block.match(
|
|
53
|
+
/test result:.*|Tests?:\s*\d+.*|(\d+)\s*(?:pass(?:ed)?|✓)/im,
|
|
54
|
+
)?.[0];
|
|
55
|
+
return summary ? `[test: ${summary.trim()}]` : block;
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// Patterns that identify tool output blocks in conversation text.
|
|
61
|
+
// Claude Code / Codex format: command on one line, output follows.
|
|
62
|
+
const TOOL_OUTPUT_BLOCK = /^(?:\$\s+|❯\s+|>\s+)(.+)\n([\s\S]*?)(?=\n(?:\$\s+|❯\s+|>\s+)|\n\n[A-Z]|\n##|\z)/gm;
|
|
63
|
+
|
|
64
|
+
// Base64 image data (screenshots, etc.)
|
|
65
|
+
const BASE64_BLOCK = /(?:data:image\/[^;]+;base64,)?[A-Za-z0-9+/]{500,}={0,2}/g;
|
|
66
|
+
|
|
67
|
+
// ============================================================================
|
|
68
|
+
// Main compressor
|
|
69
|
+
// ============================================================================
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compress tool output noise in conversation text.
|
|
73
|
+
* Call before chunking/embedding in the ingest pipeline.
|
|
74
|
+
*
|
|
75
|
+
* @param text - Raw conversation text
|
|
76
|
+
* @returns Compressed text with tool output noise reduced
|
|
77
|
+
*/
|
|
78
|
+
export function compressToolOutput(text: string): string {
|
|
79
|
+
if (!text || text.length < 100) return text;
|
|
80
|
+
|
|
81
|
+
let result = text;
|
|
82
|
+
|
|
83
|
+
// 1. Replace base64 image data with placeholder
|
|
84
|
+
result = result.replace(BASE64_BLOCK, (match) => {
|
|
85
|
+
if (match.length < 500) return match; // Short strings might be legit
|
|
86
|
+
return `[image: ~${Math.round(match.length / 1024)}KB base64]`;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// 2. Apply compression rules to tool output blocks
|
|
90
|
+
result = result.replace(TOOL_OUTPUT_BLOCK, (fullMatch, command: string, output: string) => {
|
|
91
|
+
const cmd = command.trim();
|
|
92
|
+
for (const rule of RULES) {
|
|
93
|
+
if (rule.match.test(output) || rule.match.test(cmd)) {
|
|
94
|
+
const compressed = rule.compress(output);
|
|
95
|
+
if (compressed !== output) {
|
|
96
|
+
return `$ ${cmd}\n${compressed}`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 3. Truncate large unmatched outputs (>2000 chars)
|
|
102
|
+
if (output.length > 2000) {
|
|
103
|
+
const head = output.slice(0, 1000);
|
|
104
|
+
const tail = output.slice(-300);
|
|
105
|
+
return `$ ${cmd}\n${head}\n[...${output.length - 1300} chars truncated...]\n${tail}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return fullMatch;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// 4. Collapse multiple blank lines left by removals
|
|
112
|
+
result = result.replace(/\n{4,}/g, "\n\n\n");
|
|
113
|
+
|
|
114
|
+
return result;
|
|
115
|
+
}
|
package/src/tracker.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 增量更新追踪器 — 记录已处理的文件,避免重复 ingest
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
const TRACKER_PATH = resolve(import.meta.dir, "../data/ingested-files.json");
|
|
9
|
+
|
|
10
|
+
interface TrackerData {
|
|
11
|
+
files: Record<string, { ingestedAt: string; size: number; chunks: number; mtimeMs?: number }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function load(): TrackerData {
|
|
15
|
+
if (!existsSync(TRACKER_PATH)) {
|
|
16
|
+
return { files: {} };
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(TRACKER_PATH, "utf-8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return { files: {} };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function save(data: TrackerData): void {
|
|
26
|
+
writeFileSync(TRACKER_PATH, JSON.stringify(data, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isProcessed(filePath: string, fileSize: number, mtimeMs?: number): boolean {
|
|
30
|
+
const data = load();
|
|
31
|
+
const entry = data.files[filePath];
|
|
32
|
+
if (!entry) return false;
|
|
33
|
+
// Re-process if file size or mtime changed
|
|
34
|
+
if (entry.size !== fileSize) return false;
|
|
35
|
+
if (mtimeMs !== undefined && entry.mtimeMs !== undefined && entry.mtimeMs !== mtimeMs) return false;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function markProcessed(filePath: string, fileSize: number, chunks: number, mtimeMs?: number): void {
|
|
40
|
+
const data = load();
|
|
41
|
+
data.files[filePath] = {
|
|
42
|
+
ingestedAt: new Date().toISOString(),
|
|
43
|
+
size: fileSize,
|
|
44
|
+
chunks,
|
|
45
|
+
...(mtimeMs !== undefined ? { mtimeMs } : {}),
|
|
46
|
+
};
|
|
47
|
+
save(data);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getStats(): { totalFiles: number; totalChunks: number } {
|
|
51
|
+
const data = load();
|
|
52
|
+
let totalChunks = 0;
|
|
53
|
+
for (const entry of Object.values(data.files)) {
|
|
54
|
+
totalChunks += entry.chunks;
|
|
55
|
+
}
|
|
56
|
+
return { totalFiles: Object.keys(data.files).length, totalChunks };
|
|
57
|
+
}
|
package/src/ui-server.ts
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
4
|
+
import { join, resolve, sep } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { RetrievalResult } from "./retriever.js";
|
|
7
|
+
import type { MemoryStore } from "./store.js";
|
|
8
|
+
import { distillResults, formatExplainResults, formatSearchResults, selectBriefSeedResults, summarizeResults } from "./memory-output.js";
|
|
9
|
+
import { extractMemoryProvenance } from "./memory-boundaries.js";
|
|
10
|
+
import { archiveDirtyBriefAsset, assetSummaryLine, buildBriefAsset, buildPinAsset, listDirtyBriefAssets, listExportArtifacts, listMemoryAssets, saveBriefAsset, savePinAsset, writeExportArtifact } from "./memory-assets.js";
|
|
11
|
+
import { indexAsset, indexPinnedAsset } from "./asset-sync.js";
|
|
12
|
+
import { createComponentResolver, loadConfig, loadDotEnv } from "./runtime-config.js";
|
|
13
|
+
import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
|
|
14
|
+
const config = (loadDotEnv(), loadConfig());
|
|
15
|
+
const getComponents = createComponentResolver(config);
|
|
16
|
+
|
|
17
|
+
class UiHttpError extends Error {
|
|
18
|
+
status: number;
|
|
19
|
+
|
|
20
|
+
constructor(status: number, message: string) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.status = status;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function entryToRetrievalResult(entry: Awaited<ReturnType<MemoryStore["get"]>>): RetrievalResult {
|
|
27
|
+
if (!entry) throw new Error("Memory entry not found.");
|
|
28
|
+
return {
|
|
29
|
+
entry,
|
|
30
|
+
score: entry.importance || 0.7,
|
|
31
|
+
sources: { fused: { score: entry.importance || 0.7 } },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readJson(request: Request) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = await request.json();
|
|
38
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
39
|
+
throw new UiHttpError(400, "JSON body must be an object");
|
|
40
|
+
}
|
|
41
|
+
return parsed as Record<string, any>;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error instanceof UiHttpError) throw error;
|
|
44
|
+
throw new UiHttpError(400, "Invalid JSON body");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readString(value: unknown, fallback = ""): string {
|
|
49
|
+
return typeof value === "string" ? value : fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readOptionalString(value: unknown): string | undefined {
|
|
53
|
+
const trimmed = readString(value).trim();
|
|
54
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function requireString(value: unknown, field: string): string {
|
|
58
|
+
const resolved = readOptionalString(value);
|
|
59
|
+
if (!resolved) {
|
|
60
|
+
throw new UiHttpError(400, `${field} is required`);
|
|
61
|
+
}
|
|
62
|
+
return resolved;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readLimit(value: unknown, fallback: number, min = 1, max = 50): number {
|
|
66
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
67
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
68
|
+
return Math.min(max, Math.max(min, parsed));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function textResponse(output: string, init?: ResponseInit) {
|
|
72
|
+
return Response.json({ output }, init);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function errorResponse(error: unknown): Response {
|
|
76
|
+
if (error instanceof UiHttpError) {
|
|
77
|
+
return textResponse(error.message, { status: error.status });
|
|
78
|
+
}
|
|
79
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
80
|
+
return textResponse(`Internal error: ${message}`, { status: 500 });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isAllowedArtifactPath(targetPath: string): boolean {
|
|
84
|
+
try {
|
|
85
|
+
const resolved = realpathSync(targetPath);
|
|
86
|
+
const exportsDir = resolve(import.meta.dir, "../data/exports");
|
|
87
|
+
const pinsDir = resolve(import.meta.dir, "../data/pins");
|
|
88
|
+
return (
|
|
89
|
+
resolved === exportsDir ||
|
|
90
|
+
resolved.startsWith(`${exportsDir}${sep}`) ||
|
|
91
|
+
resolved === pinsDir ||
|
|
92
|
+
resolved.startsWith(`${pinsDir}${sep}`)
|
|
93
|
+
);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseMetadata(raw?: string): Record<string, unknown> {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(raw || "{}") as Record<string, unknown>;
|
|
102
|
+
} catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getRetrievalPath(result: RetrievalResult): string {
|
|
108
|
+
const parts: string[] = [];
|
|
109
|
+
if (result.sources.vector) parts.push("vector");
|
|
110
|
+
if (result.sources.bm25) parts.push("bm25");
|
|
111
|
+
if (result.sources.reranked) parts.push("reranked");
|
|
112
|
+
return parts.join("+") || "direct";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function serializeResults(results: RetrievalResult[]) {
|
|
116
|
+
return results.map((result) => {
|
|
117
|
+
const metadata = parseMetadata(result.entry.metadata);
|
|
118
|
+
const provenance = extractMemoryProvenance({
|
|
119
|
+
scope: result.entry.scope,
|
|
120
|
+
metadata: result.entry.metadata,
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
id: result.entry.id,
|
|
124
|
+
shortId: result.entry.id.slice(0, 8),
|
|
125
|
+
score: Math.round(result.score * 100),
|
|
126
|
+
scope: result.entry.scope,
|
|
127
|
+
source: String(metadata.source || result.entry.scope || "?"),
|
|
128
|
+
file: String(metadata.file || metadata.heading || "-"),
|
|
129
|
+
timestamp: result.entry.timestamp,
|
|
130
|
+
date: new Date(result.entry.timestamp).toISOString().split("T")[0],
|
|
131
|
+
retrievalPath: getRetrievalPath(result),
|
|
132
|
+
text: result.entry.text,
|
|
133
|
+
metadata,
|
|
134
|
+
boundary: provenance.boundary,
|
|
135
|
+
canonicalKey: provenance.canonicalKey,
|
|
136
|
+
promotedFrom: provenance.promotedFrom,
|
|
137
|
+
provenanceHistory: provenance.provenanceHistory,
|
|
138
|
+
provenanceHistoryCount: provenance.provenanceHistoryCount,
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function serveStatic(pathname: string): Response | null {
|
|
144
|
+
const uiDir = resolve(import.meta.dir, "../assets/ui");
|
|
145
|
+
const relativePath = pathname === "/"
|
|
146
|
+
? join(uiDir, "index.html")
|
|
147
|
+
: join(uiDir, pathname.replace(/^\/ui\//, ""));
|
|
148
|
+
const filePath = resolve(relativePath);
|
|
149
|
+
if (filePath !== uiDir && !filePath.startsWith(`${uiDir}${sep}`)) {
|
|
150
|
+
return new Response("Forbidden", { status: 403 });
|
|
151
|
+
}
|
|
152
|
+
const file = Bun.file(filePath);
|
|
153
|
+
if (!existsSync(filePath)) return null;
|
|
154
|
+
return new Response(file);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function handleSearch(mode: "search" | "explain" | "distill", body: Record<string, any>) {
|
|
158
|
+
const query = requireString(body.query, "query");
|
|
159
|
+
const { retriever, profile } = getComponents(readOptionalString(body.profile));
|
|
160
|
+
const results = await retriever.retrieve(buildRetrievalContext({
|
|
161
|
+
query,
|
|
162
|
+
limit: readLimit(body.limit, 5, 1, 20),
|
|
163
|
+
scope: readOptionalString(body.scope),
|
|
164
|
+
sessionId: readOptionalString(body.sessionId),
|
|
165
|
+
allScopes: body.allScopes === true,
|
|
166
|
+
}, {
|
|
167
|
+
operation: `ui:${mode}`,
|
|
168
|
+
}));
|
|
169
|
+
const context = { query, profile: profile.name };
|
|
170
|
+
const output = mode === "explain"
|
|
171
|
+
? formatExplainResults(results, context)
|
|
172
|
+
: mode === "distill"
|
|
173
|
+
? distillResults(results, context)
|
|
174
|
+
: formatSearchResults(results, context);
|
|
175
|
+
return Response.json({
|
|
176
|
+
output,
|
|
177
|
+
mode,
|
|
178
|
+
profile: profile.name,
|
|
179
|
+
query,
|
|
180
|
+
items: serializeResults(results),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const server = Bun.serve({
|
|
185
|
+
port: readLimit(process.env.RECALLNEST_UI_PORT, 4317, 1, 65535),
|
|
186
|
+
async fetch(request) {
|
|
187
|
+
try {
|
|
188
|
+
const url = new URL(request.url);
|
|
189
|
+
|
|
190
|
+
if (request.method === "GET" && (url.pathname === "/" || url.pathname.startsWith("/ui/"))) {
|
|
191
|
+
const response = serveStatic(url.pathname);
|
|
192
|
+
if (response) return response;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (request.method === "POST" && url.pathname === "/api/search") {
|
|
196
|
+
return handleSearch("search", await readJson(request));
|
|
197
|
+
}
|
|
198
|
+
if (request.method === "POST" && url.pathname === "/api/explain") {
|
|
199
|
+
return handleSearch("explain", await readJson(request));
|
|
200
|
+
}
|
|
201
|
+
if (request.method === "POST" && url.pathname === "/api/distill") {
|
|
202
|
+
return handleSearch("distill", await readJson(request));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (request.method === "GET" && url.pathname === "/api/pins") {
|
|
206
|
+
const rows = listMemoryAssets(readLimit(url.searchParams.get("limit"), 10, 1, 50));
|
|
207
|
+
const output = rows.length === 0
|
|
208
|
+
? "No assets yet."
|
|
209
|
+
: [
|
|
210
|
+
"Asset ID Kind Title Scope / Sources Date",
|
|
211
|
+
"-------- ----- ----- --------------- ----------",
|
|
212
|
+
...rows.map(row => assetSummaryLine(row)),
|
|
213
|
+
].join("\n");
|
|
214
|
+
return Response.json({
|
|
215
|
+
output,
|
|
216
|
+
items: rows.map((row) => ({
|
|
217
|
+
id: row.id,
|
|
218
|
+
shortId: row.id.slice(0, 8),
|
|
219
|
+
type: row.type,
|
|
220
|
+
title: row.title,
|
|
221
|
+
summary: row.summary,
|
|
222
|
+
scope: row.type === "pinned-memory" ? row.source.scope : row.sources.map((item) => item.source).join(", "),
|
|
223
|
+
createdAt: row.createdAt,
|
|
224
|
+
date: row.createdAt.slice(0, 10),
|
|
225
|
+
tags: row.tags,
|
|
226
|
+
path: row.path,
|
|
227
|
+
sourceMemoryId: row.type === "pinned-memory" ? row.source.memoryId : undefined,
|
|
228
|
+
sourceScope: row.type === "pinned-memory" ? row.source.scope : undefined,
|
|
229
|
+
snippet: row.type === "pinned-memory" ? row.snippet : undefined,
|
|
230
|
+
retrieval: row.type === "pinned-memory" ? row.retrieval : undefined,
|
|
231
|
+
hits: row.type === "memory-brief" ? row.hits : undefined,
|
|
232
|
+
query: row.type === "memory-brief" ? row.query : undefined,
|
|
233
|
+
profile: row.type === "memory-brief" ? row.profile : undefined,
|
|
234
|
+
takeaways: row.type === "memory-brief" ? row.takeaways : undefined,
|
|
235
|
+
evidence: row.type === "memory-brief" ? row.evidence : undefined,
|
|
236
|
+
reusableCandidates: row.type === "memory-brief" ? row.reusableCandidates : undefined,
|
|
237
|
+
sources: row.type === "memory-brief" ? row.sources : undefined,
|
|
238
|
+
})),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (request.method === "GET" && url.pathname === "/api/exports") {
|
|
243
|
+
const rows = listExportArtifacts(readLimit(url.searchParams.get("limit"), 20, 1, 50));
|
|
244
|
+
const output = rows.length === 0
|
|
245
|
+
? "No exports yet."
|
|
246
|
+
: rows.map((row) => `${row.id.slice(0, 8)} ${row.query} [${row.profile}] ${row.createdAt.slice(0, 10)}`).join("\n");
|
|
247
|
+
return Response.json({
|
|
248
|
+
output,
|
|
249
|
+
items: rows.map((row) => ({
|
|
250
|
+
id: row.id,
|
|
251
|
+
shortId: row.id.length > 8 ? row.id.slice(-8) : row.id,
|
|
252
|
+
query: row.query,
|
|
253
|
+
profile: row.profile,
|
|
254
|
+
createdAt: row.createdAt,
|
|
255
|
+
date: row.createdAt.slice(0, 10),
|
|
256
|
+
format: row.format,
|
|
257
|
+
path: row.path,
|
|
258
|
+
summary: row.summary || "",
|
|
259
|
+
})),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (request.method === "GET" && url.pathname === "/api/stats") {
|
|
264
|
+
const { store } = getComponents();
|
|
265
|
+
const stats = await store.stats();
|
|
266
|
+
const sourceCounts = Object.entries(stats.scopeCounts)
|
|
267
|
+
.sort((a, b) => b[1] - a[1])
|
|
268
|
+
.map(([scope, count]) => `${scope}: ${count}`)
|
|
269
|
+
.join("\n");
|
|
270
|
+
return textResponse(`Total: ${stats.totalCount}\n\nBy scope:\n${sourceCounts}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (request.method === "GET" && url.pathname === "/api/dirty-briefs") {
|
|
274
|
+
const rows = listDirtyBriefAssets();
|
|
275
|
+
const output = rows.length === 0
|
|
276
|
+
? "No dirty briefs found."
|
|
277
|
+
: rows.map((row) => `${row.id.slice(0, 8)} ${row.title} [${row.scope}] ${row.reasons.join("; ")}`).join("\n");
|
|
278
|
+
return Response.json({
|
|
279
|
+
output,
|
|
280
|
+
count: rows.length,
|
|
281
|
+
items: rows.map((row) => ({
|
|
282
|
+
id: row.id,
|
|
283
|
+
shortId: row.id.slice(0, 8),
|
|
284
|
+
title: row.title,
|
|
285
|
+
scope: row.scope,
|
|
286
|
+
reasons: row.reasons,
|
|
287
|
+
path: row.path,
|
|
288
|
+
})),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (request.method === "POST" && url.pathname === "/api/pin") {
|
|
293
|
+
const body = await readJson(request);
|
|
294
|
+
const memoryId = requireString(body.memoryId, "memoryId");
|
|
295
|
+
const { store, embedder } = getComponents(readOptionalString(body.profile));
|
|
296
|
+
const scopeSelection = resolveScopeSelection({
|
|
297
|
+
scope: readOptionalString(body.scope),
|
|
298
|
+
sessionId: readOptionalString(body.sessionId),
|
|
299
|
+
allScopes: body.allScopes === true,
|
|
300
|
+
operation: "ui:pin",
|
|
301
|
+
});
|
|
302
|
+
const entry = await store.get(memoryId, scopeSelection.scopeFilter);
|
|
303
|
+
if (!entry) return textResponse(`Memory not found: ${memoryId}`, { status: 404 });
|
|
304
|
+
await store.update(entry.id, { importance: Math.max(entry.importance || 0.7, 0.95) }, scopeSelection.scopeFilter);
|
|
305
|
+
const asset = buildPinAsset(entryToRetrievalResult(entry), {
|
|
306
|
+
title: readOptionalString(body.title),
|
|
307
|
+
summary: readOptionalString(body.summary),
|
|
308
|
+
query: readOptionalString(body.query),
|
|
309
|
+
profile: readOptionalString(body.profile) as any || "default",
|
|
310
|
+
});
|
|
311
|
+
const path = savePinAsset(asset);
|
|
312
|
+
await indexPinnedAsset(store, embedder, asset);
|
|
313
|
+
return Response.json({
|
|
314
|
+
output: `Pinned ${asset.id.slice(0, 8)}\nMemory: ${entry.id.slice(0, 8)} (${entry.scope})\nPath: ${path}`,
|
|
315
|
+
assetId: asset.id,
|
|
316
|
+
memoryId: entry.id,
|
|
317
|
+
path,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (request.method === "POST" && url.pathname === "/api/brief") {
|
|
322
|
+
const body = await readJson(request);
|
|
323
|
+
const query = requireString(body.query, "query");
|
|
324
|
+
const { retriever, profile, store, embedder } = getComponents(readOptionalString(body.profile) || "writing");
|
|
325
|
+
const results = await retriever.retrieve(buildRetrievalContext({
|
|
326
|
+
query,
|
|
327
|
+
limit: readLimit(body.limit, 8, 1, 20),
|
|
328
|
+
scope: readOptionalString(body.scope),
|
|
329
|
+
sessionId: readOptionalString(body.sessionId),
|
|
330
|
+
allScopes: body.allScopes === true,
|
|
331
|
+
}, {
|
|
332
|
+
operation: "ui:brief",
|
|
333
|
+
}));
|
|
334
|
+
if (results.length === 0) {
|
|
335
|
+
return textResponse(`No results found for: ${query}`, { status: 404 });
|
|
336
|
+
}
|
|
337
|
+
const briefSeedResults = selectBriefSeedResults(results);
|
|
338
|
+
const summary = summarizeResults(briefSeedResults, { query, profile: profile.name });
|
|
339
|
+
const asset = buildBriefAsset(summary, { title: readOptionalString(body.title) });
|
|
340
|
+
const path = saveBriefAsset(asset);
|
|
341
|
+
await indexAsset(store, embedder, asset);
|
|
342
|
+
return Response.json({
|
|
343
|
+
output: `Created brief ${asset.id.slice(0, 8)}\nTitle: ${asset.title}\nHits: ${asset.hits}\nPath: ${path}`,
|
|
344
|
+
assetId: asset.id,
|
|
345
|
+
path,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (request.method === "POST" && url.pathname === "/api/clean-dirty-briefs") {
|
|
350
|
+
const rows = listDirtyBriefAssets();
|
|
351
|
+
if (rows.length === 0) {
|
|
352
|
+
return Response.json({ output: "No dirty briefs found.", count: 0, archived: 0, deleted: 0 });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let archived = 0;
|
|
356
|
+
let deleted = 0;
|
|
357
|
+
for (const row of rows) {
|
|
358
|
+
archiveDirtyBriefAsset(row);
|
|
359
|
+
archived += 1;
|
|
360
|
+
deleted += await getComponents().store.bulkDelete([row.scope]);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return Response.json({
|
|
364
|
+
output: `Dirty briefs: ${rows.length}\nArchived: ${archived}\nIndex rows deleted: ${deleted}`,
|
|
365
|
+
count: rows.length,
|
|
366
|
+
archived,
|
|
367
|
+
deleted,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (request.method === "POST" && url.pathname === "/api/export") {
|
|
372
|
+
const body = await readJson(request);
|
|
373
|
+
const query = requireString(body.query, "query");
|
|
374
|
+
const { retriever, profile } = getComponents(readOptionalString(body.profile) || "writing");
|
|
375
|
+
const results = await retriever.retrieve(buildRetrievalContext({
|
|
376
|
+
query,
|
|
377
|
+
limit: readLimit(body.limit, 8, 1, 20),
|
|
378
|
+
scope: readOptionalString(body.scope),
|
|
379
|
+
sessionId: readOptionalString(body.sessionId),
|
|
380
|
+
allScopes: body.allScopes === true,
|
|
381
|
+
}, {
|
|
382
|
+
operation: "ui:export",
|
|
383
|
+
}));
|
|
384
|
+
const summary = distillResults(results, { query, profile: profile.name });
|
|
385
|
+
const artifact = writeExportArtifact({
|
|
386
|
+
query,
|
|
387
|
+
profile: profile.name,
|
|
388
|
+
results,
|
|
389
|
+
summary,
|
|
390
|
+
format: body.format === "json" ? "json" : "md",
|
|
391
|
+
});
|
|
392
|
+
return Response.json({
|
|
393
|
+
output: `Exported ${artifact.id.slice(0, 8)}\nFormat: ${artifact.format}\nPath: ${artifact.outputPath}`,
|
|
394
|
+
artifactId: artifact.id,
|
|
395
|
+
format: artifact.format,
|
|
396
|
+
path: artifact.outputPath,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (request.method === "POST" && url.pathname === "/api/open-path") {
|
|
401
|
+
const body = await readJson(request);
|
|
402
|
+
const targetPath = requireString(body.path, "path");
|
|
403
|
+
if (!existsSync(targetPath) || !isAllowedArtifactPath(targetPath)) {
|
|
404
|
+
return textResponse("Path is not allowed.", { status: 400 });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const proc = Bun.spawn(["open", targetPath], {
|
|
408
|
+
stdout: "ignore",
|
|
409
|
+
stderr: "pipe",
|
|
410
|
+
});
|
|
411
|
+
const exitCode = await proc.exited;
|
|
412
|
+
if (exitCode !== 0) {
|
|
413
|
+
const errText = await new Response(proc.stderr).text();
|
|
414
|
+
return textResponse(`Failed to open path: ${errText}`, { status: 500 });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return Response.json({
|
|
418
|
+
output: `Opened ${targetPath}`,
|
|
419
|
+
path: targetPath,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return new Response("Not Found", { status: 404 });
|
|
424
|
+
} catch (error) {
|
|
425
|
+
return errorResponse(error);
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
console.log(`RecallNest UI running at http://localhost:${server.port}`);
|