@nandansai08/personal-ai 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 PersonalAI Contributors
3
+ Copyright (c) 2026 PersonalAI Contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -37,6 +37,7 @@ See [docs/PROVIDERS.md](docs/PROVIDERS.md) for API key links, recommended models
37
37
  - **4 agent profiles** — `assistant`, `coder`, `researcher`, `tutor`; each overrides system prompt, model, and tool priority
38
38
  - **Persistent memory** — SQLite-backed long-term memory; facts, preferences, context, and episodic entries survive restarts
39
39
  - **6 built-in tools** — web search (Serper → Brave → DuckDuckGo), notes, tasks, calculator, file reader, memory save
40
+ - **Plugin system** — drop a folder with `plugin.json` + `index.js` into `plugins/` to add tools and hooks; sandboxed, hot-reloadable, no build step ([docs](docs/PLUGINS.md))
40
41
  - **Streaming output** — token-by-token display with animated spinner and tool call progress indicators
41
42
  - **Hot-reload config** — edit `persona.yaml` or `profiles.yaml` while running; changes apply to the next message
42
43
  - **Observability** — every action emits typed events; daily log files at `~/.personal-ai/logs/`
@@ -404,9 +405,10 @@ Set `PORT=8080` in `.env` to change the port. `autoPort: true` in `.claude/launc
404
405
  | v0.6 | Done | Web UI — Express + WebSocket streaming chat in browser |
405
406
  | v0.7 | Done | Setup wizard, `/cost` tracking, model-pin for all providers, friendly errors, session save |
406
407
  | v0.8 | Done | Security hardening, semantic memory (local embeddings via Ollama), session save/load, npm packaging |
407
- | v0.9 | Planned | MCP supportconnect any MCP server over stdio |
408
- | v1.0 | Planned | Plugin systemweather, GitHub, calendar plugins |
409
- | v1.1 | Planned | VoiceSTT + TTS + wake word |
408
+ | v0.9 | Done | Plugin systemlocal tools + hooks, sandboxed, hot-reload ([docs/PLUGINS.md](docs/PLUGINS.md)) |
409
+ | v1.0 | Planned | MCP supportconnect any MCP server over stdio |
410
+ | v1.1 | Planned | Local document RAG point the existing embeddings at your files |
411
+ | v1.2 | Planned | Voice — STT + TTS + wake word |
410
412
 
411
413
  ---
412
414
 
package/dist/bootstrap.js CHANGED
@@ -12,6 +12,7 @@ import { tasksTool } from './tools/tasks.js';
12
12
  import { calculatorTool } from './tools/calculator.js';
13
13
  import { fileReaderTool } from './tools/file-reader.js';
14
14
  import { createMemoryTool } from './tools/memory-tool.js';
15
+ import { createPluginManager } from './plugins/manager.js';
15
16
  /** Load persona + profiles, initialise provider + tools. Never throws. */
16
17
  export async function createAppCore(configDir) {
17
18
  const persona = loadPersona(path.join(configDir, 'persona.yaml'));
@@ -29,7 +30,19 @@ export async function createAppCore(configDir) {
29
30
  // silently to keyword search if Ollama or the model is unavailable.
30
31
  memory.setEmbedder(createOllamaEmbedder());
31
32
  registerDefaultTools(memory);
32
- return { ok: true, core: { provider, profileManager, memory, persona } };
33
+ // Plugins: local extensions (custom tools + hooks). MCP remains the path
34
+ // for external integrations. A failing plugin never blocks startup.
35
+ const plugins = createPluginManager(toolRegistry, path.join(configDir, '..'));
36
+ const loaded = await plugins.loadAll();
37
+ toolRegistry.setObserver(plugins.hooks);
38
+ memory.setOnStored(m => { void plugins.hooks.memoryStored(m); });
39
+ if (loaded > 0) {
40
+ for (const p of plugins.list().filter(r => r.status === 'healthy')) {
41
+ console.log(`✓ Plugin: ${p.manifest.name} loaded`);
42
+ }
43
+ console.log(`\n${loaded} plugin${loaded === 1 ? '' : 's'} active\n`);
44
+ }
45
+ return { ok: true, core: { provider, profileManager, memory, persona, plugins } };
33
46
  }
34
47
  function registerDefaultTools(memory) {
35
48
  toolRegistry.register(webSearchTool);
@@ -31,6 +31,7 @@ export class AssistantEngine {
31
31
  profileManager;
32
32
  context;
33
33
  modelManager;
34
+ promptHooks;
34
35
  constructor(opts) {
35
36
  this.provider = opts.provider;
36
37
  this.getSystemPrompt = opts.getSystemPrompt;
@@ -39,6 +40,7 @@ export class AssistantEngine {
39
40
  this.profileManager = opts.profileManager;
40
41
  this.context = opts.context;
41
42
  this.modelManager = opts.modelManager;
43
+ this.promptHooks = opts.promptHooks;
42
44
  }
43
45
  async *chat(userMessage, options) {
44
46
  // Explicit memory intent ("remember …") — save the normalized fact and
@@ -77,7 +79,9 @@ export class AssistantEngine {
77
79
  const toolsSection = (this.registry && this.registry.count() > 0 && isGemma)
78
80
  ? this.registry.formatForPrompt()
79
81
  : '';
80
- const systemPrompt = this.getSystemPrompt(memories, toolsSection);
82
+ let systemPrompt = this.getSystemPrompt(memories, toolsSection);
83
+ if (this.promptHooks)
84
+ systemPrompt = await this.promptHooks.beforePrompt(systemPrompt);
81
85
  const nativeTools = (this.registry && !isGemma && this.provider.supportsToolUse)
82
86
  ? this.registry.formatNative()
83
87
  : undefined;
@@ -125,8 +129,12 @@ export class AssistantEngine {
125
129
  if (assistantText) {
126
130
  // Strip XML tool-call blocks that some models output as text instead of function calls
127
131
  const TOOL_XML_RE = /<(memory|web_search|notes|tasks|calculator|file_reader|tool)>[\s\S]*?(<\/\1>|<\/args>)/g;
128
- const cleanText = assistantText.replace(TOOL_XML_RE, '').trim();
129
- this.context?.addAssistant(cleanText || assistantText);
132
+ let cleanText = assistantText.replace(TOOL_XML_RE, '').trim() || assistantText;
133
+ // Plugin afterResponse transforms apply to the stored response;
134
+ // streamed text has already been displayed.
135
+ if (this.promptHooks)
136
+ cleanText = await this.promptHooks.afterResponse(cleanText);
137
+ this.context?.addAssistant(cleanText);
130
138
  this._saveMemoryCandidates(userMessage);
131
139
  }
132
140
  if (doneChunk)
package/dist/index.js CHANGED
@@ -10,7 +10,6 @@ import { watchPersona, watchProfiles } from './persona/loader.js';
10
10
  import { buildSystemPrompt, isGemma3Model } from './persona/system-prompt.js';
11
11
  import { startCLI } from './ui/cli.js';
12
12
  import { needsSetup, runSetupWizard } from './ui/setup.js';
13
- import { createWebServer, getServerUrl } from './ui/web/server.js';
14
13
  import { ModelManager, defaultModelsConfig } from './core/model-manager.js';
15
14
  import { eventBus } from './core/events.js';
16
15
  import { logger } from './core/logger.js';
@@ -31,6 +30,11 @@ function resolveEnvPath() {
31
30
  return path.join(os.homedir(), '.personal-ai', '.env');
32
31
  }
33
32
  async function main() {
33
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
34
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
35
+ console.log(`personal-ai v${pkg.version}`);
36
+ return;
37
+ }
34
38
  const envPath = resolveEnvPath();
35
39
  const { config } = await import('dotenv');
36
40
  config({ path: envPath });
@@ -45,7 +49,7 @@ async function main() {
45
49
  console.error(`Failed to initialize provider: ${boot.error}`);
46
50
  process.exit(1);
47
51
  }
48
- const { provider, profileManager, memory, persona } = boot.core;
52
+ const { provider, profileManager, memory, persona, plugins } = boot.core;
49
53
  const context = new ConversationContext();
50
54
  let currentPersona = persona;
51
55
  // Hot-reload config files
@@ -58,12 +62,15 @@ async function main() {
58
62
  const engine = new AssistantEngine({
59
63
  provider, getSystemPrompt, memory,
60
64
  registry: toolRegistry, profileManager, context, modelManager,
65
+ promptHooks: plugins.hooks,
61
66
  });
67
+ void plugins.hooks.sessionStarted();
62
68
  process.on('SIGINT', () => {
63
69
  eventBus.emit('session_ended', {
64
70
  messageCount: context.messageCount,
65
71
  toolCallCount: context.getToolCallCount(),
66
72
  });
73
+ void plugins.hooks.sessionEnded();
67
74
  memory.close();
68
75
  console.log('\nBye.');
69
76
  process.exit(0);
@@ -72,6 +79,8 @@ async function main() {
72
79
  let webPort;
73
80
  let webToken;
74
81
  const startWebFn = async () => {
82
+ // Lazy import — keeps express/ws/cors out of CLI startup entirely
83
+ const { createWebServer, getServerUrl } = await import('./ui/web/server.js');
75
84
  if (!webServer) {
76
85
  const preferred = parseInt(process.env['PORT'] ?? '3000', 10);
77
86
  const result = await createWebServer({
@@ -80,6 +89,7 @@ async function main() {
80
89
  profileManager,
81
90
  registry: toolRegistry,
82
91
  modelManager,
92
+ plugins,
83
93
  personaPath: path.join(CONFIG, 'persona.yaml'),
84
94
  port: preferred,
85
95
  });
@@ -93,6 +103,6 @@ async function main() {
93
103
  const { createProvider } = await import('./providers/factory.js');
94
104
  return createProvider();
95
105
  };
96
- await startCLI(provider, engine, context, memory, profileManager, toolRegistry, modelManager, startWebFn, reloadProvider, envPath);
106
+ await startCLI(provider, engine, context, memory, profileManager, toolRegistry, modelManager, startWebFn, reloadProvider, envPath, plugins);
97
107
  }
98
108
  main().catch(err => { console.error(err); process.exit(1); });
@@ -33,6 +33,7 @@ export class LongTermMemory {
33
33
  db;
34
34
  vectors;
35
35
  embedder;
36
+ onStored;
36
37
  constructor(dbPath = DB_PATH) {
37
38
  if (!fs.existsSync(DB_DIR))
38
39
  fs.mkdirSync(DB_DIR, { recursive: true });
@@ -48,6 +49,10 @@ export class LongTermMemory {
48
49
  setEmbedder(embedder) {
49
50
  this.embedder = embedder;
50
51
  }
52
+ /** Observer invoked with the full Memory after each new save (plugin memoryStored hook). */
53
+ setOnStored(cb) {
54
+ this.onStored = cb;
55
+ }
51
56
  migrate() {
52
57
  this.db.exec(`
53
58
  CREATE TABLE IF NOT EXISTS memories (
@@ -92,7 +97,9 @@ export class LongTermMemory {
92
97
  eventBus.emit('memory_saved', { type: input.type, importance });
93
98
  logger.debug('memory', `saved [${input.type}] importance=${importance}: ${input.content.slice(0, 60)}`);
94
99
  const row = this.db.prepare('SELECT * FROM memories WHERE id = ?').get(id);
95
- return rowToMemory(row);
100
+ const saved = rowToMemory(row);
101
+ this.onStored?.(saved);
102
+ return saved;
96
103
  }
97
104
  /**
98
105
  * Tokenized LIKE search: splits the query into words and ranks rows by how
@@ -0,0 +1,91 @@
1
+ // MIT License — personal-ai
2
+ // Hook runner: aggregates hooks across loaded plugins and runs them inside
3
+ // the sandbox. A failing hook logs, bumps the plugin's failure count, and
4
+ // the chain continues — plugins never break the assistant.
5
+ import { sandboxed } from './sandbox.js';
6
+ export class HookRunner {
7
+ entries = [];
8
+ register(record) {
9
+ if (record.plugin?.hooks)
10
+ this.entries.push({ record, hooks: record.plugin.hooks });
11
+ }
12
+ unregister(name) {
13
+ this.entries = this.entries.filter(e => e.record.manifest.name !== name);
14
+ }
15
+ count() {
16
+ return this.entries.length;
17
+ }
18
+ /** Chain: each plugin may transform the prompt; failures keep the previous value. */
19
+ async beforePrompt(prompt) {
20
+ let current = prompt;
21
+ for (const { record, hooks } of this.entries) {
22
+ if (!hooks.beforePrompt)
23
+ continue;
24
+ const r = await sandboxed(record.manifest.name, 'beforePrompt', () => hooks.beforePrompt(current));
25
+ if (r.ok && typeof r.value === 'string')
26
+ current = r.value;
27
+ else
28
+ record.failureCount++;
29
+ }
30
+ return current;
31
+ }
32
+ /** Chain: each plugin may transform the response; failures keep the previous value. */
33
+ async afterResponse(response) {
34
+ let current = response;
35
+ for (const { record, hooks } of this.entries) {
36
+ if (!hooks.afterResponse)
37
+ continue;
38
+ const r = await sandboxed(record.manifest.name, 'afterResponse', () => hooks.afterResponse(current));
39
+ if (r.ok && typeof r.value === 'string')
40
+ current = r.value;
41
+ else
42
+ record.failureCount++;
43
+ }
44
+ return current;
45
+ }
46
+ async beforeToolCall(toolName, args) {
47
+ for (const { record, hooks } of this.entries) {
48
+ if (!hooks.beforeToolCall)
49
+ continue;
50
+ const r = await sandboxed(record.manifest.name, 'beforeToolCall', () => hooks.beforeToolCall(toolName, args));
51
+ if (!r.ok)
52
+ record.failureCount++;
53
+ }
54
+ }
55
+ async afterToolCall(toolName, result) {
56
+ for (const { record, hooks } of this.entries) {
57
+ if (!hooks.afterToolCall)
58
+ continue;
59
+ const r = await sandboxed(record.manifest.name, 'afterToolCall', () => hooks.afterToolCall(toolName, result));
60
+ if (!r.ok)
61
+ record.failureCount++;
62
+ }
63
+ }
64
+ async memoryStored(memory) {
65
+ for (const { record, hooks } of this.entries) {
66
+ if (!hooks.memoryStored)
67
+ continue;
68
+ const r = await sandboxed(record.manifest.name, 'memoryStored', () => hooks.memoryStored(memory));
69
+ if (!r.ok)
70
+ record.failureCount++;
71
+ }
72
+ }
73
+ async sessionStarted() {
74
+ for (const { record, hooks } of this.entries) {
75
+ if (!hooks.sessionStarted)
76
+ continue;
77
+ const r = await sandboxed(record.manifest.name, 'sessionStarted', () => hooks.sessionStarted());
78
+ if (!r.ok)
79
+ record.failureCount++;
80
+ }
81
+ }
82
+ async sessionEnded() {
83
+ for (const { record, hooks } of this.entries) {
84
+ if (!hooks.sessionEnded)
85
+ continue;
86
+ const r = await sandboxed(record.manifest.name, 'sessionEnded', () => hooks.sessionEnded());
87
+ if (!r.ok)
88
+ record.failureCount++;
89
+ }
90
+ }
91
+ }
@@ -1,3 +1,104 @@
1
1
  // MIT License — personal-ai
2
- // Stub implemented in M8
3
- export {};
2
+ // Plugin discovery: scan plugin directories, read + validate manifests,
3
+ // import entry modules. Invalid plugins are rejected with a reason and
4
+ // never crash startup.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import { pathToFileURL } from 'node:url';
9
+ const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
10
+ const VERSION_RE = /^\d+\.\d+\.\d+/;
11
+ /** Validate a parsed plugin.json object. */
12
+ export function validateManifest(raw) {
13
+ if (typeof raw !== 'object' || raw === null)
14
+ return { ok: false, error: 'manifest is not an object' };
15
+ const m = raw;
16
+ if (typeof m['name'] !== 'string' || !NAME_RE.test(m['name'])) {
17
+ return { ok: false, error: `invalid name (kebab-case required): ${String(m['name'])}` };
18
+ }
19
+ if (typeof m['version'] !== 'string' || !VERSION_RE.test(m['version'])) {
20
+ return { ok: false, error: `invalid version (semver required): ${String(m['version'])}` };
21
+ }
22
+ if (typeof m['description'] !== 'string' || m['description'].length === 0) {
23
+ return { ok: false, error: 'description required' };
24
+ }
25
+ if (typeof m['main'] !== 'string' || !/\.(js|mjs|cjs)$/.test(m['main'])) {
26
+ return { ok: false, error: `main must point to a .js module: ${String(m['main'])}` };
27
+ }
28
+ if (m['main'].includes('..')) {
29
+ return { ok: false, error: 'main must not escape the plugin directory' };
30
+ }
31
+ if (typeof m['enabled'] !== 'boolean') {
32
+ return { ok: false, error: 'enabled must be a boolean' };
33
+ }
34
+ return {
35
+ ok: true,
36
+ manifest: {
37
+ name: m['name'], version: m['version'], description: m['description'],
38
+ main: m['main'], enabled: m['enabled'],
39
+ },
40
+ };
41
+ }
42
+ /** Directories scanned for plugins, in order. Override with PLUGINS_DIR. */
43
+ export function pluginDirs(packageRoot) {
44
+ const override = process.env['PLUGINS_DIR'];
45
+ if (override)
46
+ return [path.resolve(override)];
47
+ return [
48
+ path.join(packageRoot, 'plugins'),
49
+ path.join(os.homedir(), '.personal-ai', 'plugins'),
50
+ ];
51
+ }
52
+ /** Scan plugin directories for subdirs containing plugin.json. */
53
+ export function discoverPlugins(roots) {
54
+ const found = [];
55
+ const seen = new Set();
56
+ for (const root of roots) {
57
+ if (!fs.existsSync(root))
58
+ continue;
59
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
60
+ if (!entry.isDirectory())
61
+ continue;
62
+ const dir = path.join(root, entry.name);
63
+ const manifestPath = path.join(dir, 'plugin.json');
64
+ if (!fs.existsSync(manifestPath))
65
+ continue;
66
+ let result;
67
+ try {
68
+ result = validateManifest(JSON.parse(fs.readFileSync(manifestPath, 'utf8')));
69
+ }
70
+ catch (err) {
71
+ result = { ok: false, error: `plugin.json parse error: ${err instanceof Error ? err.message : String(err)}` };
72
+ }
73
+ // First occurrence of a name wins (package plugins shadow home-dir ones)
74
+ if (result.ok) {
75
+ if (seen.has(result.manifest.name))
76
+ continue;
77
+ seen.add(result.manifest.name);
78
+ }
79
+ found.push({ dir, result });
80
+ }
81
+ }
82
+ return found;
83
+ }
84
+ /**
85
+ * Import a plugin's entry module. Accepts `export default plugin` or
86
+ * `export const plugin`. Returns an error string instead of throwing.
87
+ */
88
+ export async function importPlugin(dir, manifest) {
89
+ const entry = path.resolve(dir, manifest.main);
90
+ if (!fs.existsSync(entry))
91
+ return { ok: false, error: `entry not found: ${manifest.main}` };
92
+ try {
93
+ // Cache-bust so reload() picks up edited plugin code
94
+ const mod = await import(`${pathToFileURL(entry).href}?t=${Date.now()}`);
95
+ const plugin = mod.default ?? mod.plugin;
96
+ if (!plugin || typeof plugin.name !== 'string') {
97
+ return { ok: false, error: 'module must export a PersonalAIPlugin as default or `plugin`' };
98
+ }
99
+ return { ok: true, plugin };
100
+ }
101
+ catch (err) {
102
+ return { ok: false, error: `import failed: ${err instanceof Error ? err.message : String(err)}` };
103
+ }
104
+ }
@@ -0,0 +1,155 @@
1
+ // MIT License — personal-ai
2
+ // PluginManager — lifecycle orchestration. Discovers plugins, loads enabled
3
+ // ones, registers their tools into the shared ToolRegistry and their hooks
4
+ // into the HookRunner. A failing plugin is reported and skipped; the
5
+ // assistant always keeps running.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { logger } from '../core/logger.js';
9
+ import { discoverPlugins, importPlugin, pluginDirs } from './loader.js';
10
+ import { PluginRegistry } from './registry.js';
11
+ import { HookRunner } from './hooks.js';
12
+ import { sandboxed, LIFECYCLE_TIMEOUT_MS } from './sandbox.js';
13
+ export class PluginManager {
14
+ toolRegistry;
15
+ roots;
16
+ registry = new PluginRegistry();
17
+ hooks = new HookRunner();
18
+ constructor(toolRegistry, roots) {
19
+ this.toolRegistry = toolRegistry;
20
+ this.roots = roots;
21
+ }
22
+ /** Discover and load every enabled plugin. Returns loaded count. */
23
+ async loadAll() {
24
+ let loaded = 0;
25
+ for (const { dir, result } of discoverPlugins(this.roots)) {
26
+ if (!result.ok) {
27
+ logger.warn('plugins', `rejected ${path.basename(dir)}: ${result.error}`);
28
+ continue;
29
+ }
30
+ const record = {
31
+ manifest: result.manifest, dir,
32
+ status: result.manifest.enabled ? 'failed' : 'disabled',
33
+ toolCount: 0, hookCount: 0, failureCount: 0,
34
+ };
35
+ this.registry.set(record);
36
+ if (!result.manifest.enabled)
37
+ continue;
38
+ if (await this.activate(record))
39
+ loaded++;
40
+ }
41
+ return loaded;
42
+ }
43
+ /** Load (or re-activate) one plugin by name. */
44
+ async load(name) {
45
+ const record = this.registry.get(name);
46
+ if (!record)
47
+ return undefined;
48
+ await this.activate(record);
49
+ return record;
50
+ }
51
+ /** Unload one plugin: shutdown, remove tools + hooks. */
52
+ async unload(name) {
53
+ const record = this.registry.get(name);
54
+ if (!record || !record.plugin)
55
+ return false;
56
+ if (record.plugin.shutdown) {
57
+ await sandboxed(name, 'shutdown', () => record.plugin.shutdown(), LIFECYCLE_TIMEOUT_MS);
58
+ }
59
+ for (const tool of record.plugin.tools ?? []) {
60
+ this.toolRegistry.unregister(tool.definition.name);
61
+ }
62
+ this.hooks.unregister(name);
63
+ record.plugin = undefined;
64
+ record.status = 'disabled';
65
+ record.toolCount = 0;
66
+ record.hookCount = 0;
67
+ return true;
68
+ }
69
+ /** Reload one plugin from disk (picks up code + manifest changes). */
70
+ async reload(name) {
71
+ const record = this.registry.get(name);
72
+ if (!record)
73
+ return undefined;
74
+ await this.unload(name);
75
+ // Re-read manifest in case enabled/version changed
76
+ try {
77
+ const manifestPath = path.join(record.dir, 'plugin.json');
78
+ const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
79
+ record.manifest = raw;
80
+ }
81
+ catch { /* keep previous manifest */ }
82
+ if (record.manifest.enabled)
83
+ await this.activate(record);
84
+ return record;
85
+ }
86
+ /** Enable/disable persists to plugin.json so the state survives restarts. */
87
+ async setEnabled(name, enabled) {
88
+ const record = this.registry.get(name);
89
+ if (!record)
90
+ return undefined;
91
+ record.manifest.enabled = enabled;
92
+ try {
93
+ fs.writeFileSync(path.join(record.dir, 'plugin.json'), JSON.stringify(record.manifest, null, 2) + '\n');
94
+ }
95
+ catch (err) {
96
+ logger.warn('plugins', `could not persist enabled state for ${name}: ${String(err)}`);
97
+ }
98
+ if (enabled)
99
+ await this.activate(record);
100
+ else
101
+ await this.unload(name);
102
+ return record;
103
+ }
104
+ list() {
105
+ return this.registry.list();
106
+ }
107
+ health() {
108
+ return this.registry.list().map(r => ({
109
+ name: r.manifest.name,
110
+ version: r.manifest.version,
111
+ status: r.failureCount > 0 && r.status === 'healthy' ? `healthy (${r.failureCount} hook failures)` : r.status,
112
+ tools: r.toolCount,
113
+ hooks: r.hookCount,
114
+ failures: r.failureCount,
115
+ ...(r.error ? { error: r.error } : {}),
116
+ }));
117
+ }
118
+ async activate(record) {
119
+ const start = Date.now();
120
+ const name = record.manifest.name;
121
+ const imported = await importPlugin(record.dir, record.manifest);
122
+ if (!imported.ok) {
123
+ record.status = 'failed';
124
+ record.error = imported.error;
125
+ logger.warn('plugins', `⚠ Plugin ${name} failed: ${imported.error}`);
126
+ return false;
127
+ }
128
+ const plugin = imported.plugin;
129
+ if (plugin.initialize) {
130
+ const init = await sandboxed(name, 'initialize', () => plugin.initialize(), LIFECYCLE_TIMEOUT_MS);
131
+ if (!init.ok) {
132
+ record.status = 'failed';
133
+ record.error = `initialize: ${init.error}`;
134
+ logger.warn('plugins', `⚠ Plugin ${name} failed: ${record.error}`);
135
+ return false;
136
+ }
137
+ }
138
+ for (const tool of plugin.tools ?? []) {
139
+ this.toolRegistry.register(tool);
140
+ }
141
+ record.plugin = plugin;
142
+ record.toolCount = plugin.tools?.length ?? 0;
143
+ record.hookCount = plugin.hooks ? Object.keys(plugin.hooks).length : 0;
144
+ record.status = 'healthy';
145
+ record.error = undefined;
146
+ record.loadMs = Date.now() - start;
147
+ this.hooks.register(record);
148
+ logger.debug('plugins', `loaded ${name} v${record.manifest.version} (${record.toolCount} tools, ${record.hookCount} hooks, ${record.loadMs}ms)`);
149
+ return true;
150
+ }
151
+ }
152
+ /** Construct a manager scanning the default plugin directories. */
153
+ export function createPluginManager(toolRegistry, packageRoot) {
154
+ return new PluginManager(toolRegistry, pluginDirs(packageRoot));
155
+ }
@@ -0,0 +1,25 @@
1
+ // MIT License — personal-ai
2
+ // Plugin record store — tracks every discovered plugin and its state.
3
+ // Kept separate from the tool registry: tools are capabilities, plugin
4
+ // records are lifecycle/health bookkeeping.
5
+ export class PluginRegistry {
6
+ records = new Map();
7
+ set(record) {
8
+ this.records.set(record.manifest.name, record);
9
+ }
10
+ get(name) {
11
+ return this.records.get(name);
12
+ }
13
+ delete(name) {
14
+ return this.records.delete(name);
15
+ }
16
+ list() {
17
+ return [...this.records.values()];
18
+ }
19
+ byStatus(status) {
20
+ return this.list().filter(r => r.status === status);
21
+ }
22
+ count() {
23
+ return this.records.size;
24
+ }
25
+ }
@@ -0,0 +1,31 @@
1
+ // MIT License — personal-ai
2
+ // Error boundary for plugin code: plugins must never crash PersonalAI.
3
+ // Every plugin call is wrapped in a timeout and a catch; failures are
4
+ // returned as values.
5
+ import { logger } from '../core/logger.js';
6
+ export const HOOK_TIMEOUT_MS = 2_000;
7
+ export const LIFECYCLE_TIMEOUT_MS = 5_000;
8
+ /**
9
+ * Run a plugin-supplied async function with a timeout and error boundary.
10
+ * Never throws. Timeouts and exceptions come back as `{ ok: false }`.
11
+ */
12
+ export async function sandboxed(pluginName, what, fn, timeoutMs = HOOK_TIMEOUT_MS) {
13
+ let timer;
14
+ try {
15
+ const timeout = new Promise((_, reject) => {
16
+ timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
17
+ timer.unref?.();
18
+ });
19
+ const value = await Promise.race([fn(), timeout]);
20
+ return { ok: true, value };
21
+ }
22
+ catch (err) {
23
+ const error = err instanceof Error ? err.message : String(err);
24
+ logger.warn('plugins', `${pluginName}.${what} failed: ${error}`);
25
+ return { ok: false, error };
26
+ }
27
+ finally {
28
+ if (timer)
29
+ clearTimeout(timer);
30
+ }
31
+ }
@@ -0,0 +1,4 @@
1
+ // MIT License — personal-ai
2
+ // Plugin system types. Plugins extend PersonalAI locally (custom tools,
3
+ // hooks); MCP remains the integration path for external services.
4
+ export {};
@@ -3,6 +3,11 @@ import { eventBus } from '../core/events.js';
3
3
  export class ToolRegistry {
4
4
  tools = new Map();
5
5
  confirmHandler;
6
+ observer;
7
+ /** Install a tool-call observer (plugin hooks). Failures inside it must be handled by the observer. */
8
+ setObserver(observer) {
9
+ this.observer = observer;
10
+ }
6
11
  /**
7
12
  * Install a confirmation handler for tools marked requiresConfirmation.
8
13
  * Without a handler such tools run unconfirmed (legacy behavior) — the CLI
@@ -15,6 +20,10 @@ export class ToolRegistry {
15
20
  register(tool) {
16
21
  this.tools.set(tool.definition.name, tool);
17
22
  }
23
+ /** Remove a tool (used when a plugin is unloaded). */
24
+ unregister(name) {
25
+ return this.tools.delete(name);
26
+ }
18
27
  /** Returns true if tool exists. */
19
28
  has(name) {
20
29
  return this.tools.has(name);
@@ -49,11 +58,15 @@ export class ToolRegistry {
49
58
  return { success: false, data: null, error: `User denied ${name} call` };
50
59
  }
51
60
  }
61
+ if (this.observer)
62
+ await this.observer.beforeToolCall(name, args);
52
63
  try {
53
64
  const result = await tool.execute(args);
54
65
  const durationMs = Date.now() - start;
55
66
  eventBus.emit('tool_called', { name, args, durationMs });
56
67
  eventBus.emit('tool_result', { name, success: result.success, resultSize: JSON.stringify(result.data).length });
68
+ if (this.observer)
69
+ await this.observer.afterToolCall(name, result.data);
57
70
  return result;
58
71
  }
59
72
  catch (err) {
package/dist/ui/cli.js CHANGED
@@ -3,6 +3,7 @@ import readline from 'node:readline';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
+ import { fileURLToPath } from 'node:url';
6
7
  import chalk from 'chalk';
7
8
  import { logger } from '../core/logger.js';
8
9
  import { eventBus } from '../core/events.js';
@@ -13,9 +14,19 @@ export { inferProvider, makeToolXmlStripper, patchEnvFile, friendlyError, create
13
14
  function modelEnvKeyFor(provider) {
14
15
  return PROVIDER_META[provider]?.modelEnvKey;
15
16
  }
17
+ function readVersion() {
18
+ try {
19
+ const here = path.dirname(fileURLToPath(import.meta.url));
20
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', '..', 'package.json'), 'utf8'));
21
+ return pkg.version ?? '';
22
+ }
23
+ catch {
24
+ return '';
25
+ }
26
+ }
16
27
  const BANNER = `
17
28
  ${chalk.cyan('╔═══════════════════════════════════════╗')}
18
- ${chalk.cyan('║')} ${chalk.bold('PersonalAI')} ${chalk.dim('v0.7.0')} ${chalk.cyan('║')}
29
+ ${chalk.cyan('║')} ${chalk.bold('PersonalAI')} ${chalk.dim(`v${readVersion()}`.padEnd(8))} ${chalk.cyan('║')}
19
30
  ${chalk.cyan('║')} ${chalk.dim('Local-first. Any model.')} ${chalk.cyan('║')}
20
31
  ${chalk.cyan('╚═══════════════════════════════════════╝')}
21
32
  `;
@@ -45,6 +56,10 @@ const HELP = `
45
56
  ${chalk.cyan('/research')} Switch to researcher profile
46
57
  ${chalk.cyan('/tutor')} Switch to tutor profile
47
58
  ${chalk.cyan('/tools')} List registered tools
59
+ ${chalk.cyan('/plugins')} List plugins and status
60
+ ${chalk.cyan('/plugins reload')} [name] Reload plugins from disk
61
+ ${chalk.cyan('/plugins enable')} <name> Enable a plugin (persists)
62
+ ${chalk.cyan('/plugins disable')} <name> Disable a plugin (persists)
48
63
  ${chalk.cyan('/save')} [name] Save conversation to a named session
49
64
  ${chalk.cyan('/load')} [name] Restore a saved session (no name = list)
50
65
  ${chalk.cyan('/cost')} Show session token usage and estimated cost
@@ -290,6 +305,51 @@ async function handleModelCmd(parts, modelManager, engine, providerName, envPath
290
305
  console.log(chalk.green(`✓ Pinned to ${modelManager.getCurrentModel()}`));
291
306
  return;
292
307
  }
308
+ async function handlePluginsCmd(parts, plugins) {
309
+ const sub = parts[1]?.toLowerCase();
310
+ if (!sub || sub === 'list' || sub === 'health') {
311
+ const rows = plugins.health();
312
+ if (rows.length === 0) {
313
+ console.log(chalk.dim('No plugins installed. Drop one into plugins/ or ~/.personal-ai/plugins/.'));
314
+ return;
315
+ }
316
+ const nameW = Math.max(16, ...rows.map(r => r.name.length + 2));
317
+ console.log(chalk.bold(`\n${'Plugin'.padEnd(nameW)}Status`));
318
+ console.log(chalk.dim('-'.repeat(nameW + 24)));
319
+ for (const r of rows) {
320
+ const color = r.status.startsWith('healthy') ? chalk.green : r.status === 'disabled' ? chalk.dim : chalk.red;
321
+ console.log(`${r.name.padEnd(nameW)}${color(r.status)}${sub === 'health' ? chalk.dim(` v${r.version} tools:${r.tools} hooks:${r.hooks}${r.error ? ` ${r.error}` : ''}`) : ''}`);
322
+ }
323
+ console.log();
324
+ return;
325
+ }
326
+ if (sub === 'reload') {
327
+ const name = parts[2];
328
+ if (name) {
329
+ const r = await plugins.reload(name);
330
+ console.log(r ? chalk.green(`✓ Reloaded ${name} (${r.status})`) : chalk.yellow(`Plugin "${name}" not found`));
331
+ }
332
+ else {
333
+ for (const p of plugins.list())
334
+ await plugins.reload(p.manifest.name);
335
+ console.log(chalk.green(`✓ Reloaded ${plugins.list().length} plugin(s)`));
336
+ }
337
+ return;
338
+ }
339
+ if (sub === 'enable' || sub === 'disable') {
340
+ const name = parts[2];
341
+ if (!name) {
342
+ console.log(chalk.yellow(`Usage: /plugins ${sub} <name>`));
343
+ return;
344
+ }
345
+ const r = await plugins.setEnabled(name, sub === 'enable');
346
+ console.log(r
347
+ ? chalk.green(`✓ ${name} ${sub}d (${r.status})`)
348
+ : chalk.yellow(`Plugin "${name}" not found`));
349
+ return;
350
+ }
351
+ console.log(chalk.yellow('Usage: /plugins [list|health|reload [name]|enable <name>|disable <name>]'));
352
+ }
293
353
  const SESSIONS_DIR = path.join(os.homedir(), '.personal-ai', 'sessions');
294
354
  function sessionPath(name) {
295
355
  const safe = name.replace(/[^\w-]/g, '_');
@@ -347,9 +407,16 @@ function handleLoadCmd(parts, context) {
347
407
  console.log(chalk.red(`Session "${name}" not found. Run /load to list.`));
348
408
  }
349
409
  }
350
- async function handleCommand(parts, provider, context, memory, profileManager, registry, modelManager, startWeb, getCost) {
410
+ async function handleCommand(parts, provider, context, memory, profileManager, registry, modelManager, startWeb, getCost, plugins) {
351
411
  const cmd = parts[0]?.toLowerCase();
352
412
  switch (cmd) {
413
+ case '/plugins':
414
+ if (!plugins) {
415
+ console.log(chalk.yellow('Plugins not available.'));
416
+ break;
417
+ }
418
+ await handlePluginsCmd(parts, plugins);
419
+ break;
353
420
  case '/exit':
354
421
  console.log(chalk.dim('Goodbye.'));
355
422
  process.exit(0);
@@ -466,7 +533,7 @@ async function handleCommand(parts, provider, context, memory, profileManager, r
466
533
  console.log(chalk.yellow(`Unknown command: ${cmd}. Type /help.`));
467
534
  }
468
535
  }
469
- export async function startCLI(provider, engine, context, memory, profileManager, registry, modelManager, startWeb, reloadProvider, envPath) {
536
+ export async function startCLI(provider, engine, context, memory, profileManager, registry, modelManager, startWeb, reloadProvider, envPath, plugins) {
470
537
  let activeProvider = provider;
471
538
  console.log(BANNER);
472
539
  if (activeProvider.healthCheck) {
@@ -577,7 +644,7 @@ export async function startCLI(provider, engine, context, memory, profileManager
577
644
  return;
578
645
  }
579
646
  if (input.startsWith('/')) {
580
- await handleCommand(input.split(' '), activeProvider, context, memory, profileManager, registry, modelManager, startWeb, getCost);
647
+ await handleCommand(input.split(' '), activeProvider, context, memory, profileManager, registry, modelManager, startWeb, getCost, plugins);
581
648
  refreshPrompt();
582
649
  return;
583
650
  }
@@ -1149,12 +1149,29 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
1149
1149
  }
1150
1150
  const AUTH_TOKEN = sessionStorage.getItem('pai_token') || '';
1151
1151
 
1152
+ // Auth-failure banner: a bookmarked URL without ?token= means every API
1153
+ // call 401s and panels silently stay empty — tell the user what to do.
1154
+ let authBannerShown = false;
1155
+ function showAuthBanner() {
1156
+ if (authBannerShown) return;
1157
+ authBannerShown = true;
1158
+ const b = document.createElement('div');
1159
+ b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#7f1d1d;color:#fff;' +
1160
+ 'padding:10px 16px;font:13px system-ui;text-align:center';
1161
+ b.textContent = 'Session token missing or expired. Restart PersonalAI (or run /web in the CLI) and open the printed URL — it contains your access token.';
1162
+ document.body.prepend(b);
1163
+ }
1164
+
1152
1165
  const _fetch = window.fetch.bind(window);
1153
1166
  window.fetch = function(input, init) {
1154
1167
  const url = typeof input === 'string' ? input : input.url;
1155
1168
  if (url.startsWith('/api')) {
1156
1169
  init = init || {};
1157
1170
  init.headers = Object.assign({}, init.headers, { 'Authorization': 'Bearer ' + AUTH_TOKEN });
1171
+ return _fetch(input, init).then(res => {
1172
+ if (res.status === 401) showAuthBanner();
1173
+ return res;
1174
+ });
1158
1175
  }
1159
1176
  return _fetch(input, init);
1160
1177
  };
@@ -25,7 +25,7 @@ function findFreePort(start) {
25
25
  });
26
26
  }
27
27
  export async function createWebServer(opts) {
28
- const { provider, memory, profileManager, registry, modelManager, personaPath } = opts;
28
+ const { provider, memory, profileManager, registry, modelManager, plugins, personaPath } = opts;
29
29
  const preferred = opts.port ?? parseInt(process.env['PORT'] ?? '3000', 10);
30
30
  const PORT = await findFreePort(preferred);
31
31
  // Security: per-session bearer token. Required on every /api request and WS
@@ -195,6 +195,10 @@ export async function createWebServer(opts) {
195
195
  : [],
196
196
  });
197
197
  });
198
+ // Read-only plugin visibility (Settings → Plugins)
199
+ app.get('/api/plugins', (_req, res) => {
200
+ res.json(plugins ? plugins.health() : []);
201
+ });
198
202
  app.get('/api/system', (_req, res) => {
199
203
  const totalMem = os.totalmem();
200
204
  const freeMem = os.freemem();
package/dist/web.js CHANGED
@@ -18,7 +18,7 @@ async function main() {
18
18
  console.error(`Failed to initialize provider: ${boot.error}`);
19
19
  process.exit(1);
20
20
  }
21
- const { provider, profileManager, memory } = boot.core;
21
+ const { provider, profileManager, memory, plugins } = boot.core;
22
22
  watchProfiles(path.join(CONFIG, 'profiles.yaml'), p => profileManager.reload(p));
23
23
  // Web UI: two-model routing — qwen2.5:14b for tools/logic, gemma3:12b for chat/research/tutor
24
24
  const defaultModel = process.env['OLLAMA_MODEL'] ?? 'qwen2.5:14b';
@@ -47,6 +47,7 @@ async function main() {
47
47
  profileManager,
48
48
  registry: toolRegistry,
49
49
  modelManager,
50
+ plugins,
50
51
  personaPath: path.join(CONFIG, 'persona.yaml'),
51
52
  port: preferred,
52
53
  });
@@ -0,0 +1,130 @@
1
+ # Plugin System
2
+
3
+ Plugins extend PersonalAI **locally**: custom tools, prompt/response hooks,
4
+ and lifecycle handlers — without modifying core code.
5
+
6
+ **Plugins vs MCP:** plugins are for local custom extensions, community
7
+ extensions, and user-defined tools. MCP (M9 roadmap) is for external
8
+ integrations, remote tools, and third-party services. If your extension
9
+ talks to an external service, build an MCP server instead.
10
+
11
+ ## Quick start
12
+
13
+ ```
14
+ plugins/
15
+ └── my-plugin/
16
+ ├── plugin.json
17
+ ├── index.js
18
+ └── README.md
19
+ ```
20
+
21
+ Plugin directories are scanned from:
22
+ 1. `<package root>/plugins/`
23
+ 2. `~/.personal-ai/plugins/`
24
+ 3. `PLUGINS_DIR` env var (overrides both)
25
+
26
+ `plugin.json`:
27
+
28
+ ```json
29
+ {
30
+ "name": "my-plugin",
31
+ "version": "1.0.0",
32
+ "description": "What it does",
33
+ "main": "./index.js",
34
+ "enabled": true
35
+ }
36
+ ```
37
+
38
+ All five fields are required. `name` must be kebab-case; `main` must be a
39
+ `.js` ESM module inside the plugin directory. Invalid manifests are rejected
40
+ with a logged reason and never crash startup.
41
+
42
+ `index.js` (plain ESM JavaScript — no build step):
43
+
44
+ ```js
45
+ /** @type {import('../../src/plugins/types.js').PersonalAIPlugin} */
46
+ const plugin = {
47
+ name: 'my-plugin',
48
+ version: '1.0.0',
49
+ description: 'What it does',
50
+
51
+ tools: [{
52
+ definition: {
53
+ name: 'my_tool',
54
+ description: 'What the model sees',
55
+ parameters: { type: 'object', properties: {} },
56
+ },
57
+ async execute(args) {
58
+ return { success: true, data: 'result' }
59
+ },
60
+ }],
61
+
62
+ hooks: {
63
+ async beforePrompt(prompt) { return prompt },
64
+ async afterResponse(response) { return response },
65
+ async beforeToolCall(name, args) {},
66
+ async afterToolCall(name, result) {},
67
+ async memoryStored(memory) {},
68
+ async sessionStarted() {},
69
+ async sessionEnded() {},
70
+ },
71
+
72
+ async initialize() {},
73
+ async shutdown() {},
74
+ }
75
+
76
+ export default plugin
77
+ ```
78
+
79
+ ## Lifecycle
80
+
81
+ 1. Startup scans plugin directories and validates each `plugin.json`.
82
+ 2. Enabled plugins are imported; `initialize()` runs (5 s timeout).
83
+ 3. Tools register into the shared tool registry — they appear in `/tools`
84
+ and are callable by the assistant immediately.
85
+ 4. Hooks register into the hook runner.
86
+ 5. On `/plugins disable <name>` or shutdown, `shutdown()` runs and the
87
+ plugin's tools/hooks are removed.
88
+
89
+ ## Hooks
90
+
91
+ | Hook | When | Contract |
92
+ |---|---|---|
93
+ | `beforePrompt` | before each model call | return the (possibly modified) system prompt |
94
+ | `afterResponse` | after the response completes | return the (possibly modified) text — applies to stored context; streamed text is already displayed |
95
+ | `beforeToolCall` | before a tool executes | observe only |
96
+ | `afterToolCall` | after a tool returns | observe only |
97
+ | `memoryStored` | when a memory is saved | observe only |
98
+ | `sessionStarted` / `sessionEnded` | CLI session boundaries | observe only |
99
+
100
+ Hooks run inside a sandbox: **2-second timeout**, errors caught and logged.
101
+ A failing hook keeps the previous value in transform chains and bumps the
102
+ plugin's failure count (visible in `/plugins health`). Plugins can degrade —
103
+ they can never take the assistant down.
104
+
105
+ ## CLI
106
+
107
+ ```
108
+ /plugins list plugins and status
109
+ /plugins health status + versions, tool/hook counts, failures
110
+ /plugins reload [name] reload from disk (picks up code changes)
111
+ /plugins enable <name> enable + persist to plugin.json
112
+ /plugins disable <name> disable + persist to plugin.json
113
+ ```
114
+
115
+ The web UI exposes the same data read-only at `GET /api/plugins`.
116
+
117
+ ## Best practices
118
+
119
+ - One concern per plugin; keep tools small and return errors as values
120
+ (`{ success: false, error }`) — never throw.
121
+ - Tools that touch the filesystem or network should set
122
+ `requiresConfirmation: true` so the CLI confirm gate applies.
123
+ - Keep hooks fast — anything over 2 s is cut off.
124
+ - Don't mutate the prompt destructively in `beforePrompt`; append.
125
+ - Ship a README per plugin (see `plugins/hello-world/`).
126
+
127
+ ## Examples
128
+
129
+ - `plugins/hello-world/` — tool + beforePrompt hook
130
+ - `plugins/timestamp/` — minimal single-tool plugin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nandansai08/personal-ai",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Personal AI assistant — any model, local or cloud",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -55,7 +55,9 @@
55
55
  "dist",
56
56
  "bin",
57
57
  "config",
58
- ".env.example"
58
+ ".env.example",
59
+ "plugins",
60
+ "docs/PLUGINS.md"
59
61
  ],
60
62
  "repository": {
61
63
  "type": "git",
@@ -64,5 +66,18 @@
64
66
  "homepage": "https://github.com/Nandansai08/personal-ai#readme",
65
67
  "bugs": {
66
68
  "url": "https://github.com/Nandansai08/personal-ai/issues"
67
- }
69
+ },
70
+ "keywords": [
71
+ "ai",
72
+ "assistant",
73
+ "local-first",
74
+ "ollama",
75
+ "llm",
76
+ "chatgpt",
77
+ "claude",
78
+ "cli",
79
+ "memory",
80
+ "semantic-search",
81
+ "privacy"
82
+ ]
68
83
  }
@@ -0,0 +1,14 @@
1
+ # hello-world
2
+
3
+ Minimal example plugin. Registers one tool (`hello_world`) and one hook
4
+ (`beforePrompt`) that appends a marker line to the system prompt.
5
+
6
+ Use it as the template for your own plugins — see `docs/PLUGINS.md`.
7
+
8
+ ```
9
+ > use the hello_world tool to greet Nandan
10
+ ⟳ hello_world… ✓
11
+ Hello, Nandan — from the hello-world plugin!
12
+ ```
13
+
14
+ Disable with `/plugins disable hello-world`.
@@ -0,0 +1,37 @@
1
+ // MIT License — personal-ai
2
+ // Example plugin. Plain ESM JavaScript — no build step required.
3
+
4
+ /** @type {import('../../src/plugins/types.js').PersonalAIPlugin} */
5
+ const plugin = {
6
+ name: 'hello-world',
7
+ version: '1.0.0',
8
+ description: 'Example plugin — greeting tool and a beforePrompt hook',
9
+
10
+ tools: [
11
+ {
12
+ definition: {
13
+ name: 'hello_world',
14
+ description: 'Returns a greeting from the hello-world plugin.',
15
+ parameters: {
16
+ type: 'object',
17
+ properties: {
18
+ name: { type: 'string', description: 'Who to greet (optional)' },
19
+ },
20
+ },
21
+ },
22
+ async execute(args) {
23
+ const who = (args && typeof args === 'object' && 'name' in args && args.name) || 'world'
24
+ return { success: true, data: `Hello, ${who} — from the hello-world plugin!` }
25
+ },
26
+ },
27
+ ],
28
+
29
+ hooks: {
30
+ // Demonstrates a prompt hook: appends one line to the system prompt.
31
+ async beforePrompt(prompt) {
32
+ return prompt + '\n(hello-world plugin is active)'
33
+ },
34
+ },
35
+ }
36
+
37
+ export default plugin
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "hello-world",
3
+ "version": "1.0.0",
4
+ "description": "Example plugin — greeting tool and a beforePrompt hook",
5
+ "main": "./index.js",
6
+ "enabled": true
7
+ }
@@ -0,0 +1,4 @@
1
+ # timestamp
2
+
3
+ Registers `current_timestamp` — returns the current ISO 8601 timestamp.
4
+ Useful as the smallest possible real plugin: one tool, no hooks.
@@ -0,0 +1,24 @@
1
+ // MIT License — personal-ai
2
+ // Timestamp plugin — exposes the current time as a tool.
3
+
4
+ /** @type {import('../../src/plugins/types.js').PersonalAIPlugin} */
5
+ const plugin = {
6
+ name: 'timestamp',
7
+ version: '1.0.0',
8
+ description: 'Current ISO timestamp tool',
9
+
10
+ tools: [
11
+ {
12
+ definition: {
13
+ name: 'current_timestamp',
14
+ description: 'Returns the current date and time as an ISO 8601 string.',
15
+ parameters: { type: 'object', properties: {} },
16
+ },
17
+ async execute() {
18
+ return { success: true, data: new Date().toISOString() }
19
+ },
20
+ },
21
+ ],
22
+ }
23
+
24
+ export default plugin
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "timestamp",
3
+ "version": "1.0.0",
4
+ "description": "Current ISO timestamp tool",
5
+ "main": "./index.js",
6
+ "enabled": true
7
+ }
@@ -1 +0,0 @@
1
- export {};
package/dist/voice/stt.js DELETED
@@ -1,3 +0,0 @@
1
- // MIT License — personal-ai
2
- // Stub — implemented in M11
3
- export {};
package/dist/voice/tts.js DELETED
@@ -1,3 +0,0 @@
1
- // MIT License — personal-ai
2
- // Stub — implemented in M11
3
- export {};