@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 +1 -1
- package/README.md +5 -3
- package/dist/bootstrap.js +14 -1
- package/dist/core/assistant.js +11 -3
- package/dist/index.js +13 -3
- package/dist/memory/long-term.js +8 -1
- package/dist/plugins/hooks.js +91 -0
- package/dist/plugins/loader.js +103 -2
- package/dist/plugins/manager.js +155 -0
- package/dist/plugins/registry.js +25 -0
- package/dist/plugins/sandbox.js +31 -0
- package/dist/plugins/types.js +4 -0
- package/dist/tools/registry.js +13 -0
- package/dist/ui/cli.js +71 -4
- package/dist/ui/web/client/index.html +17 -0
- package/dist/ui/web/server.js +5 -1
- package/dist/web.js +2 -1
- package/docs/PLUGINS.md +130 -0
- package/package.json +18 -3
- package/plugins/hello-world/README.md +14 -0
- package/plugins/hello-world/index.js +37 -0
- package/plugins/hello-world/plugin.json +7 -0
- package/plugins/timestamp/README.md +4 -0
- package/plugins/timestamp/index.js +24 -0
- package/plugins/timestamp/plugin.json +7 -0
- package/dist/plugins/interface.js +0 -1
- package/dist/voice/stt.js +0 -3
- package/dist/voice/tts.js +0 -3
package/LICENSE
CHANGED
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 |
|
|
408
|
-
| v1.0 | Planned |
|
|
409
|
-
| v1.1 | Planned |
|
|
408
|
+
| v0.9 | Done | Plugin system — local tools + hooks, sandboxed, hot-reload ([docs/PLUGINS.md](docs/PLUGINS.md)) |
|
|
409
|
+
| v1.0 | Planned | MCP support — connect 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
|
-
|
|
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);
|
package/dist/core/assistant.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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); });
|
package/dist/memory/long-term.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/plugins/loader.js
CHANGED
|
@@ -1,3 +1,104 @@
|
|
|
1
1
|
// MIT License — personal-ai
|
|
2
|
-
//
|
|
3
|
-
|
|
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
|
+
}
|
package/dist/tools/registry.js
CHANGED
|
@@ -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(
|
|
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
|
};
|
package/dist/ui/web/server.js
CHANGED
|
@@ -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
|
});
|
package/docs/PLUGINS.md
ADDED
|
@@ -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.
|
|
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,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
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/voice/stt.js
DELETED
package/dist/voice/tts.js
DELETED