dsh-tdai-memory 0.1.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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/THIRD-PARTY-NOTICES.md +20 -0
  4. package/index.js +328 -0
  5. package/package.json +53 -0
  6. package/recall-inject.js +80 -0
  7. package/vendor/tdai/adapters/index.js +15 -0
  8. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -0
  9. package/vendor/tdai/adapters/openclaw/index.js +5 -0
  10. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -0
  11. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -0
  12. package/vendor/tdai/adapters/standalone/index.js +5 -0
  13. package/vendor/tdai/adapters/standalone/llm-runner.js +246 -0
  14. package/vendor/tdai/config.js +363 -0
  15. package/vendor/tdai/core/conversation/l0-recorder.js +420 -0
  16. package/vendor/tdai/core/hooks/auto-capture.js +236 -0
  17. package/vendor/tdai/core/hooks/auto-recall.js +653 -0
  18. package/vendor/tdai/core/index.js +8 -0
  19. package/vendor/tdai/core/persona/persona-generator.js +184 -0
  20. package/vendor/tdai/core/persona/persona-trigger.js +94 -0
  21. package/vendor/tdai/core/profile/profile-sync.js +200 -0
  22. package/vendor/tdai/core/prompts/l1-dedup.js +136 -0
  23. package/vendor/tdai/core/prompts/l1-extraction.js +132 -0
  24. package/vendor/tdai/core/prompts/persona-generation.js +160 -0
  25. package/vendor/tdai/core/prompts/scene-extraction.js +269 -0
  26. package/vendor/tdai/core/record/l1-dedup.js +318 -0
  27. package/vendor/tdai/core/record/l1-extractor.js +379 -0
  28. package/vendor/tdai/core/record/l1-reader.js +178 -0
  29. package/vendor/tdai/core/record/l1-writer.js +141 -0
  30. package/vendor/tdai/core/report/reporter.js +86 -0
  31. package/vendor/tdai/core/scene/filename-normalizer.js +161 -0
  32. package/vendor/tdai/core/scene/scene-extractor.js +410 -0
  33. package/vendor/tdai/core/scene/scene-format.js +53 -0
  34. package/vendor/tdai/core/scene/scene-index.js +82 -0
  35. package/vendor/tdai/core/scene/scene-navigation.js +60 -0
  36. package/vendor/tdai/core/seed/input.js +404 -0
  37. package/vendor/tdai/core/seed/seed-runtime.js +314 -0
  38. package/vendor/tdai/core/seed/types.js +9 -0
  39. package/vendor/tdai/core/store/bm25-client.js +117 -0
  40. package/vendor/tdai/core/store/bm25-local.js +68 -0
  41. package/vendor/tdai/core/store/embedding.js +632 -0
  42. package/vendor/tdai/core/store/factory.js +95 -0
  43. package/vendor/tdai/core/store/search-utils.js +54 -0
  44. package/vendor/tdai/core/store/sqlite.js +1822 -0
  45. package/vendor/tdai/core/store/tcvdb-client.js +221 -0
  46. package/vendor/tdai/core/store/tcvdb.js +1047 -0
  47. package/vendor/tdai/core/store/types.js +17 -0
  48. package/vendor/tdai/core/tdai-core.js +446 -0
  49. package/vendor/tdai/core/tools/conversation-search.js +199 -0
  50. package/vendor/tdai/core/tools/memory-search.js +207 -0
  51. package/vendor/tdai/core/types.js +12 -0
  52. package/vendor/tdai/utils/backup.js +189 -0
  53. package/vendor/tdai/utils/checkpoint.js +356 -0
  54. package/vendor/tdai/utils/clean-context-runner.js +472 -0
  55. package/vendor/tdai/utils/ensure-hook-policy.js +213 -0
  56. package/vendor/tdai/utils/env.js +13 -0
  57. package/vendor/tdai/utils/managed-timer.js +127 -0
  58. package/vendor/tdai/utils/manifest.js +91 -0
  59. package/vendor/tdai/utils/memory-cleaner.js +300 -0
  60. package/vendor/tdai/utils/no-think-fetch.js +109 -0
  61. package/vendor/tdai/utils/openclaw-state-dir.js +26 -0
  62. package/vendor/tdai/utils/pipeline-factory.js +517 -0
  63. package/vendor/tdai/utils/pipeline-manager.js +892 -0
  64. package/vendor/tdai/utils/sanitize.js +360 -0
  65. package/vendor/tdai/utils/serial-queue.js +103 -0
  66. package/vendor/tdai/utils/session-filter.js +88 -0
  67. package/vendor/tdai/utils/text-utils.js +29 -0
  68. package/vendor/tdai/utils/time.js +252 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * memory_search tool: Agent-callable tool for searching L1 memory records.
3
+ *
4
+ * Supports three search strategies with automatic degradation:
5
+ * 1. **hybrid** (default) — FTS5 keyword + vector embedding in parallel,
6
+ * merged via Reciprocal Rank Fusion (RRF).
7
+ * 2. **embedding** — pure vector similarity (when FTS5 is unavailable).
8
+ * 3. **fts** — pure FTS5 keyword search (when embedding is unavailable).
9
+ *
10
+ * The tool is registered via `api.registerTool()` in index.ts.
11
+ */
12
+ import { buildFtsQuery } from "../store/sqlite.js";
13
+ const TAG = "[memory-tdai][tdai_memory_search]";
14
+ // ============================
15
+ // RRF (Reciprocal Rank Fusion)
16
+ // ============================
17
+ /** Standard RRF constant from the original RRF paper. */
18
+ const RRF_K = 60;
19
+ /**
20
+ * Merge multiple ranked lists of `MemorySearchResultItem` via Reciprocal Rank
21
+ * Fusion. Items appearing in multiple lists get their RRF scores summed.
22
+ *
23
+ * Returns items sorted by descending RRF score. The `score` field of each
24
+ * returned item is replaced by the RRF score for consistent ranking semantics.
25
+ */
26
+ function rrfMergeL1(...lists) {
27
+ const map = new Map();
28
+ for (const list of lists) {
29
+ for (let rank = 0; rank < list.length; rank++) {
30
+ const item = list[rank];
31
+ const score = 1 / (RRF_K + rank + 1);
32
+ const existing = map.get(item.id);
33
+ if (existing) {
34
+ existing.rrfScore += score;
35
+ }
36
+ else {
37
+ map.set(item.id, { item, rrfScore: score });
38
+ }
39
+ }
40
+ }
41
+ return [...map.values()]
42
+ .sort((a, b) => b.rrfScore - a.rrfScore)
43
+ .map(({ item, rrfScore }) => ({ ...item, score: rrfScore }));
44
+ }
45
+ // ============================
46
+ // Search implementation
47
+ // ============================
48
+ export async function executeMemorySearch(params) {
49
+ const { query, limit, type: typeFilter, scene: sceneFilter, vectorStore, embeddingService, logger, } = params;
50
+ logger?.debug?.(`${TAG} CALLED: query="${query.slice(0, 100)}", limit=${limit}, ` +
51
+ `typeFilter=${typeFilter ?? "(none)"}, sceneFilter=${sceneFilter ?? "(none)"}, ` +
52
+ `vectorStore=${vectorStore ? "available" : "UNAVAILABLE"}, ` +
53
+ `embeddingService=${embeddingService ? "available" : "UNAVAILABLE"}`);
54
+ if (!query || query.trim().length === 0) {
55
+ logger?.debug?.(`${TAG} Empty query, returning empty`);
56
+ return { results: [], total: 0, strategy: "none" };
57
+ }
58
+ if (!vectorStore) {
59
+ logger?.warn?.(`${TAG} VectorStore not available`);
60
+ return { results: [], total: 0, strategy: "none" };
61
+ }
62
+ // ── Determine available capabilities ──
63
+ const hasEmbedding = !!embeddingService;
64
+ const hasFts = vectorStore.isFtsAvailable();
65
+ if (!hasEmbedding && !hasFts) {
66
+ logger?.warn?.(`${TAG} Neither EmbeddingService nor FTS5 available — cannot search`);
67
+ return {
68
+ results: [],
69
+ total: 0,
70
+ strategy: "none",
71
+ message: "Embedding service is not configured and FTS is not available. " +
72
+ "Memory search requires an embedding provider or FTS5 support. " +
73
+ "Please configure an embedding provider in the embedding.provider setting (e.g. openai_compatible).",
74
+ };
75
+ }
76
+ // ── Over-retrieve for later filtering and RRF merging ──
77
+ const candidateK = limit * 3;
78
+ // ── Run available search strategies in parallel ──
79
+ const [ftsItems, vecItems] = await Promise.all([
80
+ // FTS5 keyword search
81
+ (async () => {
82
+ if (!hasFts)
83
+ return [];
84
+ try {
85
+ const ftsQuery = buildFtsQuery(query);
86
+ if (!ftsQuery) {
87
+ logger?.debug?.(`${TAG} [hybrid-fts] No usable FTS tokens from query`);
88
+ return [];
89
+ }
90
+ logger?.debug?.(`${TAG} [hybrid-fts] FTS5 query: "${ftsQuery}"`);
91
+ const ftsResults = await vectorStore.searchL1Fts(ftsQuery, candidateK);
92
+ logger?.debug?.(`${TAG} [hybrid-fts] FTS5 returned ${ftsResults.length} candidates`);
93
+ return ftsResults.map((r) => ({
94
+ id: r.record_id,
95
+ content: r.content,
96
+ type: r.type,
97
+ priority: r.priority,
98
+ scene_name: r.scene_name,
99
+ score: r.score,
100
+ created_at: r.timestamp_start,
101
+ updated_at: r.timestamp_end,
102
+ }));
103
+ }
104
+ catch (err) {
105
+ logger?.warn?.(`${TAG} [hybrid-fts] FTS5 search failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
106
+ return [];
107
+ }
108
+ })(),
109
+ // Vector embedding search
110
+ (async () => {
111
+ if (!hasEmbedding)
112
+ return [];
113
+ try {
114
+ logger?.debug?.(`${TAG} [hybrid-vec] Generating query embedding...`);
115
+ const queryEmbedding = await embeddingService.embed(query);
116
+ logger?.debug?.(`${TAG} [hybrid-vec] Embedding OK, dims=${queryEmbedding.length}, searching top-${candidateK}...`);
117
+ const vecResults = await vectorStore.searchL1Vector(queryEmbedding, candidateK, query);
118
+ logger?.debug?.(`${TAG} [hybrid-vec] Vector search returned ${vecResults.length} candidates`);
119
+ return vecResults.map((r) => ({
120
+ id: r.record_id,
121
+ content: r.content,
122
+ type: r.type,
123
+ priority: r.priority,
124
+ scene_name: r.scene_name,
125
+ score: r.score,
126
+ created_at: r.timestamp_start,
127
+ updated_at: r.timestamp_end,
128
+ }));
129
+ }
130
+ catch (err) {
131
+ logger?.warn?.(`${TAG} [hybrid-vec] Embedding search failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
132
+ return [];
133
+ }
134
+ })(),
135
+ ]);
136
+ // ── Determine effective strategy ──
137
+ const ftsOk = ftsItems.length > 0;
138
+ const vecOk = vecItems.length > 0;
139
+ let strategy;
140
+ if (ftsOk && vecOk) {
141
+ strategy = "hybrid";
142
+ }
143
+ else if (vecOk) {
144
+ strategy = "embedding";
145
+ }
146
+ else if (ftsOk) {
147
+ strategy = "fts";
148
+ }
149
+ else {
150
+ logger?.debug?.(`${TAG} Both search paths returned 0 results`);
151
+ return { results: [], total: 0, strategy: hasEmbedding ? "embedding" : "fts" };
152
+ }
153
+ // ── Merge results ──
154
+ let results;
155
+ if (strategy === "hybrid") {
156
+ results = rrfMergeL1(ftsItems, vecItems);
157
+ logger?.debug?.(`${TAG} [hybrid] RRF merged: fts=${ftsItems.length}, vec=${vecItems.length} → ${results.length} unique`);
158
+ }
159
+ else {
160
+ // Single-source: use whichever list has results (already sorted by score)
161
+ results = ftsOk ? ftsItems : vecItems;
162
+ }
163
+ // ── Apply secondary filters (type, scene) ──
164
+ const preFilterCount = results.length;
165
+ if (typeFilter) {
166
+ results = results.filter((r) => r.type === typeFilter);
167
+ logger?.debug?.(`${TAG} After type filter "${typeFilter}": ${results.length}/${preFilterCount}`);
168
+ }
169
+ if (sceneFilter) {
170
+ const normalizedScene = sceneFilter.toLowerCase();
171
+ results = results.filter((r) => r.scene_name.toLowerCase().includes(normalizedScene));
172
+ logger?.debug?.(`${TAG} After scene filter "${sceneFilter}": ${results.length}/${preFilterCount}`);
173
+ }
174
+ // ── Trim to requested limit ──
175
+ const trimmed = results.slice(0, limit);
176
+ logger?.debug?.(`${TAG} RESULT (strategy=${strategy}): returning ${trimmed.length} memories ` +
177
+ `(scores: [${trimmed.map((r) => r.score.toFixed(3)).join(", ")}])`);
178
+ return {
179
+ results: trimmed,
180
+ total: trimmed.length,
181
+ strategy,
182
+ };
183
+ }
184
+ // ============================
185
+ // Tool response formatter
186
+ // ============================
187
+ export function formatSearchResponse(result) {
188
+ if (result.message) {
189
+ return result.message;
190
+ }
191
+ if (result.results.length === 0) {
192
+ return "No matching memories found.";
193
+ }
194
+ const lines = [
195
+ `Found ${result.total} matching memories:`,
196
+ "",
197
+ ];
198
+ for (const item of result.results) {
199
+ const scoreStr = typeof item.score === "number" ? ` (score: ${item.score.toFixed(3)})` : "";
200
+ const sceneStr = item.scene_name ? ` [scene: ${item.scene_name}]` : "";
201
+ const priorityStr = item.priority >= 0 ? ` (priority: ${item.priority})` : " (global instruction)";
202
+ lines.push(`- **[${item.type}]**${priorityStr}${sceneStr}${scoreStr}`);
203
+ lines.push(` ${item.content}`);
204
+ lines.push("");
205
+ }
206
+ return lines.join("\n");
207
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * TDAI Core — Host-neutral type definitions and abstract interfaces.
3
+ *
4
+ * These types define the boundary between TDAI Core (memory algorithms)
5
+ * and the host environment (OpenClaw, Hermes, standalone Gateway, etc.).
6
+ *
7
+ * Design principles:
8
+ * 1. TDAI Core depends ONLY on these interfaces — never on a specific host.
9
+ * 2. Each host provides its own implementation of HostAdapter + LLMRunnerFactory.
10
+ * 3. RuntimeContext is the single source of truth for session/user identity.
11
+ */
12
+ export {};
@@ -0,0 +1,189 @@
1
+ /**
2
+ * BackupManager: generic file/directory backup utility.
3
+ *
4
+ * Provides two backup modes:
5
+ * - `backupFile(src, category, tag, maxKeep)` — copy a single file
6
+ * - `backupDirectory(src, category, tag, maxKeep)` — copy an entire directory
7
+ *
8
+ * All backups land under `<backupRoot>/<category>/` with timestamped names.
9
+ * After each backup, entries beyond `maxKeep` are automatically pruned
10
+ * (oldest first, by lexicographic order on the timestamp-embedded name).
11
+ */
12
+ import fs from "node:fs/promises";
13
+ import path from "node:path";
14
+ export class BackupManager {
15
+ backupRoot;
16
+ /**
17
+ * @param backupRoot - Absolute path to the root backup directory
18
+ * (e.g. `<dataDir>/.backup`).
19
+ */
20
+ constructor(backupRoot) {
21
+ this.backupRoot = backupRoot;
22
+ }
23
+ /**
24
+ * Backup a single file.
25
+ *
26
+ * Destination: `<backupRoot>/<category>/<category>_<timestamp>_<tag>.<ext>`
27
+ *
28
+ * @param srcFile - Absolute path to the source file
29
+ * @param category - Logical grouping (e.g. "persona")
30
+ * @param tag - Additional identifier (e.g. "offset42")
31
+ * @param maxKeep - Max backup files to retain in this category (0 = unlimited)
32
+ */
33
+ async backupFile(srcFile, category, tag, maxKeep) {
34
+ try {
35
+ await fs.access(srcFile);
36
+ }
37
+ catch {
38
+ return; // Source file doesn't exist, nothing to backup
39
+ }
40
+ const destDir = path.join(this.backupRoot, category);
41
+ await fs.mkdir(destDir, { recursive: true });
42
+ const ext = path.extname(srcFile); // e.g. ".md"
43
+ const timestamp = formatTimestamp(new Date());
44
+ const destName = `${category}_${timestamp}_${tag}${ext}`;
45
+ await fs.copyFile(srcFile, path.join(destDir, destName));
46
+ if (maxKeep > 0) {
47
+ await pruneOldEntries(destDir, maxKeep, "file");
48
+ }
49
+ }
50
+ /**
51
+ * Backup an entire directory (shallow copy of all files).
52
+ *
53
+ * Destination: `<backupRoot>/<category>/<category>_<timestamp>_<tag>/`
54
+ *
55
+ * @param srcDir - Absolute path to the source directory
56
+ * @param category - Logical grouping (e.g. "scene_blocks")
57
+ * @param tag - Additional identifier (e.g. "offset42")
58
+ * @param maxKeep - Max backup directories to retain in this category (0 = unlimited)
59
+ */
60
+ async backupDirectory(srcDir, category, tag, maxKeep) {
61
+ let entries;
62
+ try {
63
+ entries = await fs.readdir(srcDir, { withFileTypes: true });
64
+ }
65
+ catch {
66
+ return; // Source directory doesn't exist
67
+ }
68
+ // Only backup regular files (skip subdirectories to avoid EISDIR errors)
69
+ const files = entries.filter((e) => e.isFile()).map((e) => e.name);
70
+ if (files.length === 0)
71
+ return;
72
+ const parentDir = path.join(this.backupRoot, category);
73
+ const timestamp = formatTimestamp(new Date());
74
+ const destDir = path.join(parentDir, `${category}_${timestamp}_${tag}`);
75
+ await fs.mkdir(destDir, { recursive: true });
76
+ for (const file of files) {
77
+ await fs.copyFile(path.join(srcDir, file), path.join(destDir, file));
78
+ }
79
+ if (maxKeep > 0) {
80
+ await pruneOldEntries(parentDir, maxKeep, "directory");
81
+ }
82
+ }
83
+ /**
84
+ * Find the latest backup directory for a category.
85
+ *
86
+ * Backup directory names are `<category>_<timestamp>_<tag>` where the
87
+ * timestamp is `YYYYMMDD_HHmmss` (lexicographic order = chronological order),
88
+ * so the lexicographically largest entry is the most recent one.
89
+ *
90
+ * @param category - Logical grouping (e.g. "scene_blocks")
91
+ * @returns Absolute path to the latest backup directory, or undefined if none.
92
+ */
93
+ async findLatestBackup(category) {
94
+ const parentDir = path.join(this.backupRoot, category);
95
+ let entries;
96
+ try {
97
+ entries = await fs.readdir(parentDir, { withFileTypes: true });
98
+ }
99
+ catch {
100
+ return undefined; // No backup directory yet
101
+ }
102
+ const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
103
+ if (dirs.length === 0)
104
+ return undefined;
105
+ dirs.sort(); // ascending — oldest first; last = newest
106
+ return path.join(parentDir, dirs[dirs.length - 1]);
107
+ }
108
+ /**
109
+ * Restore the latest backup of `category` into `destDir`.
110
+ *
111
+ * Strategy:
112
+ * 1. Find the latest backup directory; if none exists, do nothing
113
+ * (fail-soft: never clobber the destination when there is no
114
+ * ground truth to restore from).
115
+ * 2. Wipe `destDir` and recreate it.
116
+ * 3. Copy every regular file from the backup directory into `destDir`.
117
+ *
118
+ * @param category - Logical grouping (e.g. "scene_blocks")
119
+ * @param destDir - Absolute path to the directory to restore into
120
+ * @returns `{ restored: true, from }` when a backup was applied,
121
+ * `{ restored: false }` when no backup was found.
122
+ * @throws Lets fs errors during wipe/copy propagate so callers can decide
123
+ * whether to fail-soft (log) or fail-hard.
124
+ */
125
+ async restoreLatestDirectory(category, destDir) {
126
+ const from = await this.findLatestBackup(category);
127
+ if (!from)
128
+ return { restored: false };
129
+ // Wipe the destination first so any partial LLM writes are removed,
130
+ // then recreate the directory and copy regular files back.
131
+ await fs.rm(destDir, { recursive: true, force: true });
132
+ await fs.mkdir(destDir, { recursive: true });
133
+ const entries = await fs.readdir(from, { withFileTypes: true });
134
+ for (const entry of entries) {
135
+ if (!entry.isFile())
136
+ continue;
137
+ await fs.copyFile(path.join(from, entry.name), path.join(destDir, entry.name));
138
+ }
139
+ return { restored: true, from };
140
+ }
141
+ }
142
+ // ============================
143
+ // Helpers
144
+ // ============================
145
+ function formatTimestamp(d) {
146
+ const pad = (n) => String(n).padStart(2, "0");
147
+ return [
148
+ d.getFullYear(),
149
+ pad(d.getMonth() + 1),
150
+ pad(d.getDate()),
151
+ "_",
152
+ pad(d.getHours()),
153
+ pad(d.getMinutes()),
154
+ pad(d.getSeconds()),
155
+ ].join("");
156
+ }
157
+ /**
158
+ * Keep only the newest `maxKeep` entries in a directory.
159
+ * Entries are sorted by name ascending (oldest first) since backup names
160
+ * embed timestamps, so lexicographic order = chronological order.
161
+ *
162
+ * @param dir - Directory containing the backup entries
163
+ * @param maxKeep - Number of entries to retain
164
+ * @param kind - "file" to unlink, "directory" to rm -rf
165
+ */
166
+ async function pruneOldEntries(dir, maxKeep, kind) {
167
+ let entries;
168
+ try {
169
+ entries = await fs.readdir(dir);
170
+ }
171
+ catch {
172
+ return;
173
+ }
174
+ entries.sort(); // ascending — oldest first
175
+ const toRemove = entries.slice(0, Math.max(0, entries.length - maxKeep));
176
+ for (const name of toRemove) {
177
+ try {
178
+ if (kind === "file") {
179
+ await fs.unlink(path.join(dir, name));
180
+ }
181
+ else {
182
+ await fs.rm(path.join(dir, name), { recursive: true, force: true });
183
+ }
184
+ }
185
+ catch {
186
+ // best-effort
187
+ }
188
+ }
189
+ }