@drdeeks/character-kit 1.0.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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -0
  3. package/deploy/ack_monitor.py +140 -0
  4. package/deploy/ack_watchdog.py +110 -0
  5. package/deploy/agent-character-monitor.service +21 -0
  6. package/deploy/agent-character-watchdog.service +21 -0
  7. package/deploy/agent-enforcer-proof.service +18 -0
  8. package/deploy/agent-enforcer.service +53 -0
  9. package/deploy/deploy-ack-services.sh +47 -0
  10. package/deploy/deploy-agent-enforcer.sh +133 -0
  11. package/deploy/proof-self-respawn.sh +59 -0
  12. package/node/bin/ack.js +383 -0
  13. package/node/bin/aik.js +520 -0
  14. package/node/bin/install.js +409 -0
  15. package/node/enforcer/agent_enforcer_daemon.js +690 -0
  16. package/node/examples/.agent/constitution.yaml +38 -0
  17. package/node/examples/.agent/enforcer.yaml +33 -0
  18. package/node/examples/.agent/habits/affirm_character_each_action.yaml +9 -0
  19. package/node/examples/.agent/habits/audit_not_silent.yaml +9 -0
  20. package/node/examples/.agent/habits/binding_map_one_core.yaml +9 -0
  21. package/node/examples/.agent/habits/character_hash_visible.yaml +9 -0
  22. package/node/examples/.agent/habits/check_duplication_before_debug.yaml +9 -0
  23. package/node/examples/.agent/habits/complete-thoroughly.yaml +16 -0
  24. package/node/examples/.agent/habits/consistent-info-across-files.yaml +18 -0
  25. package/node/examples/.agent/habits/document-for-next-agent.yaml +15 -0
  26. package/node/examples/.agent/habits/documented_rollback.yaml +9 -0
  27. package/node/examples/.agent/habits/drift_signal_detection.yaml +9 -0
  28. package/node/examples/.agent/habits/due-diligence.yaml +16 -0
  29. package/node/examples/.agent/habits/enterprise-grade-modular.yaml +16 -0
  30. package/node/examples/.agent/habits/fail_closed_tamper_evident.yaml +9 -0
  31. package/node/examples/.agent/habits/forever_one_idea.yaml +9 -0
  32. package/node/examples/.agent/habits/graceful_degradation.yaml +9 -0
  33. package/node/examples/.agent/habits/idempotent_operations.yaml +9 -0
  34. package/node/examples/.agent/habits/interactive-no-stalls.yaml +16 -0
  35. package/node/examples/.agent/habits/layered_not_rewritten.yaml +9 -0
  36. package/node/examples/.agent/habits/lossless_consolidation.yaml +9 -0
  37. package/node/examples/.agent/habits/no-credential-leak.yaml +41 -0
  38. package/node/examples/.agent/habits/no-deception.yaml +14 -0
  39. package/node/examples/.agent/habits/no_hold_narration.yaml +9 -0
  40. package/node/examples/.agent/habits/one_concern_per_file.yaml +9 -0
  41. package/node/examples/.agent/habits/optimized-robust.yaml +16 -0
  42. package/node/examples/.agent/habits/registered_plugin_not_string.yaml +9 -0
  43. package/node/examples/.agent/habits/rename_as_layer_op.yaml +9 -0
  44. package/node/examples/.agent/habits/resolve-root-cause.yaml +15 -0
  45. package/node/examples/.agent/habits/rigorous-commits-no-push.yaml +18 -0
  46. package/node/examples/.agent/habits/safe-deletion-via-trash.yaml +20 -0
  47. package/node/examples/.agent/habits/safe_file_permissions.yaml +9 -0
  48. package/node/examples/.agent/habits/self-healing-portability.yaml +14 -0
  49. package/node/examples/.agent/habits/shippable-pride.yaml +15 -0
  50. package/node/examples/.agent/habits/single_source_of_truth.yaml +9 -0
  51. package/node/examples/.agent/habits/test_of_forever.yaml +9 -0
  52. package/node/examples/.agent/habits/timeout_and_retry.yaml +9 -0
  53. package/node/examples/.agent/habits/track_defects_openly.yaml +9 -0
  54. package/node/examples/.agent/habits/validate-against-real-source.yaml +14 -0
  55. package/node/examples/.agent/habits/verify-against-docs.yaml +15 -0
  56. package/node/examples/.agent/habits/verify-functionality-not-syntax.yaml +15 -0
  57. package/node/examples/.agent/habits/versioning-discipline.yaml +18 -0
  58. package/node/package.json +53 -0
  59. package/node/src/enforcer/client.js +149 -0
  60. package/node/src/hooks/character.js +286 -0
  61. package/node/src/index.js +24 -0
  62. package/node/src/knowledge/indexer.js +486 -0
  63. package/node/src/knowledge/semantic.js +154 -0
  64. package/node/src/memory/index.js +355 -0
  65. package/node/tests/character.test.js +80 -0
  66. package/node/tests/habit-create.test.js +67 -0
  67. package/node/tests/habits-default.test.js +68 -0
  68. package/node/tests/install-chain.test.js +19 -0
  69. package/node/tests/install.test.js +104 -0
  70. package/package.json +65 -0
  71. package/python/agent_character_kit/__init__.py +388 -0
  72. package/python/agent_character_kit/__main__.py +333 -0
  73. package/python/agent_character_kit/_yaml.py +33 -0
  74. package/python/agent_character_kit/enforcer.py +379 -0
  75. package/python/agent_character_kit/memory.py +257 -0
  76. package/python/agent_character_kit/semantic.py +126 -0
  77. package/python/example_workspace/.agent/habits/affirm_character_each_action.yaml +9 -0
  78. package/python/example_workspace/.agent/habits/audit_not_silent.yaml +9 -0
  79. package/python/example_workspace/.agent/habits/binding_map_one_core.yaml +9 -0
  80. package/python/example_workspace/.agent/habits/character_hash_visible.yaml +9 -0
  81. package/python/example_workspace/.agent/habits/check_duplication_before_debug.yaml +9 -0
  82. package/python/example_workspace/.agent/habits/complete-thoroughly.yaml +16 -0
  83. package/python/example_workspace/.agent/habits/consistent-info-across-files.yaml +18 -0
  84. package/python/example_workspace/.agent/habits/document-for-next-agent.yaml +15 -0
  85. package/python/example_workspace/.agent/habits/documented_rollback.yaml +9 -0
  86. package/python/example_workspace/.agent/habits/drift_signal_detection.yaml +9 -0
  87. package/python/example_workspace/.agent/habits/due-diligence.yaml +16 -0
  88. package/python/example_workspace/.agent/habits/enterprise-grade-modular.yaml +16 -0
  89. package/python/example_workspace/.agent/habits/fail_closed_tamper_evident.yaml +9 -0
  90. package/python/example_workspace/.agent/habits/forever_one_idea.yaml +9 -0
  91. package/python/example_workspace/.agent/habits/graceful_degradation.yaml +9 -0
  92. package/python/example_workspace/.agent/habits/idempotent_operations.yaml +9 -0
  93. package/python/example_workspace/.agent/habits/interactive-no-stalls.yaml +16 -0
  94. package/python/example_workspace/.agent/habits/layered_not_rewritten.yaml +9 -0
  95. package/python/example_workspace/.agent/habits/lossless_consolidation.yaml +9 -0
  96. package/python/example_workspace/.agent/habits/no-credential-leak.yaml +41 -0
  97. package/python/example_workspace/.agent/habits/no-deception.yaml +14 -0
  98. package/python/example_workspace/.agent/habits/no_hold_narration.yaml +9 -0
  99. package/python/example_workspace/.agent/habits/one_concern_per_file.yaml +9 -0
  100. package/python/example_workspace/.agent/habits/optimized-robust.yaml +16 -0
  101. package/python/example_workspace/.agent/habits/registered_plugin_not_string.yaml +9 -0
  102. package/python/example_workspace/.agent/habits/rename_as_layer_op.yaml +9 -0
  103. package/python/example_workspace/.agent/habits/resolve-root-cause.yaml +15 -0
  104. package/python/example_workspace/.agent/habits/rigorous-commits-no-push.yaml +18 -0
  105. package/python/example_workspace/.agent/habits/safe-deletion-via-trash.yaml +20 -0
  106. package/python/example_workspace/.agent/habits/safe_file_permissions.yaml +9 -0
  107. package/python/example_workspace/.agent/habits/self-healing-portability.yaml +14 -0
  108. package/python/example_workspace/.agent/habits/shippable-pride.yaml +15 -0
  109. package/python/example_workspace/.agent/habits/single_source_of_truth.yaml +9 -0
  110. package/python/example_workspace/.agent/habits/test_of_forever.yaml +9 -0
  111. package/python/example_workspace/.agent/habits/timeout_and_retry.yaml +9 -0
  112. package/python/example_workspace/.agent/habits/track_defects_openly.yaml +9 -0
  113. package/python/example_workspace/.agent/habits/validate-against-real-source.yaml +14 -0
  114. package/python/example_workspace/.agent/habits/verify-against-docs.yaml +15 -0
  115. package/python/example_workspace/.agent/habits/verify-functionality-not-syntax.yaml +15 -0
  116. package/python/example_workspace/.agent/habits/versioning-discipline.yaml +18 -0
  117. package/python/hermes_plugin/README.md +63 -0
  118. package/python/hermes_plugin/__init__.py +367 -0
  119. package/python/hermes_plugin/config.yaml +20 -0
  120. package/python/hermes_plugin/plugin.yaml +7 -0
  121. package/python/hermes_plugin/test_plugin.py +166 -0
  122. package/python/pyproject.toml +19 -0
  123. package/python/tests/python_parity_test.py +84 -0
@@ -0,0 +1,486 @@
1
+ import fs from "fs/promises";
2
+ import fssync from "fs";
3
+ import path from "path";
4
+ import matter from "gray-matter";
5
+
6
+ // ─── Agent-internal exclusion ────────────────────────────────────────────────
7
+ //
8
+ // The indexer indexes ONLY the user-supplied informational corpus (docs, links,
9
+ // examples the user gives the agent). The agent's OWN files — its identity
10
+ // (SOUL.md, constitution), its habits, its memory, its knowledge graph — are
11
+ // never indexed as corpus. They live in dedicated dirs and are excluded here.
12
+
13
+ const AGENT_INTERNAL_FILES = new Set([
14
+ "soul.md", "identity.md", "constitution.md",
15
+ "agents.md", "user.md", "tools.md", "memory.md", "heartbeat.md",
16
+ "constitution.yaml", "constitution.yml", "enforcer.yaml",
17
+ "genesis.md", "readme.md", "changelog.md",
18
+ ]);
19
+
20
+ const AGENT_INTERNAL_DIRS = new Set([
21
+ ".agent", "habits", "memory", "knowledge", ".secrets",
22
+ "node_modules", ".git", ".agent-character-kit",
23
+ ]);
24
+
25
+ function isAgentInternal(name) {
26
+ return AGENT_INTERNAL_FILES.has(name.toLowerCase());
27
+ }
28
+
29
+ // Default location of the user-supplied corpus (self-resolving).
30
+ function defaultCorpus(workspace) {
31
+ return path.join(workspace || process.cwd(), "corpus");
32
+ }
33
+
34
+ // ─── Supported File Extensions ──────────────────────────────────────────────
35
+
36
+ const EXTENSIONS = {
37
+ // Documentation
38
+ markdown: [".md", ".mdx", ".mdown", ".markdown"],
39
+ text: [".txt", ".text", ".rst", ".adoc", ".asciidoc", ".org", ".tex", ".latex"],
40
+ wiki: [".wiki", ".mediawiki", ".dokuwiki", ".tiddlywiki"],
41
+
42
+ // Config / Data
43
+ yaml: [".yaml", ".yml"],
44
+ json: [".json", ".jsonl", ".json5", ".ndjson", ".geojson"],
45
+ toml: [".toml", ".ini", ".cfg", ".conf"],
46
+ xml: [".xml", ".xaml", ".svg", ".html", ".htm", ".xhtml"],
47
+ csv: [".csv", ".tsv", ".psv"],
48
+
49
+ // Code (indexable as documentation)
50
+ code: [".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx",
51
+ ".sh", ".bash", ".zsh", ".fish",
52
+ ".rb", ".go", ".rs", ".java", ".kt", ".swift",
53
+ ".c", ".cpp", ".h", ".hpp", ".cs",
54
+ ".sql", ".r", ".lua", ".perl", ".pl",
55
+ ".vim", ".el", ".lisp", ".clj"],
56
+
57
+ // Agent / AI specific
58
+ agent: [".agent", ".skill", ".hook", ".prompt", ".template"],
59
+ soul: ["SOUL.md", "IDENTITY.md", "AGENTS.md", "MEMORY.md", "USER.md", "TOOLS.md", "HEARTBEAT.md"],
60
+
61
+ // Research / Notes
62
+ research: [".bib", ".enw", ".ris", ".endnote"],
63
+ notebook: [".ipynb", ".jl"],
64
+ obsidian: [".canvas"],
65
+ };
66
+
67
+ // Flat list for indexing
68
+ const ALL_EXTENSIONS = Object.values(EXTENSIONS).flat();
69
+
70
+ // Extensions that are code (for category inference)
71
+ const CODE_EXTENSIONS = new Set(EXTENSIONS.code);
72
+
73
+ // Extensions that are config/data
74
+ const CONFIG_EXTENSIONS = new Set([
75
+ ...EXTENSIONS.yaml, ...EXTENSIONS.json, ...EXTENSIONS.toml,
76
+ ...EXTENSIONS.xml, ...EXTENSIONS.csv,
77
+ ]);
78
+
79
+ // ─── Category Rules ─────────────────────────────────────────────────────────
80
+
81
+ const CATEGORY_RULES = [
82
+ // By filename
83
+ { pattern: /SOUL|IDENTITY|CONSTITUTION/i, category: "identity" },
84
+ { pattern: /AGENTS|TOOLS|HEARTBEAT|USER\.md/i, category: "agent_config" },
85
+ { pattern: /MEMORY|JOURNAL|LOG/i, category: "memory" },
86
+ { pattern: /README|CHANGELOG|LICENSE|CONTRIBUTING/i, category: "documentation" },
87
+ { pattern: /TODO|TASKS|BACKLOG/i, category: "task_list" },
88
+ { pattern: /SKILL|TUTORIAL|GUIDE|HOWTO|LEARN/i, category: "skill" },
89
+
90
+ // By path pattern
91
+ { pattern: /daily|journal|log/i, category: "daily_note" },
92
+ { pattern: /transcript|session|conversation/i, category: "transcript" },
93
+ { pattern: /blog|post|article/i, category: "blog" },
94
+ { pattern: /experiment|spike|research/i, category: "experiment" },
95
+ { pattern: /knowledge|entity|person|company/i, category: "knowledge_graph" },
96
+ { pattern: /lesson|pattern|decision|learning/i, category: "long_term" },
97
+ { pattern: /spec|rfc|adr|decision/i, category: "spec" },
98
+ { pattern: /doc|docs|reference/i, category: "documentation" },
99
+ { pattern: /config|settings|env/i, category: "config" },
100
+ { pattern: /test|spec|__tests__/i, category: "test" },
101
+ { pattern: /skill|hook|prompt|template/i, category: "agent_skill" },
102
+
103
+ // By content (first 500 chars)
104
+ { pattern: /^---\n[\s\S]*?type:\s*person/i, category: "knowledge_graph" },
105
+ { pattern: /^---\n[\s\S]*?tags?:\s*\[/i, category: "tagged" },
106
+ ];
107
+
108
+ function inferCategory(filePath, content) {
109
+ for (const rule of CATEGORY_RULES) {
110
+ if (rule.pattern.test(filePath) || rule.pattern.test(content.slice(0, 500))) {
111
+ return rule.category;
112
+ }
113
+ }
114
+ return "document";
115
+ }
116
+
117
+ // ─── File Type Discovery ─────────────────────────────────────────────────────
118
+
119
+ function isIndexable(fileName, extensions) {
120
+ const lower = fileName.toLowerCase();
121
+
122
+ // Exact-name matches (SOUL.md, AGENTS.md, llms.txt, etc.)
123
+ const EXACT_NAMES = new Set([
124
+ "llms.txt", "llms-full.txt", "agents.md", "agent.md",
125
+ "soUl.md", "identity.md", "constitution.md", "user.md",
126
+ "tools.md", "memory.md", "heartbeat.md", "system.md",
127
+ ".agent", ".skill", ".hook", ".prompt", ".template",
128
+ ]);
129
+ if (EXACT_NAMES.has(lower)) return true;
130
+
131
+ // Never index the agent's own identity/memory files as corpus.
132
+ if (isAgentInternal(lower)) return false;
133
+
134
+ const ext = path.extname(lower);
135
+ return extensions.includes(ext);
136
+ }
137
+
138
+ // ─── Link / Reference Extraction ─────────────────────────────────────────────
139
+ //
140
+ // The agent should document any links to informational docs so they can also
141
+ // be indexed. We extract: markdown links, bare URLs, wiki-style [[links]],
142
+ // Obsidian ![[embeds]], and <doc:...> / @import style references.
143
+
144
+ const LINK_PATTERNS = [
145
+ { type: "markdown", re: /\[[^\]]*\]\(\s*([^)\s]+)\s*\)/g }, // [text](url)
146
+ { type: "url", re: /(?:^|[\s(])(https?:\/\/[^\s)\]]+)/gi }, // bare http(s)
147
+ { type: "wiki", re: /\[\[\s*([^\]|#]+)(?:[|#][^\]]*)?\s*\]\]/g },// [[Page]] / [[Page|alias]]
148
+ { type: "embed", re: /!\[\[\s*([^\]|#]+)(?:[|#][^\]]*)?\s*\]\]/g },// ![[Page]]
149
+ { type: "docref", re: /(?:doc|file|ref|see|import|include)\s*[:=]\s*["']?([^\s"'\)]+)/gi },
150
+ { type: "arxiv", re: /(?:arxiv\.org\/abs\/|arXiv:)(\d+\.\d+)/gi },// arxiv ids
151
+ ];
152
+
153
+ function extractLinks(content, basePath = null) {
154
+ const found = [];
155
+ const seen = new Set();
156
+ for (const { type, re } of LINK_PATTERNS) {
157
+ let m;
158
+ re.lastIndex = 0;
159
+ while ((m = re.exec(content)) !== null) {
160
+ let target = m[1].trim().replace(/[.,;:]$/, "");
161
+ if (!target || seen.has(target)) continue;
162
+ seen.add(target);
163
+
164
+ // Resolve relative file links against the document's directory
165
+ let resolved = null;
166
+ if (basePath && !/^https?:\/\//i.test(target) && !target.startsWith("@")) {
167
+ const abs = path.resolve(path.dirname(basePath), target);
168
+ if (fssync.existsSync(abs)) resolved = abs;
169
+ }
170
+
171
+ found.push({
172
+ type,
173
+ target,
174
+ resolvedPath: resolved,
175
+ external: /^https?:\/\//i.test(target) || type === "arxiv",
176
+ });
177
+ }
178
+ }
179
+ return found;
180
+ }
181
+
182
+ // Parse llms.txt (https://llmstxt.org) — a markdown file listing linked docs
183
+ function parseLlmsTxt(content, baseDir) {
184
+ const details = [];
185
+ const lines = content.split("\n");
186
+ for (const line of lines) {
187
+ const m = line.match(/^\s*[-*]\s*\[([^\]]+)\]\(([^)]+)\)\s*[:\-]?\s*(.*)$/);
188
+ if (m) {
189
+ const [, title, url, description] = m;
190
+ details.push({
191
+ title: title.trim(),
192
+ url: url.trim(),
193
+ description: description.trim(),
194
+ external: /^https?:\/\//i.test(url.trim()),
195
+ resolvedPath: baseDir && !/^https?:\/\//i.test(url.trim())
196
+ ? path.resolve(baseDir, url.trim()) : null,
197
+ });
198
+ }
199
+ }
200
+ return details;
201
+ }
202
+
203
+ function extractTags(content, filePath) {
204
+ const tags = new Set();
205
+ const basename = path.basename(filePath, path.extname(filePath));
206
+ tags.add(basename.toLowerCase().replace(/[_-]/g, " "));
207
+
208
+ const tagPatterns = [
209
+ /tags?:\s*\[([^\]]+)\]/i,
210
+ /#\w+/g,
211
+ ];
212
+
213
+ for (const pattern of tagPatterns) {
214
+ const matches = content.match(pattern);
215
+ if (matches) {
216
+ for (const match of matches) {
217
+ const cleaned = match.replace(/^tags?:\s*\[|]$/g, "").replace(/#/g, "").trim();
218
+ cleaned.split(/[,\s]+/).forEach((t) => {
219
+ if (t.length > 1) tags.add(t.toLowerCase());
220
+ });
221
+ }
222
+ }
223
+ }
224
+
225
+ return [...tags].slice(0, 20);
226
+ }
227
+
228
+ function chunkText(text, chunkSize = 3500, overlap = 350) {
229
+ if (text.length <= chunkSize) return [text];
230
+ const chunks = [];
231
+ let start = 0;
232
+ while (start < text.length) {
233
+ const end = Math.min(start + chunkSize, text.length);
234
+ chunks.push(text.slice(start, end));
235
+ start = end - overlap;
236
+ if (start + overlap >= text.length) break;
237
+ }
238
+ return chunks;
239
+ }
240
+
241
+ export class DocumentIndexer {
242
+ constructor(workspace) {
243
+ this.workspace = workspace || process.cwd();
244
+ this.knowledgeDir = path.join(this.workspace, "knowledge");
245
+ this.dbPath = path.join(this.knowledgeDir, "index.json");
246
+ this.yamlDir = path.join(this.knowledgeDir, "documents");
247
+ this.index = { documents: {}, links: {}, meta: { lastIndexed: null } };
248
+ }
249
+
250
+ async init() {
251
+ await fs.mkdir(this.knowledgeDir, { recursive: true });
252
+ await fs.mkdir(this.yamlDir, { recursive: true });
253
+ try {
254
+ const raw = await fs.readFile(this.dbPath, "utf-8");
255
+ this.index = JSON.parse(raw);
256
+ } catch {
257
+ this.index = { documents: {}, links: {}, meta: { lastIndexed: null } };
258
+ }
259
+ }
260
+
261
+ async save() {
262
+ this.index.meta.lastIndexed = new Date().toISOString();
263
+ await fs.writeFile(this.dbPath, JSON.stringify(this.index, null, 2));
264
+ }
265
+
266
+ async indexFile(filePath, options = {}) {
267
+ const absPath = path.resolve(filePath);
268
+ const content = await fs.readFile(absPath, "utf-8");
269
+ const { data: existingFrontmatter, content: body } = matter(content);
270
+ const stat = await fs.stat(absPath);
271
+ const contentHash = String(stat.mtimeMs);
272
+
273
+ const docId = path.relative(this.workspace, absPath)
274
+ .replace(/[\/\\]/g, "-")
275
+ .replace(/\.[^.]+$/, "");
276
+
277
+ if (this.index.documents[docId]?.contentHash === contentHash) {
278
+ return { status: "skipped", docId };
279
+ }
280
+
281
+ const category = options.category || inferCategory(absPath, body);
282
+ const tags = options.tags || extractTags(body, absPath);
283
+ const title = existingFrontmatter.title || path.basename(absPath, path.extname(absPath));
284
+
285
+ const frontmatter = {
286
+ id: docId,
287
+ title,
288
+ category,
289
+ tags,
290
+ type: existingFrontmatter.type || category,
291
+ source: path.relative(this.workspace, absPath),
292
+ indexed_at: new Date().toISOString(),
293
+ updated_at: stat.mtime.toISOString(),
294
+ };
295
+
296
+ const yamlContent = `---\n${Object.entries(frontmatter)
297
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
298
+ .join("\n")}\n---\n\n${body}`;
299
+
300
+ const yamlPath = path.join(this.yamlDir, `${docId}.yaml`);
301
+ await fs.writeFile(yamlPath, yamlContent);
302
+
303
+ const chunks = chunkText(body);
304
+
305
+ // Document any links / references to informational docs so they can
306
+ // also be indexed & retrieved.
307
+ const links = extractLinks(body, absPath);
308
+ const basename = path.basename(absPath).toLowerCase();
309
+
310
+ // llms.txt / agents.md: parse as a curated reference manifest
311
+ let llmsRefs = [];
312
+ if (basename === "llms.txt" || basename === "llms-full.txt" || basename === "agents.md") {
313
+ llmsRefs = parseLlmsTxt(body, path.dirname(absPath));
314
+ for (const ref of llmsRefs) {
315
+ this.index.links[ref.url] = {
316
+ title: ref.title,
317
+ category: "reference",
318
+ description: ref.description,
319
+ external: ref.external,
320
+ sourceDoc: docId,
321
+ addedAt: new Date().toISOString(),
322
+ };
323
+ }
324
+ }
325
+
326
+ this.index.documents[docId] = {
327
+ path: path.relative(this.workspace, absPath),
328
+ title,
329
+ category,
330
+ tags,
331
+ contentHash,
332
+ indexedAt: new Date().toISOString(),
333
+ chunkCount: chunks.length,
334
+ links: links.map((l) => l.target),
335
+ linkCount: links.length,
336
+ chunks: chunks.map((c, i) => ({ id: `${docId}:chunk-${i}`, content: c })),
337
+ };
338
+
339
+ // Record every extracted link centrally (the agent documents these too)
340
+ for (const l of links) {
341
+ const key = l.target;
342
+ if (!this.index.links[key]) {
343
+ this.index.links[key] = {
344
+ type: l.type,
345
+ category: "reference",
346
+ external: l.external,
347
+ sourceDoc: docId,
348
+ resolvedPath: l.resolvedPath || null,
349
+ addedAt: new Date().toISOString(),
350
+ };
351
+ }
352
+ }
353
+
354
+ return { status: "indexed", docId, chunks: chunks.length, links: links.length };
355
+ }
356
+
357
+ async indexDirectory(dirPath, options = {}) {
358
+ const extensions = options.extensions || ALL_EXTENSIONS;
359
+ const followLinks = options.followLinks !== false; // default: document + index linked docs
360
+ const absDir = path.resolve(dirPath);
361
+ const results = { indexed: 0, skipped: 0, errors: 0, links: 0 };
362
+
363
+ const seen = new Set();
364
+
365
+ const walk = async (dir) => {
366
+ let entries;
367
+ try {
368
+ entries = await fs.readdir(dir, { withFileTypes: true });
369
+ } catch {
370
+ return;
371
+ }
372
+ for (const entry of entries) {
373
+ const fullPath = path.join(dir, entry.name);
374
+ if (entry.isDirectory()) {
375
+ if (!entry.name.startsWith(".")
376
+ && !AGENT_INTERNAL_DIRS.has(entry.name)) {
377
+ await walk(fullPath);
378
+ }
379
+ } else if (isIndexable(entry.name, extensions)) {
380
+ if (seen.has(fullPath)) continue;
381
+ seen.add(fullPath);
382
+ try {
383
+ const result = await this.indexFile(fullPath, options);
384
+ if (result.status === "indexed") results.indexed++;
385
+ else results.skipped++;
386
+
387
+ // Follow local links → index referenced informational docs too
388
+ if (followLinks && result.links) {
389
+ for (const doc of Object.values(this.index.documents)) {
390
+ if (!doc.links) continue;
391
+ for (const link of doc.links) {
392
+ const resolved = this.index.links[link]?.resolvedPath;
393
+ if (resolved && fssync.existsSync(resolved) && !seen.has(resolved)) {
394
+ seen.add(resolved);
395
+ try {
396
+ const r = await this.indexFile(resolved, options);
397
+ if (r.status === "indexed") results.links++;
398
+ } catch { /* ignore unreadable refs */ }
399
+ }
400
+ }
401
+ }
402
+ }
403
+ } catch (err) {
404
+ results.errors++;
405
+ }
406
+ }
407
+ }
408
+ };
409
+
410
+ await walk(absDir);
411
+ await this.save();
412
+ return results;
413
+ }
414
+
415
+ async search(query, options = {}) {
416
+ const limit = options.limit || 10;
417
+ const category = options.category;
418
+ const queryLower = query.toLowerCase();
419
+
420
+ const results = [];
421
+ for (const [docId, doc] of Object.entries(this.index.documents)) {
422
+ if (category && doc.category !== category) continue;
423
+
424
+ for (const chunk of doc.chunks || []) {
425
+ const contentLower = chunk.content.toLowerCase();
426
+ const idx = contentLower.indexOf(queryLower);
427
+ if (idx !== -1) {
428
+ const start = Math.max(0, idx - 100);
429
+ const end = Math.min(chunk.content.length, idx + query.length + 100);
430
+ const snippet = (start > 0 ? "..." : "") +
431
+ chunk.content.slice(start, end) +
432
+ (end < chunk.content.length ? "..." : "");
433
+
434
+ results.push({
435
+ docId,
436
+ path: doc.path,
437
+ title: doc.title,
438
+ category: doc.category,
439
+ tags: doc.tags,
440
+ snippet,
441
+ chunkId: chunk.id,
442
+ });
443
+ break;
444
+ }
445
+ }
446
+ }
447
+
448
+ return results.slice(0, limit);
449
+ }
450
+
451
+ async addLink(url, title, category = "reference") {
452
+ this.index.links[url] = { title, category, addedAt: new Date().toISOString() };
453
+ await this.save();
454
+ return { status: "added", url };
455
+ }
456
+
457
+ async removeLink(url) {
458
+ delete this.index.links[url];
459
+ await this.save();
460
+ return { status: "removed", url };
461
+ }
462
+
463
+ listDocuments(category = null) {
464
+ return Object.entries(this.index.documents)
465
+ .filter(([_, doc]) => !category || doc.category === category)
466
+ .map(([id, doc]) => ({
467
+ id,
468
+ path: doc.path,
469
+ title: doc.title,
470
+ category: doc.category,
471
+ tags: doc.tags,
472
+ chunks: doc.chunkCount,
473
+ indexedAt: doc.indexedAt,
474
+ }));
475
+ }
476
+
477
+ status() {
478
+ return {
479
+ documents: Object.keys(this.index.documents).length,
480
+ chunks: Object.values(this.index.documents).reduce((sum, d) => sum + (d.chunkCount || 0), 0),
481
+ links: Object.keys(this.index.links).length,
482
+ lastIndexed: this.index.meta.lastIndexed,
483
+ knowledgeDir: this.knowledgeDir,
484
+ };
485
+ }
486
+ }
@@ -0,0 +1,154 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+
4
+ let Pipeline = null;
5
+ try {
6
+ const transformers = await import("@xenova/transformers");
7
+ Pipeline = transformers.pipeline;
8
+ } catch {
9
+ // Optional dependency — semantic search disabled if not installed
10
+ }
11
+
12
+ export class SemanticSearch {
13
+ constructor(workspace) {
14
+ this.workspace = workspace || process.cwd();
15
+ this.vectorsDir = path.join(this.workspace, "knowledge", "vectors");
16
+ this.embedder = null;
17
+ this.vectors = new Map();
18
+ }
19
+
20
+ async init() {
21
+ await fs.mkdir(this.vectorsDir, { recursive: true });
22
+ try {
23
+ const raw = await fs.readFile(path.join(this.vectorsDir, "vectors.json"), "utf-8");
24
+ const data = JSON.parse(raw);
25
+ for (const [id, entry] of Object.entries(data)) {
26
+ this.vectors.set(id, entry);
27
+ }
28
+ } catch {}
29
+ }
30
+
31
+ async loadEmbedder() {
32
+ if (this.embedder) return this.embedder;
33
+ if (!Pipeline) {
34
+ throw new Error("Semantic search requires @xenova/transformers. Install with: npm install @xenova/transformers");
35
+ }
36
+ this.embedder = await Pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2");
37
+ return this.embedder;
38
+ }
39
+
40
+ async embed(text) {
41
+ const pipe = await this.loadEmbedder();
42
+ const output = await pipe(text, { pooling: "mean", normalize: true });
43
+ return Array.from(output.data);
44
+ }
45
+
46
+ cosineSimilarity(a, b) {
47
+ let dot = 0, normA = 0, normB = 0;
48
+ for (let i = 0; i < a.length; i++) {
49
+ dot += a[i] * b[i];
50
+ normA += a[i] * a[i];
51
+ normB += b[i] * b[i];
52
+ }
53
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
54
+ }
55
+
56
+ async indexDocument(docId, text, metadata = {}) {
57
+ const chunks = [];
58
+ const chunkSize = 3500;
59
+ const overlap = 350;
60
+
61
+ if (text.length <= chunkSize) {
62
+ chunks.push({ id: docId, text, metadata });
63
+ } else {
64
+ let start = 0;
65
+ let i = 0;
66
+ while (start < text.length) {
67
+ const end = Math.min(start + chunkSize, text.length);
68
+ chunks.push({
69
+ id: `${docId}:chunk-${i}`,
70
+ text: text.slice(start, end),
71
+ metadata: { ...metadata, parent: docId, chunkIndex: i },
72
+ });
73
+ start = end - overlap;
74
+ i++;
75
+ if (start + overlap >= text.length) break;
76
+ }
77
+ }
78
+
79
+ for (const chunk of chunks) {
80
+ const embedding = await this.embed(chunk.text);
81
+ this.vectors.set(chunk.id, {
82
+ embedding,
83
+ text: chunk.text.slice(0, 200),
84
+ metadata: chunk.metadata,
85
+ });
86
+ }
87
+
88
+ return chunks.length;
89
+ }
90
+
91
+ async save() {
92
+ const data = {};
93
+ for (const [id, entry] of this.vectors) {
94
+ data[id] = entry;
95
+ }
96
+ await fs.writeFile(
97
+ path.join(this.vectorsDir, "vectors.json"),
98
+ JSON.stringify(data)
99
+ );
100
+ }
101
+
102
+ async search(query, limit = 10) {
103
+ const queryEmbedding = await this.embed(query);
104
+ const results = [];
105
+
106
+ for (const [id, entry] of this.vectors) {
107
+ const score = this.cosineSimilarity(queryEmbedding, entry.embedding);
108
+ results.push({
109
+ id,
110
+ score,
111
+ text: entry.text,
112
+ metadata: entry.metadata,
113
+ });
114
+ }
115
+
116
+ results.sort((a, b) => b.score - a.score);
117
+ return results.slice(0, limit);
118
+ }
119
+
120
+ async hybridSearch(query, keywordResults, limit = 10) {
121
+ const semanticResults = await this.search(query, limit * 2);
122
+
123
+ const rrf = new Map();
124
+ const k = 60;
125
+
126
+ for (let i = 0; i < keywordResults.length; i++) {
127
+ const id = keywordResults[i].docId || keywordResults[i].id;
128
+ const score = 1 / (k + i + 1);
129
+ rrf.set(id, (rrf.get(id) || 0) + score);
130
+ }
131
+
132
+ for (let i = 0; i < semanticResults.length; i++) {
133
+ const id = semanticResults[i].metadata?.parent || semanticResults[i].id;
134
+ const score = 1 / (k + i + 1);
135
+ rrf.set(id, (rrf.get(id) || 0) + score);
136
+ }
137
+
138
+ const sorted = [...rrf.entries()].sort((a, b) => b[1] - a[1]);
139
+ return sorted.slice(0, limit).map(([id, score]) => ({
140
+ id,
141
+ rrfScore: score,
142
+ text: this.vectors.get(id)?.text || "",
143
+ metadata: this.vectors.get(id)?.metadata || {},
144
+ }));
145
+ }
146
+
147
+ status() {
148
+ return {
149
+ vectors: this.vectors.size,
150
+ semanticAvailable: !!Pipeline,
151
+ vectorsDir: this.vectorsDir,
152
+ };
153
+ }
154
+ }