agentikit 0.0.9 → 0.0.13

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 (108) hide show
  1. package/README.md +139 -208
  2. package/dist/index.d.ts +8 -2
  3. package/dist/index.js +4 -1
  4. package/dist/src/asset-spec.d.ts +2 -0
  5. package/dist/src/asset-spec.js +22 -3
  6. package/dist/src/asset-type-handler.d.ts +27 -0
  7. package/dist/src/asset-type-handler.js +33 -0
  8. package/dist/src/cli.js +201 -75
  9. package/dist/src/common.d.ts +6 -1
  10. package/dist/src/common.js +18 -4
  11. package/dist/src/config-cli.d.ts +9 -0
  12. package/dist/src/config-cli.js +473 -0
  13. package/dist/src/config.d.ts +19 -6
  14. package/dist/src/config.js +139 -29
  15. package/dist/src/db.d.ts +46 -0
  16. package/dist/src/db.js +299 -0
  17. package/dist/src/embedder.js +12 -7
  18. package/dist/src/github.d.ts +4 -0
  19. package/dist/src/github.js +19 -0
  20. package/dist/src/handlers/agent-handler.d.ts +2 -0
  21. package/dist/src/handlers/agent-handler.js +26 -0
  22. package/dist/src/handlers/command-handler.d.ts +2 -0
  23. package/dist/src/handlers/command-handler.js +23 -0
  24. package/dist/src/handlers/index.d.ts +6 -0
  25. package/dist/src/handlers/index.js +23 -0
  26. package/dist/src/handlers/knowledge-handler.d.ts +2 -0
  27. package/dist/src/handlers/knowledge-handler.js +56 -0
  28. package/dist/src/handlers/markdown-helpers.d.ts +7 -0
  29. package/dist/src/handlers/markdown-helpers.js +15 -0
  30. package/dist/src/handlers/script-handler.d.ts +2 -0
  31. package/dist/src/handlers/script-handler.js +78 -0
  32. package/dist/src/handlers/skill-handler.d.ts +2 -0
  33. package/dist/src/handlers/skill-handler.js +30 -0
  34. package/dist/src/handlers/tool-handler.d.ts +2 -0
  35. package/dist/src/handlers/tool-handler.js +58 -0
  36. package/dist/src/indexer.d.ts +1 -23
  37. package/dist/src/indexer.js +162 -155
  38. package/dist/src/init.d.ts +2 -2
  39. package/dist/src/init.js +21 -9
  40. package/dist/src/llm.js +4 -3
  41. package/dist/src/metadata.d.ts +0 -1
  42. package/dist/src/metadata.js +6 -64
  43. package/dist/src/origin-resolve.d.ts +19 -0
  44. package/dist/src/origin-resolve.js +53 -0
  45. package/dist/src/registry-install.d.ts +2 -2
  46. package/dist/src/registry-install.js +142 -35
  47. package/dist/src/registry-resolve.js +90 -22
  48. package/dist/src/registry-search.d.ts +22 -0
  49. package/dist/src/registry-search.js +231 -97
  50. package/dist/src/registry-types.d.ts +9 -2
  51. package/dist/src/stash-add.js +4 -4
  52. package/dist/src/stash-clone.d.ts +22 -0
  53. package/dist/src/stash-clone.js +83 -0
  54. package/dist/src/stash-ref.d.ts +27 -3
  55. package/dist/src/stash-ref.js +63 -24
  56. package/dist/src/stash-registry.js +12 -12
  57. package/dist/src/stash-resolve.js +3 -0
  58. package/dist/src/stash-search.js +168 -164
  59. package/dist/src/stash-show.d.ts +1 -1
  60. package/dist/src/stash-show.js +28 -96
  61. package/dist/src/stash-source.d.ts +24 -0
  62. package/dist/src/stash-source.js +81 -0
  63. package/dist/src/stash-types.d.ts +14 -4
  64. package/dist/src/stash.d.ts +6 -0
  65. package/dist/src/stash.js +3 -0
  66. package/dist/src/tool-runner.d.ts +1 -1
  67. package/dist/src/tool-runner.js +18 -5
  68. package/package.json +7 -2
  69. package/src/asset-spec.ts +20 -4
  70. package/src/asset-type-handler.ts +77 -0
  71. package/src/cli.ts +213 -82
  72. package/src/common.ts +23 -5
  73. package/src/config-cli.ts +499 -0
  74. package/src/config.ts +160 -38
  75. package/src/db.ts +411 -0
  76. package/src/embedder.ts +22 -11
  77. package/src/github.ts +21 -0
  78. package/src/handlers/agent-handler.ts +32 -0
  79. package/src/handlers/command-handler.ts +29 -0
  80. package/src/handlers/index.ts +25 -0
  81. package/src/handlers/knowledge-handler.ts +62 -0
  82. package/src/handlers/markdown-helpers.ts +19 -0
  83. package/src/handlers/script-handler.ts +92 -0
  84. package/src/handlers/skill-handler.ts +37 -0
  85. package/src/handlers/tool-handler.ts +71 -0
  86. package/src/indexer.ts +208 -187
  87. package/src/init.ts +17 -9
  88. package/src/llm.ts +4 -3
  89. package/src/metadata.ts +5 -65
  90. package/src/origin-resolve.ts +67 -0
  91. package/src/registry-install.ts +158 -42
  92. package/src/registry-resolve.ts +92 -23
  93. package/src/registry-search.ts +288 -98
  94. package/src/registry-types.ts +10 -2
  95. package/src/stash-add.ts +14 -17
  96. package/src/stash-clone.ts +127 -0
  97. package/src/stash-ref.ts +84 -26
  98. package/src/stash-registry.ts +12 -12
  99. package/src/stash-resolve.ts +3 -0
  100. package/src/stash-search.ts +202 -184
  101. package/src/stash-show.ts +33 -90
  102. package/src/stash-source.ts +103 -0
  103. package/src/stash-types.ts +14 -4
  104. package/src/stash.ts +8 -0
  105. package/src/tool-runner.ts +18 -5
  106. package/dist/src/similarity.d.ts +0 -34
  107. package/dist/src/similarity.js +0 -211
  108. package/src/similarity.ts +0 -271
package/src/stash-show.ts CHANGED
@@ -1,24 +1,17 @@
1
1
  import fs from "node:fs"
2
- import path from "node:path"
3
- import { parseFrontmatter, toStringOrUndefined } from "./frontmatter"
4
- import { resolveStashDir } from "./common"
5
- import { parseOpenRef } from "./stash-ref"
2
+ import { parseAssetRef } from "./stash-ref"
3
+ import { resolveSourcesForOrigin } from "./origin-resolve"
6
4
  import { resolveAssetPath } from "./stash-resolve"
7
5
  import type { KnowledgeView, ShowResponse } from "./stash-types"
8
- import { parseMarkdownToc, extractSection, extractLineRange, extractFrontmatterOnly, formatToc } from "./markdown"
9
- import { buildToolInfo } from "./tool-runner"
10
- import { loadConfig } from "./config"
6
+ import { getHandler } from "./asset-type-handler"
7
+ import { resolveStashSources, findSourceForPath } from "./stash-source"
11
8
 
12
- export function agentikitShow(input: { ref: string; view?: KnowledgeView }): ShowResponse {
13
- const parsed = parseOpenRef(input.ref)
14
- const stashDir = resolveStashDir()
15
- const config = loadConfig(stashDir)
16
- const allStashDirs = [
17
- stashDir,
18
- ...config.additionalStashDirs.filter((d) => {
19
- try { return fs.statSync(d).isDirectory() } catch { return false }
20
- }),
21
- ]
9
+ export async function agentikitShow(input: { ref: string; view?: KnowledgeView }): Promise<ShowResponse> {
10
+ const parsed = parseAssetRef(input.ref)
11
+ const allSources = resolveStashSources()
12
+ const searchSources = resolveSourcesForOrigin(parsed.origin, allSources)
13
+
14
+ const allStashDirs = searchSources.map((s) => s.path)
22
15
 
23
16
  let assetPath: string | undefined
24
17
  let lastError: Error | undefined
@@ -30,83 +23,33 @@ export function agentikitShow(input: { ref: string; view?: KnowledgeView }): Sho
30
23
  lastError = err instanceof Error ? err : new Error(String(err))
31
24
  }
32
25
  }
26
+
27
+ if (!assetPath && parsed.origin && searchSources.length === 0) {
28
+ const installCmd = `akm add ${parsed.origin}`
29
+ throw new Error(
30
+ `Stash asset not found for ref: ${parsed.type}:${parsed.name}. ` +
31
+ `Kit "${parsed.origin}" is not installed. Run: ${installCmd}`
32
+ )
33
+ }
34
+
33
35
  if (!assetPath) {
34
36
  throw lastError ?? new Error(`Stash asset not found for ref: ${parsed.type}:${parsed.name}`)
35
37
  }
36
38
  const content = fs.readFileSync(assetPath, "utf8")
37
39
 
38
- switch (parsed.type) {
39
- case "skill":
40
- return {
41
- type: "skill",
42
- name: parsed.name,
43
- path: assetPath,
44
- content,
45
- }
46
- case "command": {
47
- const parsedMd = parseFrontmatter(content)
48
- return {
49
- type: "command",
50
- name: parsed.name,
51
- path: assetPath,
52
- description: toStringOrUndefined(parsedMd.data.description),
53
- template: parsedMd.content,
54
- }
55
- }
56
- case "agent": {
57
- const parsedMd = parseFrontmatter(content)
58
- return {
59
- type: "agent",
60
- name: parsed.name,
61
- path: assetPath,
62
- description: toStringOrUndefined(parsedMd.data.description),
63
- prompt: "Dispatching prompt must include the agent's full prompt content verbatim; summaries are non-compliant. \n\n"
64
- + parsedMd.content,
65
- toolPolicy: parsedMd.data.tools,
66
- modelHint: parsedMd.data.model,
67
- }
68
- }
69
- case "tool": {
70
- const assetStashDir = allStashDirs.find((d) => path.resolve(assetPath!).startsWith(path.resolve(d) + path.sep)) ?? stashDir
71
- const toolInfo = buildToolInfo(assetStashDir, assetPath)
72
- return {
73
- type: "tool",
74
- name: parsed.name,
75
- path: assetPath,
76
- runCmd: toolInfo.runCmd,
77
- kind: toolInfo.kind,
78
- }
79
- }
80
- case "knowledge": {
81
- const v = input.view ?? { mode: "full" }
82
- switch (v.mode) {
83
- case "toc": {
84
- const toc = parseMarkdownToc(content)
85
- return { type: "knowledge", name: parsed.name, path: assetPath, content: formatToc(toc) }
86
- }
87
- case "frontmatter": {
88
- const fm = extractFrontmatterOnly(content)
89
- return { type: "knowledge", name: parsed.name, path: assetPath, content: fm ?? "(no frontmatter)" }
90
- }
91
- case "section": {
92
- const section = extractSection(content, v.heading)
93
- if (!section) {
94
- return {
95
- type: "knowledge",
96
- name: parsed.name,
97
- path: assetPath,
98
- content: `Section "${v.heading}" not found in ${parsed.name}. Try --view toc to discover available headings.`,
99
- }
100
- }
101
- return { type: "knowledge", name: parsed.name, path: assetPath, content: section.content }
102
- }
103
- case "lines": {
104
- return { type: "knowledge", name: parsed.name, path: assetPath, content: extractLineRange(content, v.start, v.end) }
105
- }
106
- default: {
107
- return { type: "knowledge", name: parsed.name, path: assetPath, content }
108
- }
109
- }
110
- }
40
+ const source = findSourceForPath(assetPath, allSources)
41
+ const handler = getHandler(parsed.type)
42
+ const response = handler.buildShowResponse({
43
+ name: parsed.name,
44
+ path: assetPath,
45
+ content,
46
+ view: input.view,
47
+ stashDirs: allStashDirs,
48
+ })
49
+
50
+ return {
51
+ ...response,
52
+ registryId: source?.registryId,
53
+ editable: source?.writable ?? false,
111
54
  }
112
55
  }
@@ -0,0 +1,103 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { resolveStashDir } from "./common"
4
+ import { loadConfig } from "./config"
5
+
6
+ // ── Types ───────────────────────────────────────────────────────────────────
7
+
8
+ export type StashSourceKind = "working" | "mounted" | "installed"
9
+
10
+ export interface StashSource {
11
+ kind: StashSourceKind
12
+ path: string
13
+ /** For installed sources, the registry entry id */
14
+ registryId?: string
15
+ /** Whether this source is writable (only working stash) */
16
+ writable: boolean
17
+ }
18
+
19
+ // ── Resolution ──────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Build the ordered list of stash sources:
23
+ * 1. Working stash (writable)
24
+ * 2. Mounted stash dirs (read-only, user-configured)
25
+ * 3. Installed stash dirs (read-only, derived from registry.installed)
26
+ */
27
+ export function resolveStashSources(overrideStashDir?: string): StashSource[] {
28
+ const stashDir = overrideStashDir ?? resolveStashDir()
29
+ const config = loadConfig()
30
+
31
+ const sources: StashSource[] = [
32
+ { kind: "working", path: stashDir, writable: true },
33
+ ]
34
+
35
+ for (const dir of config.mountedStashDirs) {
36
+ if (isSuspiciousStashRoot(dir)) {
37
+ console.warn(`Warning: stash root "${dir}" appears to be a system directory. This may be unintentional.`)
38
+ }
39
+ if (isValidDirectory(dir)) {
40
+ sources.push({ kind: "mounted", path: dir, writable: false })
41
+ }
42
+ }
43
+
44
+ for (const entry of config.registry?.installed ?? []) {
45
+ if (isSuspiciousStashRoot(entry.stashRoot)) {
46
+ console.warn(`Warning: stash root "${entry.stashRoot}" appears to be a system directory. This may be unintentional.`)
47
+ }
48
+ if (isValidDirectory(entry.stashRoot)) {
49
+ sources.push({
50
+ kind: "installed",
51
+ path: entry.stashRoot,
52
+ registryId: entry.id,
53
+ writable: false,
54
+ })
55
+ }
56
+ }
57
+
58
+ return sources
59
+ }
60
+
61
+ /**
62
+ * Convenience: returns just the directory paths, preserving priority order.
63
+ */
64
+ export function resolveAllStashDirs(overrideStashDir?: string): string[] {
65
+ return resolveStashSources(overrideStashDir).map((s) => s.path)
66
+ }
67
+
68
+ /**
69
+ * Find which source a file path belongs to.
70
+ */
71
+ export function findSourceForPath(filePath: string, sources: StashSource[]): StashSource | undefined {
72
+ const resolved = path.resolve(filePath)
73
+ for (const source of sources) {
74
+ if (resolved.startsWith(path.resolve(source.path) + path.sep)) return source
75
+ }
76
+ return undefined
77
+ }
78
+
79
+ // ── Validation ──────────────────────────────────────────────────────────────
80
+
81
+ const SUSPICIOUS_ROOTS = new Set(['/', '/etc', '/bin', '/sbin', '/usr', '/var', '/tmp', '/dev', '/proc', '/sys'])
82
+
83
+ function isSuspiciousStashRoot(dir: string): boolean {
84
+ const resolved = path.resolve(dir)
85
+ const normalized = process.platform === 'win32' ? resolved.toLowerCase() : resolved
86
+ if (SUSPICIOUS_ROOTS.has(normalized)) return true
87
+ if (process.platform === 'win32') {
88
+ // Check for Windows system directories
89
+ const winDir = (process.env.SystemRoot || 'C:\\Windows').toLowerCase()
90
+ if (normalized === winDir || normalized.startsWith(winDir + path.sep)) return true
91
+ }
92
+ return false
93
+ }
94
+
95
+ // ── Helpers ─────────────────────────────────────────────────────────────────
96
+
97
+ function isValidDirectory(dir: string): boolean {
98
+ try {
99
+ return fs.statSync(dir).isDirectory()
100
+ } catch {
101
+ return false
102
+ }
103
+ }
@@ -12,6 +12,10 @@ export interface LocalSearchHit {
12
12
  name: string
13
13
  path: string
14
14
  openRef: string
15
+ /** For installed sources, the registry id */
16
+ registryId?: string
17
+ /** Whether this asset is editable (only true for working stash) */
18
+ editable?: boolean
15
19
  description?: string
16
20
  tags?: string[]
17
21
  score?: number
@@ -41,6 +45,8 @@ export interface RegistrySearchResultHit {
41
45
  metadata?: Record<string, string>
42
46
  installRef: string
43
47
  installCmd: string
48
+ /** Whether this entry was manually reviewed and approved */
49
+ curated?: boolean
44
50
  }
45
51
 
46
52
  export type SearchHit = LocalSearchHit | RegistrySearchResultHit
@@ -72,7 +78,7 @@ export interface AddResponse {
72
78
  installedAt: string
73
79
  }
74
80
  config: {
75
- additionalStashDirs: string[]
81
+ mountedStashDirs: string[]
76
82
  installedRegistryCount: number
77
83
  }
78
84
  index: {
@@ -129,7 +135,7 @@ export interface RemoveResponse {
129
135
  stashRoot: string
130
136
  }
131
137
  config: {
132
- additionalStashDirs: string[]
138
+ mountedStashDirs: string[]
133
139
  installedRegistryCount: number
134
140
  }
135
141
  index: {
@@ -154,7 +160,7 @@ export interface ReinstallResponse {
154
160
  all: boolean
155
161
  processed: ReinstallResultItem[]
156
162
  config: {
157
- additionalStashDirs: string[]
163
+ mountedStashDirs: string[]
158
164
  installedRegistryCount: number
159
165
  }
160
166
  index: {
@@ -188,7 +194,7 @@ export interface UpdateResponse {
188
194
  all: boolean
189
195
  processed: UpdateResultItem[]
190
196
  config: {
191
- additionalStashDirs: string[]
197
+ mountedStashDirs: string[]
192
198
  installedRegistryCount: number
193
199
  }
194
200
  index: {
@@ -211,6 +217,10 @@ export interface ShowResponse {
211
217
  modelHint?: unknown
212
218
  runCmd?: string
213
219
  kind?: ToolKind
220
+ /** For installed sources, the registry id */
221
+ registryId?: string
222
+ /** Whether this asset is editable (only true for working stash) */
223
+ editable?: boolean
214
224
  }
215
225
 
216
226
  export type KnowledgeView =
package/src/stash.ts CHANGED
@@ -3,11 +3,17 @@ export { resolveStashDir } from "./common"
3
3
  export { agentikitInit } from "./init"
4
4
  export type { InitResponse } from "./init"
5
5
  export type { ToolKind } from "./tool-runner"
6
+ export type { AssetTypeHandler, ShowInput } from "./asset-type-handler"
7
+ export { registerAssetType, getHandler, getAllHandlers, getRegisteredTypeNames } from "./asset-type-handler"
6
8
 
7
9
  export { agentikitSearch } from "./stash-search"
8
10
  export { agentikitShow } from "./stash-show"
9
11
  export { agentikitAdd } from "./stash-add"
12
+ export { agentikitClone } from "./stash-clone"
13
+
10
14
  export { agentikitList, agentikitRemove, agentikitReinstall, agentikitUpdate } from "./stash-registry"
15
+ export { resolveStashSources, resolveAllStashDirs, findSourceForPath } from "./stash-source"
16
+ export type { StashSource, StashSourceKind } from "./stash-source"
11
17
 
12
18
  export type {
13
19
  AddResponse,
@@ -29,3 +35,5 @@ export type {
29
35
  ReinstallResultItem,
30
36
  UpdateResultItem,
31
37
  } from "./stash-types"
38
+
39
+ export type { CloneOptions, CloneResponse } from "./stash-clone"
@@ -34,7 +34,7 @@ export interface ToolInfo {
34
34
  *
35
35
  * For `.ts` / `.js` files, looks up the nearest `package.json` so that
36
36
  * `bun install` can be run in the correct working directory when the
37
- * `AGENTIKIT_BUN_INSTALL` env flag is set.
37
+ * `AKM_BUN_INSTALL` env flag is set.
38
38
  */
39
39
  export function buildToolInfo(stashDir: string, filePath: string): ToolInfo {
40
40
  const ext = path.extname(filePath).toLowerCase()
@@ -67,8 +67,17 @@ export function buildToolInfo(stashDir: string, filePath: string): ToolInfo {
67
67
  throw new Error(`Unsupported tool extension: ${ext}`)
68
68
  }
69
69
 
70
- const toolsRoot = path.resolve(path.join(stashDir, "tools"))
71
- const pkgDir = findNearestPackageDir(path.dirname(filePath), toolsRoot)
70
+ // Determine the type root by checking which subdirectory contains the file
71
+ const resolvedFile = path.resolve(filePath)
72
+ let typeRoot = path.resolve(path.join(stashDir, "tools"))
73
+ for (const subdir of ["tools", "scripts"]) {
74
+ const candidate = path.resolve(path.join(stashDir, subdir))
75
+ if (resolvedFile.startsWith(candidate + path.sep)) {
76
+ typeRoot = candidate
77
+ break
78
+ }
79
+ }
80
+ const pkgDir = findNearestPackageDir(path.dirname(filePath), typeRoot)
72
81
  if (!pkgDir) {
73
82
  return {
74
83
  runCmd: `bun ${shellQuote(filePath)}`,
@@ -76,7 +85,7 @@ export function buildToolInfo(stashDir: string, filePath: string): ToolInfo {
76
85
  execute: { command: "bun", args: [filePath] },
77
86
  }
78
87
  }
79
- const installFlag = process.env.AGENTIKIT_BUN_INSTALL
88
+ const installFlag = process.env.AKM_BUN_INSTALL
80
89
  const shouldInstall = installFlag === "1" || installFlag === "true" || installFlag === "yes"
81
90
 
82
91
  const quotedPkgDir = shellQuote(pkgDir)
@@ -101,7 +110,11 @@ export function shellQuote(input: string): string {
101
110
  throw new Error("Unsupported control characters in stash path.")
102
111
  }
103
112
  if (IS_WINDOWS) {
104
- return `"${input.replace(/"/g, '""')}"`
113
+ const escaped = input
114
+ .replace(/%/g, "%%")
115
+ .replace(/([\\^|&<>])/g, "^$1")
116
+ .replace(/"/g, '""')
117
+ return `"${escaped}"`
105
118
  }
106
119
  const escaped = input
107
120
  .replace(/\\/g, "\\\\")
@@ -1,34 +0,0 @@
1
- import type { StashEntry } from "./metadata";
2
- export interface ScoredEntry {
3
- id: string;
4
- text: string;
5
- entry: StashEntry;
6
- path: string;
7
- }
8
- export interface ScoredResult {
9
- entry: StashEntry;
10
- path: string;
11
- score: number;
12
- }
13
- export interface SearchAdapter {
14
- buildIndex(entries: ScoredEntry[]): void;
15
- search(query: string, limit: number, typeFilter?: string): ScoredResult[];
16
- }
17
- export interface SerializedTfIdf {
18
- idf: Record<string, number>;
19
- docs: Array<{
20
- id: string;
21
- termFreqs: Record<string, number>;
22
- magnitude: number;
23
- }>;
24
- }
25
- export declare class TfIdfAdapter implements SearchAdapter {
26
- private documents;
27
- private idf;
28
- private entries;
29
- buildIndex(entries: ScoredEntry[]): void;
30
- search(query: string, limit: number, typeFilter?: string): ScoredResult[];
31
- serialize(): SerializedTfIdf;
32
- static deserialize(data: SerializedTfIdf, entries: ScoredEntry[]): TfIdfAdapter;
33
- private substringFallback;
34
- }
@@ -1,211 +0,0 @@
1
- export class TfIdfAdapter {
2
- documents = [];
3
- idf = new Map();
4
- entries = [];
5
- buildIndex(entries) {
6
- this.entries = entries;
7
- const docCount = entries.length;
8
- if (docCount === 0)
9
- return;
10
- // Compute term frequencies per document
11
- const docFreqs = new Map();
12
- this.documents = entries.map((entry) => {
13
- const tokens = tokenize(entry.text);
14
- const termFreqs = new Map();
15
- for (const token of tokens) {
16
- termFreqs.set(token, (termFreqs.get(token) || 0) + 1);
17
- }
18
- // Track document frequency for IDF
19
- for (const term of termFreqs.keys()) {
20
- docFreqs.set(term, (docFreqs.get(term) || 0) + 1);
21
- }
22
- return { entry, termFreqs, magnitude: 0 };
23
- });
24
- // Compute IDF: log(N / df)
25
- this.idf = new Map();
26
- for (const [term, df] of docFreqs) {
27
- this.idf.set(term, Math.log(docCount / df));
28
- }
29
- // Compute document magnitudes for cosine similarity
30
- for (const doc of this.documents) {
31
- let sumSq = 0;
32
- for (const [term, tf] of doc.termFreqs) {
33
- const idf = this.idf.get(term) || 0;
34
- const tfidf = tf * idf;
35
- sumSq += tfidf * tfidf;
36
- }
37
- doc.magnitude = Math.sqrt(sumSq);
38
- }
39
- }
40
- search(query, limit, typeFilter) {
41
- if (this.documents.length === 0)
42
- return [];
43
- const queryTokens = tokenize(query.toLowerCase());
44
- if (queryTokens.length === 0) {
45
- // Empty query: return all, sorted by type
46
- return this.documents
47
- .filter((d) => !typeFilter || typeFilter === "any" || d.entry.entry.type === typeFilter)
48
- .slice(0, limit)
49
- .map((d) => ({
50
- entry: d.entry.entry,
51
- path: d.entry.path,
52
- score: 1,
53
- }));
54
- }
55
- // Build query TF-IDF vector
56
- const queryTermFreqs = new Map();
57
- for (const token of queryTokens) {
58
- queryTermFreqs.set(token, (queryTermFreqs.get(token) || 0) + 1);
59
- }
60
- let queryMagnitude = 0;
61
- const queryVector = new Map();
62
- for (const [term, tf] of queryTermFreqs) {
63
- const idf = this.idf.get(term) || 0;
64
- const tfidf = tf * idf;
65
- queryVector.set(term, tfidf);
66
- queryMagnitude += tfidf * tfidf;
67
- }
68
- queryMagnitude = Math.sqrt(queryMagnitude);
69
- if (queryMagnitude === 0) {
70
- // All query terms are unknown — fallback to substring match
71
- return this.substringFallback(query, limit, typeFilter);
72
- }
73
- const results = [];
74
- const querySet = new Set(queryTokens);
75
- for (const doc of this.documents) {
76
- if (typeFilter && typeFilter !== "any" && doc.entry.entry.type !== typeFilter)
77
- continue;
78
- // Cosine similarity
79
- let dotProduct = 0;
80
- for (const [term, queryTfidf] of queryVector) {
81
- const docTf = doc.termFreqs.get(term) || 0;
82
- if (docTf === 0)
83
- continue;
84
- const docIdf = this.idf.get(term) || 0;
85
- dotProduct += queryTfidf * (docTf * docIdf);
86
- }
87
- let score = doc.magnitude > 0 && queryMagnitude > 0
88
- ? dotProduct / (doc.magnitude * queryMagnitude)
89
- : 0;
90
- // Boost: tag exact match
91
- const tags = doc.entry.entry.tags || [];
92
- for (const tag of tags) {
93
- if (querySet.has(tag.toLowerCase())) {
94
- score += 0.15;
95
- }
96
- }
97
- // Boost: intent phrase contains query token
98
- const intents = doc.entry.entry.intents || [];
99
- for (const intent of intents) {
100
- const intentLower = intent.toLowerCase();
101
- for (const token of queryTokens) {
102
- if (intentLower.includes(token)) {
103
- score += 0.12;
104
- break; // one boost per intent phrase
105
- }
106
- }
107
- }
108
- // Boost: name contains query token
109
- const nameLower = doc.entry.entry.name.toLowerCase().replace(/[-_]/g, " ");
110
- for (const token of queryTokens) {
111
- if (nameLower.includes(token)) {
112
- score += 0.1;
113
- break;
114
- }
115
- }
116
- if (score > 0) {
117
- results.push({
118
- entry: doc.entry.entry,
119
- path: doc.entry.path,
120
- score: Math.round(score * 1000) / 1000,
121
- });
122
- }
123
- }
124
- results.sort((a, b) => b.score - a.score);
125
- return results.slice(0, limit);
126
- }
127
- serialize() {
128
- const idf = {};
129
- for (const [term, val] of this.idf) {
130
- idf[term] = val;
131
- }
132
- const docs = this.documents.map((d) => {
133
- const termFreqs = {};
134
- for (const [term, tf] of d.termFreqs) {
135
- termFreqs[term] = tf;
136
- }
137
- return { id: d.entry.id, termFreqs, magnitude: d.magnitude };
138
- });
139
- return { idf, docs };
140
- }
141
- static deserialize(data, entries) {
142
- const adapter = new TfIdfAdapter();
143
- adapter.entries = entries;
144
- adapter.idf = new Map(Object.entries(data.idf));
145
- const entryMap = new Map(entries.map((e) => [e.id, e]));
146
- adapter.documents = data.docs
147
- .map((d) => {
148
- const entry = entryMap.get(d.id);
149
- if (!entry)
150
- return null;
151
- return {
152
- entry,
153
- termFreqs: new Map(Object.entries(d.termFreqs)),
154
- magnitude: d.magnitude,
155
- };
156
- })
157
- .filter((d) => d !== null);
158
- return adapter;
159
- }
160
- substringFallback(query, limit, typeFilter) {
161
- const q = query.toLowerCase();
162
- const tokens = tokenize(q);
163
- return this.documents
164
- .map((d) => {
165
- if (typeFilter && typeFilter !== "any" && d.entry.entry.type !== typeFilter)
166
- return null;
167
- // Check if any query token matches the document text or name
168
- const text = d.entry.text;
169
- const name = d.entry.entry.name.toLowerCase();
170
- let matchCount = 0;
171
- for (const token of tokens) {
172
- if (text.includes(token) || name.includes(token))
173
- matchCount++;
174
- }
175
- // Also check full substring match
176
- if (text.includes(q) || name.includes(q))
177
- matchCount = Math.max(matchCount, tokens.length);
178
- if (matchCount === 0)
179
- return null;
180
- return {
181
- entry: d.entry.entry,
182
- path: d.entry.path,
183
- score: Math.round((matchCount / Math.max(tokens.length, 1)) * 500) / 1000,
184
- };
185
- })
186
- .filter((d) => d !== null)
187
- .sort((a, b) => b.score - a.score)
188
- .slice(0, limit);
189
- }
190
- }
191
- // ── Tokenization ────────────────────────────────────────────────────────────
192
- const STOP_WORDS = new Set([
193
- "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
194
- "have", "has", "had", "do", "does", "did", "will", "would", "could",
195
- "should", "may", "might", "shall", "can", "need", "dare", "ought",
196
- "to", "of", "in", "for", "on", "with", "at", "by", "from", "as",
197
- "into", "through", "during", "before", "after", "above", "below",
198
- "and", "but", "or", "nor", "not", "so", "yet", "both", "either",
199
- "neither", "each", "every", "all", "any", "few", "more", "most",
200
- "other", "some", "such", "no", "only", "own", "same", "than",
201
- "too", "very", "just", "because", "if", "when", "where", "how",
202
- "what", "which", "who", "whom", "this", "that", "these", "those",
203
- "it", "its",
204
- ]);
205
- function tokenize(text) {
206
- return text
207
- .toLowerCase()
208
- .replace(/[^a-z0-9]+/g, " ")
209
- .split(/\s+/)
210
- .filter((t) => t.length > 1 && !STOP_WORDS.has(t));
211
- }