screenpipe-mcp 0.20.2 → 0.20.3
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/README.md +69 -1
- package/dist/cli.js +781 -232
- package/dist/http-server.js +730 -85
- package/dist/index.js +593 -47
- package/package.json +1 -1
- package/scripts/build.mjs +2 -0
- package/scripts/gen-bundled-skills.mjs +17 -0
- package/scripts/hermes-smoke.py +55 -0
- package/scripts/openclaw-smoke.mjs +59 -0
- package/server.json +2 -2
- package/src/activity-summary-format.ts +9 -0
- package/src/activity-summary-tool.test.ts +19 -0
- package/src/bundled-skills-transport.test.ts +52 -0
- package/src/bundled-skills.test.ts +39 -0
- package/src/bundled-skills.ts +34 -0
- package/src/call-observation.test.ts +49 -0
- package/src/call-observation.ts +109 -0
- package/src/generated/bundled-skills.ts +116 -0
- package/src/http-server.test.ts +11 -3
- package/src/http-server.ts +31 -44
- package/src/index.ts +41 -69
- package/src/search-result.test.ts +22 -0
- package/src/search-result.ts +75 -0
- package/src/stdio-lifecycle.test.ts +48 -0
- package/src/workflow-transport.test.ts +9 -1
package/package.json
CHANGED
package/scripts/build.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { rmSync } from "node:fs";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
|
|
9
|
+
await import("./gen-bundled-skills.mjs");
|
|
10
|
+
|
|
9
11
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
12
|
const outdir = path.join(packageRoot, "dist");
|
|
11
13
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// screenpipe — AI that knows everything you've seen, said, or heard
|
|
2
|
+
// https://screenpipe.com
|
|
3
|
+
|
|
4
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { readBundledSkill, readStarterSkills } from "../../../scripts/lib/skill-bundle.mjs";
|
|
8
|
+
|
|
9
|
+
const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
|
10
|
+
const skills = [
|
|
11
|
+
await readBundledSkill(repoRoot, "screenpipe-api"),
|
|
12
|
+
await readBundledSkill(repoRoot, "screenpipe-cli"),
|
|
13
|
+
...await readStarterSkills(repoRoot),
|
|
14
|
+
];
|
|
15
|
+
const output = path.join(repoRoot, "packages/screenpipe-mcp/src/generated/bundled-skills.ts");
|
|
16
|
+
await mkdir(path.dirname(output), { recursive: true });
|
|
17
|
+
await writeFile(output, `// GENERATED FILE - do not edit by hand.\n// Source: crates/screenpipe-core/assets/skills and src/starter_skills.rs\n// Regenerate: bun scripts/gen-bundled-skills.mjs\n\nexport const BUNDLED_SKILLS = ${JSON.stringify(skills, null, 2)} as const;\n`);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# screenpipe — AI that knows everything you've seen, said, or heard
|
|
2
|
+
# https://screenpipe.com
|
|
3
|
+
|
|
4
|
+
"""Run with Hermes's Python: hermes-smoke.py HERMES_REPO GENERATED_CONFIG.
|
|
5
|
+
Uses an isolated profile and synthetic authenticated API; no model or recording access.
|
|
6
|
+
"""
|
|
7
|
+
import os, sys, tempfile, threading, json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
|
10
|
+
from urllib.parse import urlparse, parse_qs
|
|
11
|
+
root = Path(tempfile.mkdtemp(prefix='screenpipe-hermes-smoke-'))
|
|
12
|
+
os.environ['HERMES_HOME'] = str(root)
|
|
13
|
+
os.environ['SCREENPIPE_LOCAL_API_KEY'] = 'sp-fixture-only'
|
|
14
|
+
requests = []
|
|
15
|
+
reject = False
|
|
16
|
+
class API(BaseHTTPRequestHandler):
|
|
17
|
+
def log_message(self, *args): pass
|
|
18
|
+
def do_GET(self):
|
|
19
|
+
requests.append({'path': self.path, 'authorized': self.headers.get('Authorization') == 'Bearer sp-fixture-only'})
|
|
20
|
+
if reject or not requests[-1]['authorized']:
|
|
21
|
+
self.send_response(401); self.end_headers(); self.wfile.write(b'{"error":"fixture authorization required"}'); return
|
|
22
|
+
self.send_response(200); self.send_header('Content-Type','application/json'); self.end_headers()
|
|
23
|
+
self.wfile.write(json.dumps({'data':[{'type':'OCR','content':{'frame_id':42,'text':'Synthetic Hermes integration acceptance','timestamp':'2026-10-02T12:30:00Z','app_name':'Fixture','window_name':'Acceptance'}}], 'pagination':{'limit':1,'offset':0,'total':1}}).encode())
|
|
24
|
+
def do_POST(self):
|
|
25
|
+
self.send_response(200); self.end_headers(); self.wfile.write(b'{}')
|
|
26
|
+
api = ThreadingHTTPServer(('127.0.0.1',0), API)
|
|
27
|
+
threading.Thread(target=api.serve_forever,daemon=True).start()
|
|
28
|
+
os.environ['SCREENPIPE_API_URL'] = f'http://127.0.0.1:{api.server_port}'
|
|
29
|
+
sys.path.insert(0, sys.argv[1])
|
|
30
|
+
from tools import mcp_tool
|
|
31
|
+
import yaml, shutil
|
|
32
|
+
config = yaml.safe_load(Path(sys.argv[2]).read_text())['mcp_servers']
|
|
33
|
+
config = {'screenpipe': config['screenpipe']}
|
|
34
|
+
# Exercise the generated entry with this checkout's MCP build and a fixture API.
|
|
35
|
+
# Only the executable/package location and API address differ from desktop setup.
|
|
36
|
+
config['screenpipe']['command'] = shutil.which('bun')
|
|
37
|
+
config['screenpipe']['args'] = [str(Path(__file__).resolve().parents[1]/'dist/cli.js')]
|
|
38
|
+
config['screenpipe']['env']['SCREENPIPE_API_URL'] = os.environ['SCREENPIPE_API_URL']
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
names=mcp_tool.register_mcp_servers(config)
|
|
42
|
+
assert any(n.endswith('search_content') for n in names), names
|
|
43
|
+
from tools.registry import registry
|
|
44
|
+
result=registry.dispatch(next(n for n in names if n.endswith('search_content')),{'start_time':'2026-10-02T12:00:00Z','end_time':'2026-10-02T13:00:00Z','limit':1})
|
|
45
|
+
assert 'Synthetic Hermes integration acceptance' in str(result), result
|
|
46
|
+
assert '2026-10-02T12:30:00Z' in str(result), result
|
|
47
|
+
req=next(r for r in requests if '/search?' in r['path'])
|
|
48
|
+
query=parse_qs(urlparse(req['path']).query)
|
|
49
|
+
assert req['authorized'] and query['start_time']==['2026-10-02T12:00:00Z'] and query['end_time']==['2026-10-02T13:00:00Z']
|
|
50
|
+
reject = True
|
|
51
|
+
denied = registry.dispatch(next(n for n in names if n.endswith('search_content')), {'limit':1})
|
|
52
|
+
assert '401' in str(denied), denied
|
|
53
|
+
print(json.dumps({'passed':True,'tools_discovered':len(names),'authenticated_bounded_search':True,'timestamped_source_returned':True,'auth_failure_reported':True,'data':'synthetic','profile':str(root)}))
|
|
54
|
+
finally:
|
|
55
|
+
mcp_tool.shutdown_mcp_servers(); api.shutdown()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// screenpipe — AI that knows everything you've seen, said, or heard
|
|
2
|
+
// https://screenpipe.com
|
|
3
|
+
// Run with Node >=22: node scripts/openclaw-smoke.mjs OPENCLAW_PACKAGE GENERATED_CONFIG
|
|
4
|
+
// Uses OpenClaw's actual runtime, synthetic HTTP data, and an isolated state directory.
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { resolve, join } from 'node:path';
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
const [openclawPackage, configPath] = process.argv.slice(2);
|
|
13
|
+
assert(openclawPackage && configPath, 'provide OpenClaw package and generated config paths');
|
|
14
|
+
const state = await mkdtemp(join(tmpdir(), 'screenpipe-openclaw-'));
|
|
15
|
+
process.env.OPENCLAW_STATE_DIR = state;
|
|
16
|
+
process.env.OPENCLAW_CONFIG_PATH = join(state, 'openclaw.json');
|
|
17
|
+
let requests = [];
|
|
18
|
+
const api = createServer((req, res) => {
|
|
19
|
+
if (req.headers.authorization !== 'Bearer sp-fixture-only') {
|
|
20
|
+
res.writeHead(401); res.end('unauthorized'); return;
|
|
21
|
+
}
|
|
22
|
+
const url = new URL(req.url, 'http://localhost');
|
|
23
|
+
requests.push(url);
|
|
24
|
+
res.setHeader('content-type', 'application/json');
|
|
25
|
+
res.end(JSON.stringify({ data: [{ type: 'OCR', content: { text: 'openclaw-fixture-result', timestamp: '2026-10-01T12:00:00Z', frame_id: 1 } }], pagination: { limit: 5, offset: 0, total: 1 } }));
|
|
26
|
+
});
|
|
27
|
+
await new Promise(resolve => api.listen(0, '127.0.0.1', resolve));
|
|
28
|
+
let runtime;
|
|
29
|
+
try {
|
|
30
|
+
const cfg = JSON.parse(await readFile(configPath, 'utf8'));
|
|
31
|
+
assert(!cfg.mcpServers, 'legacy root config must be absent');
|
|
32
|
+
const server = cfg.mcp.servers.screenpipe;
|
|
33
|
+
assert.equal(server.transport, 'stdio');
|
|
34
|
+
assert.equal(server.env.SCREENPIPE_MCP_CLIENT, 'openclaw');
|
|
35
|
+
server.command = execFileSync('which', ['bun'], { encoding: 'utf8' }).trim();
|
|
36
|
+
server.args = [fileURLToPath(new URL('../dist/index.js', import.meta.url))];
|
|
37
|
+
server.env = { ...server.env, SCREENPIPE_LOCAL_API_KEY: 'sp-fixture-only', SCREENPIPE_LOCAL_API_URL: `http://127.0.0.1:${api.address().port}`, SCREENPIPE_DISABLE_TELEMETRY: 'true' };
|
|
38
|
+
await writeFile(process.env.OPENCLAW_CONFIG_PATH, JSON.stringify(cfg));
|
|
39
|
+
const cli = resolve(openclawPackage, 'openclaw.mjs');
|
|
40
|
+
const validation = JSON.parse(execFileSync(process.execPath, [cli, 'config', 'validate', '--json'], { encoding: 'utf8', env: process.env }));
|
|
41
|
+
assert.equal(validation.valid, true);
|
|
42
|
+
const { createSessionMcpRuntime } = await import(pathToFileURL(resolve(openclawPackage, 'dist/agents/agent-bundle-mcp-runtime.js')));
|
|
43
|
+
runtime = createSessionMcpRuntime({ cfg, workspaceDir: state, agentDir: state, sessionId: 'screenpipe-smoke' });
|
|
44
|
+
const catalog = await runtime.getCatalog();
|
|
45
|
+
assert(catalog.tools.some(t => t.originalName === 'search-content' || t.name === 'search-content' || t.toolName === 'search-content'), 'search-content must be advertised');
|
|
46
|
+
const result = await runtime.callTool('screenpipe', 'search-content', { q: 'fixture', start_time: '2026-10-01T00:00:00Z', end_time: '2026-10-02T00:00:00Z', limit: 5 });
|
|
47
|
+
assert(JSON.stringify(result).includes('openclaw-fixture-result'), JSON.stringify(result));
|
|
48
|
+
assert(requests.some(url => url.pathname === '/search' && url.searchParams.get('start_time') === '2026-10-01T00:00:00Z' && url.searchParams.get('limit') === '5'));
|
|
49
|
+
server.env.SCREENPIPE_LOCAL_API_KEY = 'sp-invalid-fixture';
|
|
50
|
+
await runtime.dispose(); await runtime.joinCleanup();
|
|
51
|
+
runtime = createSessionMcpRuntime({ cfg, workspaceDir: state, agentDir: state, sessionId: 'screenpipe-smoke-invalid-key' });
|
|
52
|
+
const denied = await runtime.callTool('screenpipe', 'search-content', { q: 'fixture', limit: 1 });
|
|
53
|
+
assert(JSON.stringify(denied).includes('401'), JSON.stringify(denied));
|
|
54
|
+
console.log(JSON.stringify({ configValid: true, tools: catalog.tools.length, boundedSearch: true, authFailureVisible: true }));
|
|
55
|
+
} finally {
|
|
56
|
+
if (runtime) { await runtime.dispose(); await runtime.joinCleanup(); }
|
|
57
|
+
await new Promise(resolve => api.close(resolve));
|
|
58
|
+
await rm(state, { recursive: true, force: true });
|
|
59
|
+
}
|
package/server.json
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
"source": "github",
|
|
9
9
|
"subfolder": "packages/screenpipe-mcp"
|
|
10
10
|
},
|
|
11
|
-
"version": "0.20.
|
|
11
|
+
"version": "0.20.3",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
16
16
|
"identifier": "screenpipe-mcp",
|
|
17
|
-
"version": "0.20.
|
|
17
|
+
"version": "0.20.3",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import * as os from "os";
|
|
6
6
|
|
|
7
7
|
export type ActivitySummaryPayload = {
|
|
8
|
+
starred_sessions?: Array<{ id: string; start: string; end: string; has_audio: boolean }>;
|
|
9
|
+
starred_sessions_has_more?: boolean;
|
|
8
10
|
time_range?: { start?: string; end?: string };
|
|
9
11
|
data_status?: string;
|
|
10
12
|
query_status?: string;
|
|
@@ -215,6 +217,13 @@ export function formatActivitySummary(
|
|
|
215
217
|
]
|
|
216
218
|
: []),
|
|
217
219
|
"",
|
|
220
|
+
...(data.starred_sessions === undefined ? [] : [
|
|
221
|
+
"Starred intervals (user intent; not proof of capture, completion or effort):",
|
|
222
|
+
...data.starred_sessions.slice(0, 10).map((s) => ` ${s.start} to ${s.end} | id=${s.id} | has_audio=${s.has_audio}`),
|
|
223
|
+
...(data.starred_sessions.length ? [] : [" (none in this range)"]),
|
|
224
|
+
...(data.starred_sessions_has_more ? [" More intervals exist; narrow the time range for the complete list. starred_only search covers the requested range independently of this list."] : []),
|
|
225
|
+
"",
|
|
226
|
+
]),
|
|
218
227
|
"Apps:",
|
|
219
228
|
...(appsLines.length ? appsLines : [" (none)"]),
|
|
220
229
|
"",
|
|
@@ -39,6 +39,25 @@ function summaryPayload() {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
describe("activity-summary tool orchestration", () => {
|
|
42
|
+
it("returns bounded starred metadata in the existing summary call, including empty sessions", async () => {
|
|
43
|
+
const endpoints: string[] = [];
|
|
44
|
+
const result = await buildActivitySummaryResult(
|
|
45
|
+
{ start_time: "2026-10-02T00:00:00Z", end_time: "2026-10-03T00:00:00Z" },
|
|
46
|
+
async endpoint => {
|
|
47
|
+
endpoints.push(endpoint);
|
|
48
|
+
return jsonResponse({ ...summaryPayload(), starred_sessions: [
|
|
49
|
+
{ id: "empty-session", start: "2026-10-02T10:00:00Z", end: "2026-10-02T10:15:00Z", has_audio: false },
|
|
50
|
+
], starred_sessions_has_more: true });
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
expect(endpoints).toHaveLength(1);
|
|
54
|
+
expect(endpoints[0]).toContain("/activity-summary?");
|
|
55
|
+
expect(result.text).toContain("empty-session");
|
|
56
|
+
expect(result.text).toContain("has_audio=false");
|
|
57
|
+
expect(result.text).toContain("More intervals exist");
|
|
58
|
+
expect(result.text).toContain("Authoritative active time: 120 min");
|
|
59
|
+
});
|
|
60
|
+
|
|
42
61
|
it("returns authoritative time, paths, and bounded parsed context", async () => {
|
|
43
62
|
const endpoints: string[] = [];
|
|
44
63
|
const result = await buildActivitySummaryResult(
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// screenpipe — AI that knows everything you've seen, said, or heard
|
|
2
|
+
// https://screenpipe.com
|
|
3
|
+
|
|
4
|
+
import { beforeAll, it, expect } from "vitest";
|
|
5
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { mkdtempSync, cpSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { buildHttpServer } from "./http-server";
|
|
13
|
+
import { BUNDLED_SKILLS } from "./generated/bundled-skills";
|
|
14
|
+
|
|
15
|
+
const root = path.resolve(__dirname, "..");
|
|
16
|
+
beforeAll(() => execFileSync("bun", ["run", "build"], { cwd: root, stdio: "pipe", timeout: 120000 }), 120000);
|
|
17
|
+
|
|
18
|
+
async function checkCatalog(client: Client) {
|
|
19
|
+
expect((await client.listTools()).tools.map(tool => tool.name)).toContain("screenpipe-skills");
|
|
20
|
+
const listed = await client.callTool({ name: "screenpipe-skills" });
|
|
21
|
+
expect(listed.isError).not.toBe(true);
|
|
22
|
+
const catalog = JSON.parse((listed.content as { text: string }[])[0].text);
|
|
23
|
+
expect(catalog.skills.map((skill: { name: string }) => skill.name)).toEqual(BUNDLED_SKILLS.map(skill => skill.name));
|
|
24
|
+
for (const skill of BUNDLED_SKILLS) {
|
|
25
|
+
const result = await client.callTool({ name: "screenpipe-skills", arguments: { name: skill.name } });
|
|
26
|
+
expect(result.isError).not.toBe(true);
|
|
27
|
+
expect((result.content as { text: string }[])[0].text).toBe(skill.markdown);
|
|
28
|
+
}
|
|
29
|
+
expect((await client.callTool({ name: "screenpipe-skills", arguments: { name: "screenpipe-learned-private" } })).isError).toBe(true);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it("serves every skill from the isolated built stdio artifact with no source tree or engine", async () => {
|
|
33
|
+
const temp = mkdtempSync(path.join(tmpdir(), "screenpipe-skills-"));
|
|
34
|
+
cpSync(path.join(root, "dist/cli.js"), path.join(temp, "cli.js"));
|
|
35
|
+
const client = new Client({ name: "bundled-skills-test", version: "1.0" });
|
|
36
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: [path.join(temp, "cli.js")], cwd: temp, env: {
|
|
37
|
+
PATH: process.env.PATH || "", HOME: temp, USERPROFILE: temp, SCREENPIPE_LOCAL_API_KEY: "fixture-key", SCREENPIPE_DISABLE_TELEMETRY: "1",
|
|
38
|
+
}, stderr: "pipe" });
|
|
39
|
+
try { await client.connect(transport); await checkCatalog(client); }
|
|
40
|
+
finally { await client.close(); await transport.close(); rmSync(temp, { recursive: true, force: true }); }
|
|
41
|
+
}, 15000);
|
|
42
|
+
|
|
43
|
+
it("serves the same catalog over HTTP without the engine", async () => {
|
|
44
|
+
const server = buildHttpServer({ mcpPort: 0, screenpipePort: 1, host: "127.0.0.1" });
|
|
45
|
+
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
46
|
+
const address = server.address();
|
|
47
|
+
if (!address || typeof address === "string") throw new Error("Missing fixture port");
|
|
48
|
+
const client = new Client({ name: "bundled-skills-http-test", version: "1.0" });
|
|
49
|
+
const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp`));
|
|
50
|
+
try { await client.connect(transport); await checkCatalog(client); await transport.terminateSession(); }
|
|
51
|
+
finally { await client.close(); await transport.close(); await new Promise<void>(resolve => server.close(() => resolve())); }
|
|
52
|
+
}, 15000);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// screenpipe — AI that knows everything you've seen, said, or heard
|
|
2
|
+
// https://screenpipe.com
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect } from "vitest";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { BUNDLED_SKILLS } from "./generated/bundled-skills";
|
|
8
|
+
import { BUNDLED_SKILLS_TOOL, readBundledSkills } from "./bundled-skills";
|
|
9
|
+
|
|
10
|
+
const root = path.resolve(__dirname, "../../..");
|
|
11
|
+
const registry = readFileSync(path.join(root, "crates/screenpipe-core/src/starter_skills.rs"), "utf8");
|
|
12
|
+
const starters = [...registry.matchAll(/"(screenpipe-[^"]+)",\s*include_str!\(/g)].map(match => match[1]).sort();
|
|
13
|
+
|
|
14
|
+
describe("public bundled skill access", () => {
|
|
15
|
+
it("lists every native starter and both API guides without loading their bodies", () => {
|
|
16
|
+
expect(BUNDLED_SKILLS.map(skill => skill.name)).toEqual(["screenpipe-api", "screenpipe-cli", ...starters]);
|
|
17
|
+
const result = readBundledSkills();
|
|
18
|
+
const catalog = JSON.parse(result.content[0].text);
|
|
19
|
+
expect(catalog.skills).toEqual(BUNDLED_SKILLS.map(({ name, description }) => ({ name, description })));
|
|
20
|
+
expect(catalog.skills.every((skill: object) => !("markdown" in skill))).toBe(true);
|
|
21
|
+
expect(BUNDLED_SKILLS_TOOL.annotations?.readOnlyHint).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("reads the complete canonical files, including newly bundled handoff guidance", () => {
|
|
25
|
+
expect(starters).toContain("screenpipe-project-handoff");
|
|
26
|
+
for (const skill of BUNDLED_SKILLS) {
|
|
27
|
+
const canonical = readFileSync(path.join(root, "crates/screenpipe-core/assets/skills", skill.name, "SKILL.md"), "utf8");
|
|
28
|
+
expect(readBundledSkills({ name: skill.name }).content[0].text).toBe(canonical);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it.each(["../private", "/Users/example/.codex/skills/private/SKILL.md", "screenpipe-learned-private", "toString", "__proto__", ""])("does not read private or arbitrary paths: %s", name => {
|
|
33
|
+
expect(readBundledSkills({ name }).isError).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it.each([{ name: 7 }, { query: "private" }, { name: null }])("rejects invalid arguments", args => {
|
|
37
|
+
expect(readBundledSkills(args).isError).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// screenpipe — AI that knows everything you've seen, said, or heard
|
|
2
|
+
// https://screenpipe.com
|
|
3
|
+
|
|
4
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { BUNDLED_SKILLS } from "./generated/bundled-skills";
|
|
6
|
+
|
|
7
|
+
export const BUNDLED_SKILLS_TOOL: Tool = {
|
|
8
|
+
name: "screenpipe-skills",
|
|
9
|
+
description: "List Screenpipe's bundled workflow skills, or read one by exact name before using it. Covers recall, meetings, project handoffs, decisions, customer context, bug reports, and process guides. Available without filesystem access. Only public bundled instructions are returned; personal and learned skills are not exported.",
|
|
10
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
name: { type: "string", description: "Exact skill name from the catalog. Omit to list names and descriptions." },
|
|
15
|
+
},
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function readBundledSkills(args: Record<string, unknown> = {}) {
|
|
21
|
+
if (Object.keys(args).some(key => key !== "name") || (args.name !== undefined && typeof args.name !== "string")) {
|
|
22
|
+
return { isError: true, content: [{ type: "text" as const, text: "Provide an exact skill name or omit name to list bundled skills." }] };
|
|
23
|
+
}
|
|
24
|
+
if (args.name === undefined) {
|
|
25
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
26
|
+
skills: BUNDLED_SKILLS.map(({ name, description }) => ({ name, description })),
|
|
27
|
+
usage: "Read the closest matching skill by name. Use your available Screenpipe tools or read screenpipe-api for API access. Loading a skill does not grant new permissions or establish that a task succeeded.",
|
|
28
|
+
}) }] };
|
|
29
|
+
}
|
|
30
|
+
const skill = BUNDLED_SKILLS.find(skill => skill.name === args.name);
|
|
31
|
+
return skill
|
|
32
|
+
? { content: [{ type: "text" as const, text: skill.markdown }] }
|
|
33
|
+
: { isError: true, content: [{ type: "text" as const, text: "Unknown bundled skill. Omit name to list the public catalog." }] };
|
|
34
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// screenpipe — AI that knows everything you've seen, said, or heard
|
|
2
|
+
// https://screenpipe.com
|
|
3
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
4
|
+
import { createCallObserver, currentMcpRequestId, observeSearchResult, observeMcpError, type CallObservation } from "./call-observation";
|
|
5
|
+
const tick = () => new Promise(resolve => setImmediate(resolve));
|
|
6
|
+
afterEach(() => vi.useRealTimers());
|
|
7
|
+
function fixture(send?: (p: CallObservation, s: AbortSignal) => Promise<unknown>, enabled = true) {
|
|
8
|
+
const sent: CallObservation[] = [];
|
|
9
|
+
const observe = createCallObserver({ tools: ["search-content"], transport: "stdio", client: () => "codex", enabled: () => enabled,
|
|
10
|
+
send: send || (async p => { sent.push(p); }) });
|
|
11
|
+
return { sent, observe };
|
|
12
|
+
}
|
|
13
|
+
it("isolates concurrent calls and keeps raw content out of diagnostics", async () => {
|
|
14
|
+
const { sent, observe } = fixture();
|
|
15
|
+
const results = await Promise.all([0, 2].map(count => observe("search-content", async () => {
|
|
16
|
+
const id = currentMcpRequestId(); await tick(); expect(currentMcpRequestId()).toBe(id);
|
|
17
|
+
observeSearchResult(count, count > 0); return {content:[{type:"text",text:"private query and answer"}]};
|
|
18
|
+
})));
|
|
19
|
+
await tick();
|
|
20
|
+
expect(new Set(sent.map(p => p.request_id)).size).toBe(2);
|
|
21
|
+
expect(sent.map(p => p.status)).toEqual(["empty", "ok"]);
|
|
22
|
+
expect(results.map(r => r._meta?.["screenpipe/request_id"])).toEqual(sent.map(p => p.request_id));
|
|
23
|
+
expect(JSON.stringify(sent)).not.toContain("private"); expect(currentMcpRequestId()).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
it("records returned and thrown errors without their text or unknown tool names", async () => {
|
|
26
|
+
const { sent, observe } = fixture();
|
|
27
|
+
await observe("private-tool-name", async () => { observeMcpError({status:401}); return {isError:true,content:[]}; });
|
|
28
|
+
await expect(observe("search-content", async () => { throw Object.assign(new Error("private"), {status:503}); })).rejects.toThrow("private");
|
|
29
|
+
await tick();
|
|
30
|
+
expect(sent.map(p => [p.tool, p.status, p.error_kind])).toEqual([["unknown","error","auth"],["search-content","error","backend"]]);
|
|
31
|
+
expect(sent[0].result_count).toBeUndefined(); expect(JSON.stringify(sent)).not.toContain("private");
|
|
32
|
+
});
|
|
33
|
+
it("does no reporting when disabled and retains the tool result if reporting throws", async () => {
|
|
34
|
+
const send = vi.fn().mockRejectedValue(new Error("offline"));
|
|
35
|
+
await fixture(send, false).observe("search-content", async () => ({content:[]}));
|
|
36
|
+
expect(send).not.toHaveBeenCalled();
|
|
37
|
+
const result = await fixture(send).observe("search-content", async () => ({content:[{text:"answer"}]}));
|
|
38
|
+
await tick(); expect(result.content[0].text).toBe("answer");
|
|
39
|
+
});
|
|
40
|
+
it("bounds outstanding reports, expires hung adapters, and reports the loss later", async () => {
|
|
41
|
+
vi.useFakeTimers(); const sent: CallObservation[] = [];
|
|
42
|
+
let hang = true;
|
|
43
|
+
const {observe} = fixture(async p => {sent.push(p); if(hang) await new Promise(() => {});});
|
|
44
|
+
for(let i=0;i<12;i++) await observe("search-content",async()=>({content:[]}));
|
|
45
|
+
expect(sent).toHaveLength(8);
|
|
46
|
+
await vi.advanceTimersByTimeAsync(1001); hang = false;
|
|
47
|
+
await observe("search-content",async()=>({content:[]})); await vi.advanceTimersByTimeAsync(1);
|
|
48
|
+
expect(sent).toHaveLength(9); expect(sent[8].dropped_reports).toBe(12);
|
|
49
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// screenpipe — AI that knows everything you've seen, said, or heard
|
|
2
|
+
// https://screenpipe.com
|
|
3
|
+
|
|
4
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { PKG_VERSION } from "./version";
|
|
7
|
+
import { safeMcpClient, type McpClient } from "./qualified-value";
|
|
8
|
+
|
|
9
|
+
export type ErrorKind = "none" | "auth" | "invalid_request" | "not_found" | "rate_limited" | "backend" | "unavailable" | "cancelled" | "unknown";
|
|
10
|
+
type Observation = {
|
|
11
|
+
request_id: string;
|
|
12
|
+
error_kind: ErrorKind;
|
|
13
|
+
result_count?: number;
|
|
14
|
+
truncated?: boolean;
|
|
15
|
+
};
|
|
16
|
+
const context = new AsyncLocalStorage<Observation>();
|
|
17
|
+
export const currentMcpRequestId = () => context.getStore()?.request_id;
|
|
18
|
+
|
|
19
|
+
export function observeSearchResult(count: number, truncated: boolean): void {
|
|
20
|
+
const state = context.getStore();
|
|
21
|
+
if (state) { state.result_count = Math.max(0, Math.floor(count)); state.truncated = truncated; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function observeMcpError(error: unknown): void {
|
|
25
|
+
const state = context.getStore();
|
|
26
|
+
if (!state) return;
|
|
27
|
+
const e = error as { status?: number; name?: string } | null;
|
|
28
|
+
state.error_kind = e?.name === "AbortError" ? "cancelled"
|
|
29
|
+
: e?.name === "BackendDownError" ? "unavailable"
|
|
30
|
+
: e?.status === 401 || e?.status === 403 ? "auth"
|
|
31
|
+
: e?.status === 400 || e?.status === 422 ? "invalid_request"
|
|
32
|
+
: e?.status === 404 ? "not_found"
|
|
33
|
+
: e?.status === 429 ? "rate_limited"
|
|
34
|
+
: e?.status && e.status >= 500 ? "backend" : "unknown";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type CallObservation = {
|
|
38
|
+
schema_version: 1;
|
|
39
|
+
request_id: string;
|
|
40
|
+
mcp_version: string;
|
|
41
|
+
client: McpClient;
|
|
42
|
+
transport: "stdio" | "http";
|
|
43
|
+
tool: string;
|
|
44
|
+
status: "ok" | "empty" | "error";
|
|
45
|
+
error_kind: ErrorKind;
|
|
46
|
+
duration_ms: number;
|
|
47
|
+
response_bytes: number;
|
|
48
|
+
result_count?: number;
|
|
49
|
+
truncated?: boolean;
|
|
50
|
+
dropped_reports: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const disabled = () => ["SCREENPIPE_DISABLE_TELEMETRY", "SCREENPIPE_TELEMETRY_DISABLED", "CI", "GITHUB_ACTIONS"]
|
|
54
|
+
.some(key => /^(1|true|yes|on)$/i.test(process.env[key] || ""));
|
|
55
|
+
|
|
56
|
+
/** A bounded best-effort reporter. It never records arguments, results or errors. */
|
|
57
|
+
export function createCallObserver(options: {
|
|
58
|
+
tools: readonly string[];
|
|
59
|
+
transport: "stdio" | "http";
|
|
60
|
+
client: () => McpClient;
|
|
61
|
+
send: (payload: CallObservation, signal: AbortSignal) => Promise<unknown>;
|
|
62
|
+
enabled?: () => boolean;
|
|
63
|
+
}) {
|
|
64
|
+
const knownTools = new Set(options.tools);
|
|
65
|
+
let pending = 0, dropped = 0;
|
|
66
|
+
function report(payload: Omit<CallObservation, "dropped_reports">) {
|
|
67
|
+
if (!(options.enabled?.() ?? !disabled())) return;
|
|
68
|
+
if (pending >= 8) { dropped++; return; }
|
|
69
|
+
pending++;
|
|
70
|
+
const previousDrops = dropped;
|
|
71
|
+
dropped = 0;
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const timer = setTimeout(() => controller.abort(), 1000);
|
|
74
|
+
timer.unref();
|
|
75
|
+
// Resolve on timeout even if an adapter ignores its signal.
|
|
76
|
+
const timeout = new Promise<never>((_, reject) => controller.signal.addEventListener("abort", () => reject(new Error("telemetry timeout")), { once: true }));
|
|
77
|
+
void Promise.race([Promise.resolve().then(() => options.send({ ...payload, dropped_reports: previousDrops }, controller.signal)), timeout])
|
|
78
|
+
.catch(() => { dropped += previousDrops + 1; })
|
|
79
|
+
.finally(() => { clearTimeout(timer); pending--; });
|
|
80
|
+
}
|
|
81
|
+
return async function observe<T extends { content?: unknown; isError?: boolean; _meta?: Record<string, unknown> }>(name: string, run: () => Promise<T>): Promise<T & { _meta: Record<string, unknown> }> {
|
|
82
|
+
const state: Observation = { request_id: randomUUID(), error_kind: "none" };
|
|
83
|
+
const start = performance.now();
|
|
84
|
+
return context.run(state, async () => {
|
|
85
|
+
let result: T | undefined;
|
|
86
|
+
try {
|
|
87
|
+
result = await run();
|
|
88
|
+
return { ...result, _meta: { ...result._meta, "screenpipe/request_id": state.request_id } };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
observeMcpError(error);
|
|
91
|
+
throw error;
|
|
92
|
+
} finally {
|
|
93
|
+
const failed = !result || result.isError === true;
|
|
94
|
+
// Count content size without serializing or retaining a second copy.
|
|
95
|
+
const bytes = Array.isArray(result?.content) ? result.content.reduce((sum, item) =>
|
|
96
|
+
sum + (typeof item?.text === "string" ? Buffer.byteLength(item.text) : typeof item?.data === "string" ? Buffer.byteLength(item.data) : 0), 0) : 0;
|
|
97
|
+
try { report({ schema_version: 1, request_id: state.request_id, mcp_version: PKG_VERSION,
|
|
98
|
+
client: safeMcpClient(options.client()), transport: options.transport,
|
|
99
|
+
tool: knownTools.has(name) ? name : "unknown",
|
|
100
|
+
status: failed ? "error" : state.result_count === 0 ? "empty" : "ok",
|
|
101
|
+
error_kind: failed ? state.error_kind === "none" ? "unknown" : state.error_kind : "none",
|
|
102
|
+
duration_ms: Math.max(0, Math.round(performance.now() - start)), response_bytes: bytes,
|
|
103
|
+
...(state.result_count === undefined ? {} : { result_count: state.result_count }),
|
|
104
|
+
...(state.truncated === undefined ? {} : { truncated: state.truncated }),
|
|
105
|
+
}); } catch { /* Diagnostics must never change the tool result. */ }
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
}
|