@soederpop/luca 0.0.2
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.md +71 -0
- package/README.md +78 -0
- package/bun.lock +2928 -0
- package/bunfig.toml +3 -0
- package/commands/audit-docs.ts +740 -0
- package/commands/build-scaffolds.ts +154 -0
- package/commands/generate-api-docs.ts +114 -0
- package/commands/update-introspection.ts +67 -0
- package/docs/CLI.md +335 -0
- package/docs/README.md +88 -0
- package/docs/TABLE-OF-CONTENTS.md +157 -0
- package/docs/apis/clients/elevenlabs.md +84 -0
- package/docs/apis/clients/graph.md +56 -0
- package/docs/apis/clients/openai.md +69 -0
- package/docs/apis/clients/rest.md +41 -0
- package/docs/apis/clients/websocket.md +107 -0
- package/docs/apis/features/agi/assistant.md +471 -0
- package/docs/apis/features/agi/assistants-manager.md +154 -0
- package/docs/apis/features/agi/claude-code.md +602 -0
- package/docs/apis/features/agi/conversation-history.md +352 -0
- package/docs/apis/features/agi/conversation.md +333 -0
- package/docs/apis/features/agi/docs-reader.md +121 -0
- package/docs/apis/features/agi/openai-codex.md +318 -0
- package/docs/apis/features/agi/openapi.md +138 -0
- package/docs/apis/features/agi/semantic-search.md +387 -0
- package/docs/apis/features/agi/skills-library.md +216 -0
- package/docs/apis/features/node/container-link.md +133 -0
- package/docs/apis/features/node/content-db.md +313 -0
- package/docs/apis/features/node/disk-cache.md +379 -0
- package/docs/apis/features/node/dns.md +651 -0
- package/docs/apis/features/node/docker.md +705 -0
- package/docs/apis/features/node/downloader.md +81 -0
- package/docs/apis/features/node/esbuild.md +59 -0
- package/docs/apis/features/node/file-manager.md +182 -0
- package/docs/apis/features/node/fs.md +581 -0
- package/docs/apis/features/node/git.md +330 -0
- package/docs/apis/features/node/google-auth.md +174 -0
- package/docs/apis/features/node/google-calendar.md +187 -0
- package/docs/apis/features/node/google-docs.md +151 -0
- package/docs/apis/features/node/google-drive.md +225 -0
- package/docs/apis/features/node/google-sheets.md +179 -0
- package/docs/apis/features/node/grep.md +290 -0
- package/docs/apis/features/node/helpers.md +135 -0
- package/docs/apis/features/node/ink.md +334 -0
- package/docs/apis/features/node/ipc-socket.md +260 -0
- package/docs/apis/features/node/json-tree.md +86 -0
- package/docs/apis/features/node/launcher-app-command-listener.md +145 -0
- package/docs/apis/features/node/networking.md +281 -0
- package/docs/apis/features/node/nlp.md +133 -0
- package/docs/apis/features/node/opener.md +97 -0
- package/docs/apis/features/node/os.md +118 -0
- package/docs/apis/features/node/package-finder.md +402 -0
- package/docs/apis/features/node/postgres.md +212 -0
- package/docs/apis/features/node/proc.md +430 -0
- package/docs/apis/features/node/process-manager.md +210 -0
- package/docs/apis/features/node/python.md +278 -0
- package/docs/apis/features/node/repl.md +88 -0
- package/docs/apis/features/node/runpod.md +673 -0
- package/docs/apis/features/node/secure-shell.md +169 -0
- package/docs/apis/features/node/semantic-search.md +401 -0
- package/docs/apis/features/node/sqlite.md +211 -0
- package/docs/apis/features/node/telegram.md +254 -0
- package/docs/apis/features/node/tts.md +118 -0
- package/docs/apis/features/node/ui.md +703 -0
- package/docs/apis/features/node/vault.md +64 -0
- package/docs/apis/features/node/vm.md +84 -0
- package/docs/apis/features/node/window-manager.md +337 -0
- package/docs/apis/features/node/yaml-tree.md +85 -0
- package/docs/apis/features/node/yaml.md +176 -0
- package/docs/apis/features/web/asset-loader.md +47 -0
- package/docs/apis/features/web/container-link.md +133 -0
- package/docs/apis/features/web/esbuild.md +59 -0
- package/docs/apis/features/web/helpers.md +135 -0
- package/docs/apis/features/web/network.md +30 -0
- package/docs/apis/features/web/speech.md +55 -0
- package/docs/apis/features/web/vault.md +64 -0
- package/docs/apis/features/web/vm.md +84 -0
- package/docs/apis/features/web/voice.md +67 -0
- package/docs/apis/servers/express.md +127 -0
- package/docs/apis/servers/mcp.md +213 -0
- package/docs/apis/servers/websocket.md +99 -0
- package/docs/documentation-audit.md +134 -0
- package/docs/examples/content-db.md +77 -0
- package/docs/examples/disk-cache.md +83 -0
- package/docs/examples/docker.md +101 -0
- package/docs/examples/downloader.md +70 -0
- package/docs/examples/esbuild.md +80 -0
- package/docs/examples/file-manager.md +82 -0
- package/docs/examples/fs.md +83 -0
- package/docs/examples/git.md +85 -0
- package/docs/examples/google-auth.md +88 -0
- package/docs/examples/google-calendar.md +94 -0
- package/docs/examples/google-docs.md +82 -0
- package/docs/examples/google-drive.md +96 -0
- package/docs/examples/google-sheets.md +95 -0
- package/docs/examples/grep.md +85 -0
- package/docs/examples/ink-blocks.md +75 -0
- package/docs/examples/ink-renderer.md +41 -0
- package/docs/examples/ink.md +103 -0
- package/docs/examples/ipc-socket.md +103 -0
- package/docs/examples/json-tree.md +91 -0
- package/docs/examples/launcher-app-command-listener.md +120 -0
- package/docs/examples/networking.md +58 -0
- package/docs/examples/nlp.md +91 -0
- package/docs/examples/opener.md +78 -0
- package/docs/examples/os.md +72 -0
- package/docs/examples/package-finder.md +89 -0
- package/docs/examples/port-exposer.md +89 -0
- package/docs/examples/postgres.md +91 -0
- package/docs/examples/proc.md +81 -0
- package/docs/examples/process-manager.md +79 -0
- package/docs/examples/python.md +91 -0
- package/docs/examples/repl.md +93 -0
- package/docs/examples/runpod.md +119 -0
- package/docs/examples/secure-shell.md +92 -0
- package/docs/examples/sqlite.md +86 -0
- package/docs/examples/telegram.md +77 -0
- package/docs/examples/tts.md +86 -0
- package/docs/examples/ui.md +80 -0
- package/docs/examples/vault.md +70 -0
- package/docs/examples/vm.md +86 -0
- package/docs/examples/window-manager.md +125 -0
- package/docs/examples/yaml-tree.md +93 -0
- package/docs/examples/yaml.md +104 -0
- package/docs/ideas/class-registration-refactor-possibilities.md +197 -0
- package/docs/ideas/container-use-api.md +9 -0
- package/docs/ideas/easy-auth-for-express-servers-and-luca-serve.md +0 -0
- package/docs/ideas/feature-stacks.md +22 -0
- package/docs/ideas/luca-cli-self-sufficiency-demo.md +23 -0
- package/docs/ideas/mcp-design.md +9 -0
- package/docs/ideas/web-container-debugging-feature.md +13 -0
- package/docs/introspection-audit.md +49 -0
- package/docs/introspection.md +154 -0
- package/docs/mcp/readme.md +162 -0
- package/docs/models.ts +38 -0
- package/docs/philosophy.md +85 -0
- package/docs/principles.md +7 -0
- package/docs/prompts/audit-codebase-for-failures-to-use-the-container.md +34 -0
- package/docs/prompts/mcp-test-easy-command.md +27 -0
- package/docs/reports/assistant-bugs.md +38 -0
- package/docs/reports/attach-pattern-usage.md +18 -0
- package/docs/reports/code-audit-results.md +391 -0
- package/docs/reports/introspection-audit-tasks.md +378 -0
- package/docs/reports/luca-mcp-improvements.md +128 -0
- package/docs/scaffolds/client.md +140 -0
- package/docs/scaffolds/command.md +106 -0
- package/docs/scaffolds/endpoint.md +176 -0
- package/docs/scaffolds/feature.md +148 -0
- package/docs/scaffolds/server.md +187 -0
- package/docs/tasks/web-container-helper-discovery.md +71 -0
- package/docs/todos.md +1 -0
- package/docs/tutorials/01-getting-started.md +106 -0
- package/docs/tutorials/02-container.md +210 -0
- package/docs/tutorials/03-scripts.md +194 -0
- package/docs/tutorials/04-features-overview.md +196 -0
- package/docs/tutorials/05-state-and-events.md +171 -0
- package/docs/tutorials/06-servers.md +157 -0
- package/docs/tutorials/07-endpoints.md +198 -0
- package/docs/tutorials/08-commands.md +171 -0
- package/docs/tutorials/09-clients.md +162 -0
- package/docs/tutorials/10-creating-features.md +198 -0
- package/docs/tutorials/11-contentbase.md +191 -0
- package/docs/tutorials/12-assistants.md +215 -0
- package/docs/tutorials/13-introspection.md +147 -0
- package/docs/tutorials/14-type-system.md +174 -0
- package/docs/tutorials/15-project-patterns.md +222 -0
- package/docs/tutorials/16-google-features.md +534 -0
- package/docs/tutorials/17-tui-blocks.md +530 -0
- package/docs/tutorials/18-semantic-search.md +334 -0
- package/index.ts +1 -0
- package/luca.console.ts +9 -0
- package/main.py +6 -0
- package/package.json +154 -0
- package/pyproject.toml +7 -0
- package/scripts/animations/chrome-glitch.ts +55 -0
- package/scripts/animations/index.ts +16 -0
- package/scripts/animations/neon-pulse.ts +64 -0
- package/scripts/animations/types.ts +6 -0
- package/scripts/build-web.ts +28 -0
- package/scripts/examples/ask-luca-expert.ts +42 -0
- package/scripts/examples/assistant-questions.ts +12 -0
- package/scripts/examples/excalidraw-expert.ts +75 -0
- package/scripts/examples/expert-chat.ts +0 -0
- package/scripts/examples/file-manager.ts +14 -0
- package/scripts/examples/ideas.ts +12 -0
- package/scripts/examples/interactive-chat.ts +20 -0
- package/scripts/examples/openai-tool-calls.ts +113 -0
- package/scripts/examples/opening-a-web-browser.ts +5 -0
- package/scripts/examples/telegram-bot.ts +79 -0
- package/scripts/examples/telegram-ink-ui.ts +302 -0
- package/scripts/examples/using-assistant-with-mcp.ts +560 -0
- package/scripts/examples/using-claude-code.ts +10 -0
- package/scripts/examples/using-contentdb.ts +35 -0
- package/scripts/examples/using-conversations.ts +35 -0
- package/scripts/examples/using-disk-cache.ts +10 -0
- package/scripts/examples/using-docker-shell.ts +75 -0
- package/scripts/examples/using-elevenlabs.ts +25 -0
- package/scripts/examples/using-google-calendar.ts +57 -0
- package/scripts/examples/using-google-docs.ts +74 -0
- package/scripts/examples/using-google-drive.ts +74 -0
- package/scripts/examples/using-google-sheets.ts +89 -0
- package/scripts/examples/using-nlp.ts +55 -0
- package/scripts/examples/using-ollama.ts +10 -0
- package/scripts/examples/using-openai-codex.ts +23 -0
- package/scripts/examples/using-postgres.ts +55 -0
- package/scripts/examples/using-runpod.ts +32 -0
- package/scripts/examples/using-tts.ts +40 -0
- package/scripts/examples/vm-loading-esm-modules.ts +16 -0
- package/scripts/scaffold.ts +391 -0
- package/scripts/scratch.ts +15 -0
- package/scripts/test-command-listener.ts +123 -0
- package/scripts/test-window-manager-lifecycle.ts +86 -0
- package/scripts/test-window-manager.ts +43 -0
- package/scripts/update-introspection-data.ts +58 -0
- package/src/agi/README.md +14 -0
- package/src/agi/container.server.ts +114 -0
- package/src/agi/endpoints/ask.ts +60 -0
- package/src/agi/endpoints/conversations/[id].ts +45 -0
- package/src/agi/endpoints/conversations.ts +31 -0
- package/src/agi/endpoints/experts.ts +37 -0
- package/src/agi/features/assistant.ts +767 -0
- package/src/agi/features/assistants-manager.ts +260 -0
- package/src/agi/features/claude-code.ts +1111 -0
- package/src/agi/features/conversation-history.ts +497 -0
- package/src/agi/features/conversation.ts +799 -0
- package/src/agi/features/openai-codex.ts +631 -0
- package/src/agi/features/openapi.ts +438 -0
- package/src/agi/features/skills-library.ts +425 -0
- package/src/agi/index.ts +6 -0
- package/src/agi/lib/token-counter.ts +122 -0
- package/src/browser.ts +25 -0
- package/src/bus.ts +100 -0
- package/src/cli/cli.ts +70 -0
- package/src/client.ts +461 -0
- package/src/clients/civitai/index.ts +541 -0
- package/src/clients/client-template.ts +41 -0
- package/src/clients/comfyui/index.ts +597 -0
- package/src/clients/elevenlabs/index.ts +291 -0
- package/src/clients/openai/index.ts +451 -0
- package/src/clients/supabase/index.ts +366 -0
- package/src/command.ts +164 -0
- package/src/commands/chat.ts +182 -0
- package/src/commands/console.ts +192 -0
- package/src/commands/describe.ts +433 -0
- package/src/commands/eval.ts +116 -0
- package/src/commands/help.ts +214 -0
- package/src/commands/index.ts +14 -0
- package/src/commands/mcp.ts +64 -0
- package/src/commands/prompt.ts +807 -0
- package/src/commands/run.ts +257 -0
- package/src/commands/sandbox-mcp.ts +439 -0
- package/src/commands/scaffold.ts +79 -0
- package/src/commands/serve.ts +172 -0
- package/src/container.ts +781 -0
- package/src/endpoint.ts +340 -0
- package/src/feature.ts +75 -0
- package/src/hash-object.ts +97 -0
- package/src/helper.ts +543 -0
- package/src/introspection/generated.agi.ts +23388 -0
- package/src/introspection/generated.node.ts +18899 -0
- package/src/introspection/generated.web.ts +2021 -0
- package/src/introspection/index.ts +256 -0
- package/src/introspection/scan.ts +912 -0
- package/src/node/container.ts +354 -0
- package/src/node/feature.ts +13 -0
- package/src/node/features/container-link.ts +558 -0
- package/src/node/features/content-db.ts +475 -0
- package/src/node/features/disk-cache.ts +382 -0
- package/src/node/features/dns.ts +655 -0
- package/src/node/features/docker.ts +912 -0
- package/src/node/features/downloader.ts +92 -0
- package/src/node/features/esbuild.ts +68 -0
- package/src/node/features/file-manager.ts +357 -0
- package/src/node/features/fs.ts +534 -0
- package/src/node/features/git.ts +492 -0
- package/src/node/features/google-auth.ts +502 -0
- package/src/node/features/google-calendar.ts +300 -0
- package/src/node/features/google-docs.ts +404 -0
- package/src/node/features/google-drive.ts +339 -0
- package/src/node/features/google-sheets.ts +279 -0
- package/src/node/features/grep.ts +406 -0
- package/src/node/features/helpers.ts +374 -0
- package/src/node/features/ink.ts +490 -0
- package/src/node/features/ipc-socket.ts +459 -0
- package/src/node/features/json-tree.ts +188 -0
- package/src/node/features/launcher-app-command-listener.ts +388 -0
- package/src/node/features/networking.ts +925 -0
- package/src/node/features/nlp.ts +211 -0
- package/src/node/features/opener.ts +166 -0
- package/src/node/features/os.ts +157 -0
- package/src/node/features/package-finder.ts +539 -0
- package/src/node/features/port-exposer.ts +342 -0
- package/src/node/features/postgres.ts +273 -0
- package/src/node/features/proc.ts +502 -0
- package/src/node/features/process-manager.ts +542 -0
- package/src/node/features/python.ts +444 -0
- package/src/node/features/repl.ts +194 -0
- package/src/node/features/runpod.ts +802 -0
- package/src/node/features/secure-shell.ts +248 -0
- package/src/node/features/semantic-search.ts +924 -0
- package/src/node/features/sqlite.ts +289 -0
- package/src/node/features/telegram.ts +342 -0
- package/src/node/features/tts.ts +184 -0
- package/src/node/features/ui.ts +857 -0
- package/src/node/features/vault.ts +164 -0
- package/src/node/features/vm.ts +312 -0
- package/src/node/features/window-manager.ts +804 -0
- package/src/node/features/yaml-tree.ts +149 -0
- package/src/node/features/yaml.ts +132 -0
- package/src/node.ts +70 -0
- package/src/react/index.ts +175 -0
- package/src/registry.ts +199 -0
- package/src/scaffolds/generated.ts +1613 -0
- package/src/scaffolds/template.ts +37 -0
- package/src/schemas/base.ts +255 -0
- package/src/server.ts +135 -0
- package/src/servers/express.ts +209 -0
- package/src/servers/mcp.ts +805 -0
- package/src/servers/socket.ts +120 -0
- package/src/state.ts +101 -0
- package/src/web/clients/socket.ts +82 -0
- package/src/web/container.ts +74 -0
- package/src/web/extension.ts +30 -0
- package/src/web/feature.ts +12 -0
- package/src/web/features/asset-loader.ts +64 -0
- package/src/web/features/container-link.ts +385 -0
- package/src/web/features/esbuild.ts +79 -0
- package/src/web/features/helpers.ts +267 -0
- package/src/web/features/network.ts +61 -0
- package/src/web/features/speech.ts +87 -0
- package/src/web/features/vault.ts +189 -0
- package/src/web/features/vm.ts +78 -0
- package/src/web/features/voice-recognition.ts +129 -0
- package/src/web/shims/isomorphic-vm.ts +149 -0
- package/test/bus.test.ts +134 -0
- package/test/clients-servers.test.ts +216 -0
- package/test/container-link.test.ts +274 -0
- package/test/features.test.ts +160 -0
- package/test/integration.test.ts +787 -0
- package/test/node-container.test.ts +121 -0
- package/test/rate-limit.test.ts +272 -0
- package/test/semantic-search.test.ts +550 -0
- package/test/state.test.ts +121 -0
- package/test-integration/assistant.test.ts +138 -0
- package/test-integration/assistants-manager.test.ts +123 -0
- package/test-integration/claude-code.test.ts +98 -0
- package/test-integration/conversation-history.test.ts +205 -0
- package/test-integration/conversation.test.ts +137 -0
- package/test-integration/elevenlabs.test.ts +55 -0
- package/test-integration/google-services.test.ts +80 -0
- package/test-integration/helpers.ts +89 -0
- package/test-integration/openai-codex.test.ts +93 -0
- package/test-integration/runpod.test.ts +58 -0
- package/test-integration/server-endpoints.test.ts +97 -0
- package/test-integration/skills-library.test.ts +157 -0
- package/test-integration/telegram.test.ts +46 -0
- package/tsconfig.json +58 -0
- package/uv.lock +8 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import fs from 'fs/promises'
|
|
5
|
+
import yaml from 'js-yaml'
|
|
6
|
+
import { kebabCase } from 'lodash-es'
|
|
7
|
+
import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
|
|
8
|
+
import type { Container } from '@soederpop/luca/container'
|
|
9
|
+
import { type AvailableFeatures, features, Feature } from '@soederpop/luca/feature'
|
|
10
|
+
import { Collection, defineModel } from 'contentbase'
|
|
11
|
+
import type { ConversationTool } from './conversation'
|
|
12
|
+
|
|
13
|
+
declare module '@soederpop/luca/feature' {
|
|
14
|
+
interface AvailableFeatures {
|
|
15
|
+
skillsLibrary: typeof SkillsLibrary
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SkillEntry {
|
|
20
|
+
/** Skill name from frontmatter */
|
|
21
|
+
name: string
|
|
22
|
+
/** Skill description from frontmatter */
|
|
23
|
+
description: string
|
|
24
|
+
/** Markdown body (instructions) */
|
|
25
|
+
body: string
|
|
26
|
+
/** Raw content including frontmatter */
|
|
27
|
+
raw: string
|
|
28
|
+
/** Which collection this came from */
|
|
29
|
+
source: 'project' | 'user'
|
|
30
|
+
/** Directory/path id within its collection */
|
|
31
|
+
pathId: string
|
|
32
|
+
/** All frontmatter metadata */
|
|
33
|
+
meta: Record<string, unknown>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SkillMetaSchema = z.object({
|
|
37
|
+
name: z.string().describe('Unique name identifier for the skill'),
|
|
38
|
+
description: z.string().describe('What the skill does and when to use it'),
|
|
39
|
+
version: z.string().optional().describe('Skill version'),
|
|
40
|
+
tags: z.array(z.string()).optional().describe('Tags for categorization'),
|
|
41
|
+
author: z.string().optional().describe('Skill author'),
|
|
42
|
+
license: z.string().optional().describe('Skill license'),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const SkillModel = defineModel('Skill', {
|
|
46
|
+
meta: SkillMetaSchema as any,
|
|
47
|
+
match: (doc: { id: string; meta: Record<string, unknown> }) =>
|
|
48
|
+
doc.id.endsWith('/SKILL') || doc.id === 'SKILL',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
export const SkillsLibraryStateSchema = FeatureStateSchema.extend({
|
|
52
|
+
loaded: z.boolean().describe('Whether both collections have been loaded'),
|
|
53
|
+
projectSkillCount: z.number().describe('Number of skills in the project collection'),
|
|
54
|
+
userSkillCount: z.number().describe('Number of skills in the user-level collection'),
|
|
55
|
+
totalSkillCount: z.number().describe('Total number of skills across both collections'),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
export const SkillsLibraryOptionsSchema = FeatureOptionsSchema.extend({
|
|
59
|
+
/** Path to project-level skills directory. Defaults to .claude/skills relative to container cwd. */
|
|
60
|
+
projectSkillsPath: z.string().optional().describe('Path to project-level skills directory'),
|
|
61
|
+
/** Path to user-level skills directory. Defaults to ~/.luca/skills. */
|
|
62
|
+
userSkillsPath: z.string().optional().describe('Path to user-level global skills directory'),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
export type SkillsLibraryState = z.infer<typeof SkillsLibraryStateSchema>
|
|
66
|
+
export type SkillsLibraryOptions = z.infer<typeof SkillsLibraryOptionsSchema>
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Manages two contentbase collections of skills following the Claude Code SKILL.md format.
|
|
70
|
+
* Project-level skills live in .claude/skills/ and user-level skills live in ~/.luca/skills/.
|
|
71
|
+
* Skills can be discovered, searched, created, updated, and removed at runtime.
|
|
72
|
+
*
|
|
73
|
+
* @extends Feature
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```typescript
|
|
77
|
+
* const skills = container.feature('skillsLibrary')
|
|
78
|
+
* await skills.load()
|
|
79
|
+
*
|
|
80
|
+
* // List and search
|
|
81
|
+
* const allSkills = skills.list()
|
|
82
|
+
* const matches = skills.search('code review')
|
|
83
|
+
*
|
|
84
|
+
* // Create a new skill
|
|
85
|
+
* await skills.create({
|
|
86
|
+
* name: 'summarize',
|
|
87
|
+
* description: 'Summarize a document',
|
|
88
|
+
* body: '## Instructions\nRead the document and produce a concise summary.'
|
|
89
|
+
* })
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export class SkillsLibrary extends Feature<SkillsLibraryState, SkillsLibraryOptions> {
|
|
93
|
+
static override stateSchema = SkillsLibraryStateSchema
|
|
94
|
+
static override optionsSchema = SkillsLibraryOptionsSchema
|
|
95
|
+
static override shortcut = 'features.skillsLibrary' as const
|
|
96
|
+
|
|
97
|
+
private _projectCollection?: Collection
|
|
98
|
+
private _userCollection?: Collection
|
|
99
|
+
|
|
100
|
+
static attach(container: Container<AvailableFeatures, any>) {
|
|
101
|
+
features.register('skillsLibrary', SkillsLibrary)
|
|
102
|
+
return container
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @returns Default state with loaded=false and zero skill counts across both collections. */
|
|
106
|
+
override get initialState(): SkillsLibraryState {
|
|
107
|
+
return {
|
|
108
|
+
...super.initialState,
|
|
109
|
+
loaded: false,
|
|
110
|
+
projectSkillCount: 0,
|
|
111
|
+
userSkillCount: 0,
|
|
112
|
+
totalSkillCount: 0,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Returns the project-level contentbase Collection, lazily initialized. */
|
|
117
|
+
get projectCollection(): Collection {
|
|
118
|
+
if (this._projectCollection) return this._projectCollection
|
|
119
|
+
const rootPath =
|
|
120
|
+
this.options.projectSkillsPath ||
|
|
121
|
+
(this.container as any).paths.resolve('.claude', 'skills')
|
|
122
|
+
this._projectCollection = new Collection({ rootPath, extensions: ['md'] })
|
|
123
|
+
this._projectCollection.register(SkillModel)
|
|
124
|
+
return this._projectCollection
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Returns the user-level contentbase Collection, lazily initialized. */
|
|
128
|
+
get userCollection(): Collection {
|
|
129
|
+
if (this._userCollection) return this._userCollection
|
|
130
|
+
const rootPath =
|
|
131
|
+
this.options.userSkillsPath || path.resolve(os.homedir(), '.luca', 'skills')
|
|
132
|
+
this._userCollection = new Collection({ rootPath, extensions: ['md'] })
|
|
133
|
+
this._userCollection.register(SkillModel)
|
|
134
|
+
return this._userCollection
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether the skills library has been loaded. */
|
|
138
|
+
get isLoaded(): boolean {
|
|
139
|
+
return !!this.state.get('loaded')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Array of all skill names across both collections. */
|
|
143
|
+
get skillNames(): string[] {
|
|
144
|
+
return this.list().map((s) => s.name)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Loads both project and user skill collections from disk.
|
|
149
|
+
* Gracefully handles missing directories.
|
|
150
|
+
*
|
|
151
|
+
* @returns {Promise<SkillsLibrary>} This instance
|
|
152
|
+
*/
|
|
153
|
+
async load(): Promise<SkillsLibrary> {
|
|
154
|
+
if (this.isLoaded) return this
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
await this.projectCollection.load()
|
|
158
|
+
} catch {
|
|
159
|
+
// Directory doesn't exist yet - zero project skills
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await this.userCollection.load()
|
|
164
|
+
} catch {
|
|
165
|
+
// Directory doesn't exist yet - zero user skills
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.updateCounts()
|
|
169
|
+
this.state.set('loaded', true)
|
|
170
|
+
this.emit('loaded')
|
|
171
|
+
return this
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Lists all skills from both collections. Project skills come first.
|
|
176
|
+
*
|
|
177
|
+
* @returns {SkillEntry[]} All available skills
|
|
178
|
+
*/
|
|
179
|
+
list(): SkillEntry[] {
|
|
180
|
+
const projectSkills = this.listFromCollection(this.projectCollection, 'project')
|
|
181
|
+
const userSkills = this.listFromCollection(this.userCollection, 'user')
|
|
182
|
+
return [...projectSkills, ...userSkills]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Finds a skill by name. Project skills take precedence over user skills.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} name - The skill name to find (case-insensitive)
|
|
189
|
+
* @returns {SkillEntry | undefined} The skill entry, or undefined if not found
|
|
190
|
+
*/
|
|
191
|
+
find(name: string): SkillEntry | undefined {
|
|
192
|
+
const lower = name.toLowerCase()
|
|
193
|
+
return this.list().find((s) => s.name.toLowerCase() === lower)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Searches skills by substring match against name and description.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} query - The search query
|
|
200
|
+
* @returns {SkillEntry[]} Matching skills
|
|
201
|
+
*/
|
|
202
|
+
search(query: string): SkillEntry[] {
|
|
203
|
+
const q = query.toLowerCase()
|
|
204
|
+
return this.list().filter(
|
|
205
|
+
(s) =>
|
|
206
|
+
s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Gets a skill by name. Alias for find().
|
|
212
|
+
*
|
|
213
|
+
* @param {string} name - The skill name
|
|
214
|
+
* @returns {SkillEntry | undefined} The skill entry
|
|
215
|
+
*/
|
|
216
|
+
getSkill(name: string): SkillEntry | undefined {
|
|
217
|
+
return this.find(name)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Creates a new SKILL.md file in the specified collection.
|
|
222
|
+
* Maintains the directory-per-skill structure (skill-name/SKILL.md).
|
|
223
|
+
*
|
|
224
|
+
* @param {object} skill - The skill to create
|
|
225
|
+
* @param {'project' | 'user'} target - Which collection to write to (default: 'project')
|
|
226
|
+
* @returns {Promise<SkillEntry>} The created skill entry
|
|
227
|
+
*/
|
|
228
|
+
async create(
|
|
229
|
+
skill: {
|
|
230
|
+
name: string
|
|
231
|
+
description: string
|
|
232
|
+
body: string
|
|
233
|
+
meta?: Record<string, unknown>
|
|
234
|
+
},
|
|
235
|
+
target: 'project' | 'user' = 'project'
|
|
236
|
+
): Promise<SkillEntry> {
|
|
237
|
+
const collection =
|
|
238
|
+
target === 'project' ? this.projectCollection : this.userCollection
|
|
239
|
+
|
|
240
|
+
const frontmatter = (yaml.dump({
|
|
241
|
+
name: skill.name,
|
|
242
|
+
description: skill.description,
|
|
243
|
+
...skill.meta,
|
|
244
|
+
}) as string).trim()
|
|
245
|
+
|
|
246
|
+
const content = `---\n${frontmatter}\n---\n\n${skill.body}`
|
|
247
|
+
const dirName = kebabCase(skill.name)
|
|
248
|
+
const pathId = `${dirName}/SKILL`
|
|
249
|
+
|
|
250
|
+
await fs.mkdir((collection as any).rootPath, { recursive: true })
|
|
251
|
+
await collection.saveItem(pathId, { content, extension: '.md' })
|
|
252
|
+
await collection.load({ refresh: true })
|
|
253
|
+
this.updateCounts()
|
|
254
|
+
|
|
255
|
+
const entry: SkillEntry = {
|
|
256
|
+
name: skill.name,
|
|
257
|
+
description: skill.description,
|
|
258
|
+
body: skill.body,
|
|
259
|
+
raw: content,
|
|
260
|
+
source: target,
|
|
261
|
+
pathId,
|
|
262
|
+
meta: { name: skill.name, description: skill.description, ...skill.meta },
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
this.emit('skillCreated', entry)
|
|
266
|
+
return entry
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Updates an existing skill's content or metadata.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} name - The skill name to update
|
|
273
|
+
* @param {object} updates - Fields to update
|
|
274
|
+
* @returns {Promise<SkillEntry>} The updated skill entry
|
|
275
|
+
*/
|
|
276
|
+
async update(
|
|
277
|
+
name: string,
|
|
278
|
+
updates: {
|
|
279
|
+
description?: string
|
|
280
|
+
body?: string
|
|
281
|
+
meta?: Record<string, unknown>
|
|
282
|
+
}
|
|
283
|
+
): Promise<SkillEntry> {
|
|
284
|
+
const existing = this.find(name)
|
|
285
|
+
if (!existing) throw new Error(`Skill "${name}" not found`)
|
|
286
|
+
|
|
287
|
+
const collection =
|
|
288
|
+
existing.source === 'project' ? this.projectCollection : this.userCollection
|
|
289
|
+
|
|
290
|
+
const newMeta = { ...existing.meta, ...updates.meta }
|
|
291
|
+
if (updates.description) newMeta.description = updates.description
|
|
292
|
+
|
|
293
|
+
const frontmatter = (yaml.dump(newMeta) as string).trim()
|
|
294
|
+
const body = updates.body ?? existing.body
|
|
295
|
+
const content = `---\n${frontmatter}\n---\n\n${body}`
|
|
296
|
+
|
|
297
|
+
await collection.saveItem(existing.pathId, { content, extension: '.md' })
|
|
298
|
+
await collection.load({ refresh: true })
|
|
299
|
+
this.updateCounts()
|
|
300
|
+
|
|
301
|
+
const entry: SkillEntry = {
|
|
302
|
+
name: existing.name,
|
|
303
|
+
description: updates.description ?? existing.description,
|
|
304
|
+
body,
|
|
305
|
+
raw: content,
|
|
306
|
+
source: existing.source,
|
|
307
|
+
pathId: existing.pathId,
|
|
308
|
+
meta: newMeta,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
this.emit('skillUpdated', entry)
|
|
312
|
+
return entry
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Removes a skill by name, deleting its SKILL.md and cleaning up the directory.
|
|
317
|
+
*
|
|
318
|
+
* @param {string} name - The skill name to remove
|
|
319
|
+
* @returns {Promise<boolean>} Whether the skill was found and removed
|
|
320
|
+
*/
|
|
321
|
+
async remove(name: string): Promise<boolean> {
|
|
322
|
+
const existing = this.find(name)
|
|
323
|
+
if (!existing) return false
|
|
324
|
+
|
|
325
|
+
const collection =
|
|
326
|
+
existing.source === 'project' ? this.projectCollection : this.userCollection
|
|
327
|
+
|
|
328
|
+
await collection.deleteItem(existing.pathId)
|
|
329
|
+
|
|
330
|
+
const skillDir = path.resolve(
|
|
331
|
+
(collection as any).rootPath,
|
|
332
|
+
existing.pathId.split('/')[0]!
|
|
333
|
+
)
|
|
334
|
+
try {
|
|
335
|
+
await fs.rm(skillDir, { recursive: true })
|
|
336
|
+
} catch {
|
|
337
|
+
// directory might have other files or already be gone
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
await collection.load({ refresh: true })
|
|
341
|
+
this.updateCounts()
|
|
342
|
+
this.emit('skillRemoved', existing.name)
|
|
343
|
+
return true
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Converts all skills into ConversationTool format for use with Conversation.
|
|
348
|
+
* Each skill becomes a tool that returns its instruction body when invoked.
|
|
349
|
+
*
|
|
350
|
+
* @returns {Record<string, ConversationTool>} Tools keyed by sanitized skill name
|
|
351
|
+
*/
|
|
352
|
+
toConversationTools(): Record<string, ConversationTool> {
|
|
353
|
+
const tools: Record<string, ConversationTool> = {}
|
|
354
|
+
|
|
355
|
+
for (const skill of this.list()) {
|
|
356
|
+
const toolName = `skill_${skill.name.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
|
357
|
+
tools[toolName] = {
|
|
358
|
+
handler: async () => skill.body,
|
|
359
|
+
description: skill.description,
|
|
360
|
+
parameters: {
|
|
361
|
+
type: 'object',
|
|
362
|
+
properties: {},
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return tools
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Generates a markdown block listing all available skills with names and descriptions.
|
|
372
|
+
* Suitable for injecting into a system prompt.
|
|
373
|
+
*
|
|
374
|
+
* @returns {string} Markdown listing, or empty string if no skills
|
|
375
|
+
*/
|
|
376
|
+
toSystemPromptBlock(): string {
|
|
377
|
+
const skills = this.list()
|
|
378
|
+
if (skills.length === 0) return ''
|
|
379
|
+
|
|
380
|
+
const lines = skills.map((s) => `- **${s.name}**: ${s.description}`)
|
|
381
|
+
return `## Available Skills\n\n${lines.join('\n')}`
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// --- Private ---
|
|
385
|
+
|
|
386
|
+
private listFromCollection(
|
|
387
|
+
collection: Collection,
|
|
388
|
+
source: 'project' | 'user'
|
|
389
|
+
): SkillEntry[] {
|
|
390
|
+
if (!(collection as any).loaded) return []
|
|
391
|
+
|
|
392
|
+
const entries: SkillEntry[] = []
|
|
393
|
+
for (const pathId of collection.available) {
|
|
394
|
+
if (!pathId.endsWith('/SKILL') && pathId !== 'SKILL') continue
|
|
395
|
+
|
|
396
|
+
const item = collection.items.get(pathId)!
|
|
397
|
+
entries.push({
|
|
398
|
+
name: (item.meta.name as string) || pathId.split('/')[0] || pathId,
|
|
399
|
+
description: (item.meta.description as string) || '',
|
|
400
|
+
body: item.content,
|
|
401
|
+
raw: item.raw,
|
|
402
|
+
source,
|
|
403
|
+
pathId,
|
|
404
|
+
meta: item.meta,
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return entries
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private updateCounts(): void {
|
|
412
|
+
const projectCount = this.listFromCollection(
|
|
413
|
+
this.projectCollection,
|
|
414
|
+
'project'
|
|
415
|
+
).length
|
|
416
|
+
const userCount = this.listFromCollection(this.userCollection, 'user').length
|
|
417
|
+
this.state.setState({
|
|
418
|
+
projectSkillCount: projectCount,
|
|
419
|
+
userSkillCount: userCount,
|
|
420
|
+
totalSkillCount: projectCount + userCount,
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export default features.register('skillsLibrary', SkillsLibrary)
|
package/src/agi/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { encodingForModel, getEncoding } from 'js-tiktoken'
|
|
2
|
+
import type { Tiktoken } from 'js-tiktoken'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Known model context window sizes. Prefix-matched for dated variants
|
|
6
|
+
* (e.g. "gpt-4o-2024-08-06" matches "gpt-4o").
|
|
7
|
+
*/
|
|
8
|
+
const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
|
9
|
+
'gpt-4.1': 1_000_000,
|
|
10
|
+
'gpt-4.1-mini': 1_000_000,
|
|
11
|
+
'gpt-4.1-nano': 1_000_000,
|
|
12
|
+
'gpt-4o': 128_000,
|
|
13
|
+
'gpt-4o-mini': 128_000,
|
|
14
|
+
'gpt-4-turbo': 128_000,
|
|
15
|
+
'gpt-4': 8_192,
|
|
16
|
+
'gpt-3.5-turbo': 16_385,
|
|
17
|
+
'o1': 200_000,
|
|
18
|
+
'o1-mini': 128_000,
|
|
19
|
+
'o1-pro': 200_000,
|
|
20
|
+
'o3': 200_000,
|
|
21
|
+
'o3-mini': 200_000,
|
|
22
|
+
'o4-mini': 200_000,
|
|
23
|
+
'gpt-5': 1_000_000,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_CONTEXT_WINDOW = 128_000
|
|
27
|
+
|
|
28
|
+
const encoderCache = new Map<string, Tiktoken>()
|
|
29
|
+
|
|
30
|
+
/** Look up the context window size for a model name (exact then prefix match). */
|
|
31
|
+
export function getContextWindow(model: string): number {
|
|
32
|
+
if (MODEL_CONTEXT_WINDOWS[model]) return MODEL_CONTEXT_WINDOWS[model]
|
|
33
|
+
|
|
34
|
+
// Prefix match — longest prefix wins (e.g. "gpt-4o-mini" before "gpt-4o")
|
|
35
|
+
let best = ''
|
|
36
|
+
for (const key of Object.keys(MODEL_CONTEXT_WINDOWS)) {
|
|
37
|
+
if (model.startsWith(key) && key.length > best.length) {
|
|
38
|
+
best = key
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return best ? MODEL_CONTEXT_WINDOWS[best] : DEFAULT_CONTEXT_WINDOW
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Get a cached tiktoken encoder for a model (falls back to o200k_base). */
|
|
46
|
+
export function getEncoder(model: string): Tiktoken {
|
|
47
|
+
if (encoderCache.has(model)) return encoderCache.get(model)!
|
|
48
|
+
|
|
49
|
+
let enc: Tiktoken
|
|
50
|
+
try {
|
|
51
|
+
enc = encodingForModel(model as any)
|
|
52
|
+
} catch {
|
|
53
|
+
enc = getEncoding('o200k_base')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
encoderCache.set(model, enc)
|
|
57
|
+
return enc
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Count tokens in a plain string. */
|
|
61
|
+
export function countTokens(text: string, model: string): number {
|
|
62
|
+
return getEncoder(model).encode(text).length
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Estimate the total input token count for a messages array.
|
|
67
|
+
* Follows the OpenAI token counting recipe with per-message overhead.
|
|
68
|
+
*/
|
|
69
|
+
export function countMessageTokens(messages: any[], model: string): number {
|
|
70
|
+
const enc = getEncoder(model)
|
|
71
|
+
const TOKENS_PER_MESSAGE = 3 // <|start|>role\ncontent<|end|>
|
|
72
|
+
const REPLY_PRIMING = 3
|
|
73
|
+
|
|
74
|
+
let total = 0
|
|
75
|
+
|
|
76
|
+
for (const msg of messages) {
|
|
77
|
+
total += TOKENS_PER_MESSAGE
|
|
78
|
+
|
|
79
|
+
// Role
|
|
80
|
+
if (msg.role) {
|
|
81
|
+
total += enc.encode(msg.role).length
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Content
|
|
85
|
+
if (typeof msg.content === 'string') {
|
|
86
|
+
total += enc.encode(msg.content).length
|
|
87
|
+
} else if (Array.isArray(msg.content)) {
|
|
88
|
+
for (const part of msg.content) {
|
|
89
|
+
if (part.type === 'text' && part.text) {
|
|
90
|
+
total += enc.encode(part.text).length
|
|
91
|
+
} else if (part.type === 'image_url') {
|
|
92
|
+
// Rough image token estimates
|
|
93
|
+
const detail = part.image_url?.detail || 'auto'
|
|
94
|
+
total += detail === 'low' ? 85 : 170
|
|
95
|
+
} else if (part.type === 'input_audio' || part.type === 'input_file') {
|
|
96
|
+
total += 50 // rough placeholder for non-text parts
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Tool calls on assistant messages
|
|
102
|
+
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
|
103
|
+
for (const tc of msg.tool_calls) {
|
|
104
|
+
total += 3 // tool call overhead
|
|
105
|
+
if (tc.function?.name) {
|
|
106
|
+
total += enc.encode(tc.function.name).length
|
|
107
|
+
}
|
|
108
|
+
if (tc.function?.arguments) {
|
|
109
|
+
total += enc.encode(tc.function.arguments).length
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Name field (used on some message types)
|
|
115
|
+
if (msg.name) {
|
|
116
|
+
total += enc.encode(msg.name).length + 1
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
total += REPLY_PRIMING
|
|
121
|
+
return total
|
|
122
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export * from './web/container.js'
|
|
2
|
+
import { WebContainer } from './web/container.js'
|
|
3
|
+
import './introspection/generated.web.js'
|
|
4
|
+
|
|
5
|
+
const container = new WebContainer({})
|
|
6
|
+
|
|
7
|
+
if (typeof window !== 'undefined') {
|
|
8
|
+
;(window as any).luca = container
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default container
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns the singleton container instance.
|
|
15
|
+
* LLMs love to hallucinate this function — so we provide it, but warn.
|
|
16
|
+
* If you need a separate container, use `container.subcontainer()`.
|
|
17
|
+
*/
|
|
18
|
+
export function createContainer() {
|
|
19
|
+
console.warn(
|
|
20
|
+
'[luca] createContainer() is unnecessary — import the default export instead.\n' +
|
|
21
|
+
' `import container from "@soederpop/luca"`\n' +
|
|
22
|
+
' For a separate instance, use container.subcontainer().'
|
|
23
|
+
)
|
|
24
|
+
return container
|
|
25
|
+
}
|
package/src/bus.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export type EventMap = Record<string, any[]>;
|
|
2
|
+
|
|
3
|
+
type Listener<Args extends any[] = any[]> = (...args: Args) => void;
|
|
4
|
+
|
|
5
|
+
export interface EventStats {
|
|
6
|
+
event: string;
|
|
7
|
+
fireCount: number;
|
|
8
|
+
lastFiredAt: number | null;
|
|
9
|
+
timestamps: number[];
|
|
10
|
+
firesPerMinute: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class Bus<T extends EventMap = EventMap> {
|
|
14
|
+
private events: Map<string, Listener[]>;
|
|
15
|
+
private stats: Map<string, { fireCount: number; lastFiredAt: number | null; timestamps: number[] }>;
|
|
16
|
+
|
|
17
|
+
constructor() {
|
|
18
|
+
this.events = new Map();
|
|
19
|
+
this.stats = new Map();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private recordEmit(event: string): void {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const existing = this.stats.get(event) || { fireCount: 0, lastFiredAt: null, timestamps: [] };
|
|
25
|
+
existing.fireCount++;
|
|
26
|
+
existing.lastFiredAt = now;
|
|
27
|
+
existing.timestamps.push(now);
|
|
28
|
+
this.stats.set(event, existing);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private computeFiresPerMinute(timestamps: number[]): number {
|
|
32
|
+
if (timestamps.length < 2) return 0;
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
const oneMinuteAgo = now - 60_000;
|
|
35
|
+
const recent = timestamps.filter(t => t >= oneMinuteAgo);
|
|
36
|
+
return recent.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getEventStats<E extends string & keyof T>(event: E): EventStats {
|
|
40
|
+
const raw = this.stats.get(event) || { fireCount: 0, lastFiredAt: null, timestamps: [] };
|
|
41
|
+
return {
|
|
42
|
+
event,
|
|
43
|
+
fireCount: raw.fireCount,
|
|
44
|
+
lastFiredAt: raw.lastFiredAt,
|
|
45
|
+
timestamps: raw.timestamps,
|
|
46
|
+
firesPerMinute: this.computeFiresPerMinute(raw.timestamps),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get history(): EventStats[] {
|
|
51
|
+
return Array.from(this.stats.keys()).map(event => this.getEventStats(event as any));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get firedEvents(): string[] {
|
|
55
|
+
return Array.from(this.stats.keys());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async waitFor<E extends string & keyof T>(event: E): Promise<T[E]> {
|
|
59
|
+
return new Promise(resolve => {
|
|
60
|
+
this.once(event, resolve as any);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
emit<E extends string & keyof T>(event: E, ...args: T[E]): void {
|
|
65
|
+
this.recordEmit(event);
|
|
66
|
+
const listeners = this.events.get(event);
|
|
67
|
+
if (!listeners) return;
|
|
68
|
+
|
|
69
|
+
listeners.forEach(listener => listener(...args));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
on<E extends string & keyof T>(event: E, listener: (...args: T[E]) => void): void {
|
|
73
|
+
const listeners = this.events.get(event) || [];
|
|
74
|
+
listeners.push(listener as Listener);
|
|
75
|
+
this.events.set(event, listeners);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
once<E extends string & keyof T>(event: E, listener: (...args: T[E]) => void): void {
|
|
79
|
+
const onceListener: Listener = (...args: any[]) => {
|
|
80
|
+
(listener as Listener)(...args);
|
|
81
|
+
this.off(event, onceListener as any);
|
|
82
|
+
};
|
|
83
|
+
this.on(event, onceListener as any);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
off<E extends string & keyof T>(event: E, listener?: (...args: T[E]) => void): void {
|
|
87
|
+
const listeners = this.events.get(event);
|
|
88
|
+
if (!listeners) return;
|
|
89
|
+
|
|
90
|
+
if (!listener) {
|
|
91
|
+
this.events.delete(event);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const index = listeners.indexOf(listener as Listener);
|
|
96
|
+
if (index !== -1) {
|
|
97
|
+
listeners.splice(index, 1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|