screenpipe-mcp 0.20.0 → 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.
@@ -0,0 +1,75 @@
1
+ // screenpipe — AI that knows everything you've seen, said, or heard
2
+ // https://screenpipe.com
3
+
4
+ import { inputEventContent } from "./workflow-tools";
5
+
6
+ export const DEFAULT_SEARCH_CONTENT_TRUNCATE = 1000;
7
+
8
+ export function searchContentCap(value: unknown): number {
9
+ const cap = typeof value === "number" ? value : Number(value);
10
+ return value !== undefined && Number.isFinite(cap) && cap >= 0
11
+ ? Math.floor(cap) : DEFAULT_SEARCH_CONTENT_TRUNCATE;
12
+ }
13
+
14
+ export function truncateSearchText(text: string, cap: number): string {
15
+ if (cap === 0 || text.length <= cap) return text;
16
+ const left = Math.floor(cap / 2);
17
+ return text.slice(0, left) + `…[${text.length - cap} chars truncated; inspect the source or narrow the search]…` + text.slice(text.length - (cap - left));
18
+ }
19
+
20
+ function numericId(value: unknown): number | undefined {
21
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
22
+ }
23
+
24
+ /** Source identifiers come from the backend, never from result position or text. */
25
+ export function formatSearchHit(result: any, cap: number): { text: string; truncated: boolean } | null {
26
+ const c = result.content;
27
+ if (!c || typeof c !== "object") return null;
28
+ const frame = numericId(c.frame_id);
29
+ const chunk = numericId(c.chunk_id);
30
+ const frameRef = frame === undefined ? "" : `\nSource: frame_id=${frame}; screenpipe://frame/${frame}`;
31
+ const tags = Array.isArray(c.tags) && c.tags.length ? `\nTags: ${c.tags.join(", ")}` : "";
32
+ let label: string, body: string, source = frameRef;
33
+ switch (result.type) {
34
+ case "OCR":
35
+ label = c.text_source === "accessibility" ? "[Screen·a11y]" : c.text_source === "ocr" ? "[Screen·ocr]" : "[Screen]";
36
+ label += ` ${c.app_name || "?"} | ${c.window_name || "?"}`;
37
+ body = c.text || "";
38
+ break;
39
+ case "UI": case "Accessibility":
40
+ label = `[Accessibility] ${c.app_name || "?"} | ${c.window_name || "?"}`;
41
+ body = c.text || "";
42
+ break;
43
+ case "Audio":
44
+ label = `[Audio] ${c.device_name || "?"}`;
45
+ body = c.transcription || "";
46
+ source = chunk === undefined ? "" : `\nSource: chunk_id=${chunk}`;
47
+ if (typeof c.timestamp === "string" && Number.isFinite(Date.parse(c.timestamp))) {
48
+ source += `${source ? "; " : "\nSource: "}screenpipe://timeline?timestamp=${encodeURIComponent(c.timestamp)}`;
49
+ }
50
+ break;
51
+ case "Parsed":
52
+ label = `[Parsed] ${c.app_name || "?"} | ${c.window_name || "?"} | frame ${frame ?? "?"}`;
53
+ body = c.text || "";
54
+ break;
55
+ case "Memory":
56
+ label = `[Memory #${c.id}]${c.importance != null ? ` (importance: ${c.importance})` : ""}`;
57
+ body = c.content || "";
58
+ break;
59
+ case "Input":
60
+ return { text: `${result.starred ? "[Starred moment]\n" : ""}${inputEventContent(c, cap === 0 ? Number.MAX_SAFE_INTEGER : cap)}${source}`, truncated: cap > 0 && typeof c.text_content === "string" && c.text_content.length > cap };
61
+ default: return null;
62
+ }
63
+ body = String(body);
64
+ return {
65
+ text: `${result.starred ? "[Starred moment]\n" : ""}${label}\n${c.timestamp || c.created_at || ""}${source}\n${truncateSearchText(body, cap)}${tags}`,
66
+ truncated: cap > 0 && body.length > cap,
67
+ };
68
+ }
69
+
70
+ export function searchResultHeader(count: number, pagination: any): string {
71
+ const total = typeof pagination.total === "number" ? pagination.total : undefined;
72
+ const offset = typeof pagination.offset === "number" ? pagination.offset : 0;
73
+ return `Results: ${count}/${total ?? "?"}` +
74
+ (count > 0 && total !== undefined && offset + count < total ? ` (use offset=${offset + count} for more)` : "");
75
+ }
@@ -0,0 +1,48 @@
1
+ // screenpipe — AI that knows everything you've seen, said, or heard
2
+ // https://screenpipe.com
3
+ import { it, expect } from "vitest";
4
+ import { spawn, execFileSync, type ChildProcessWithoutNullStreams } from "node:child_process";
5
+ import { createServer } from "node:http";
6
+ import { once } from "node:events";
7
+ import path from "node:path";
8
+ const sleep=(ms:number)=>new Promise(resolve=>setTimeout(resolve,ms));
9
+ async function until(predicate:()=>boolean, timeout=2000) {
10
+ const end=Date.now()+timeout;
11
+ while(!predicate() && Date.now()<end) await sleep(10);
12
+ expect(predicate()).toBe(true);
13
+ }
14
+ it("reaps a helper on EOF during a hung read while another client remains usable",async()=>{
15
+ const root=path.resolve(__dirname,"..");
16
+ execFileSync("bun",["run","build"],{cwd:root,stdio:"pipe",timeout:120000});
17
+ let hanging=false;
18
+ const api=createServer((req,res)=>{
19
+ if(req.url?.includes("q=hang")){hanging=true;return;}
20
+ res.setHeader("Content-Type","application/json");res.end(JSON.stringify({data:[],pagination:{total:0}}));
21
+ });
22
+ await new Promise<void>(resolve=>api.listen(0,"127.0.0.1",resolve));
23
+ const address=api.address();if(!address||typeof address==='string')throw new Error('fixture unavailable');
24
+ const port=address.port;
25
+ const children:ChildProcessWithoutNullStreams[]=[];
26
+ async function start(){
27
+ const child=spawn(process.execPath,[path.join(root,'dist/cli.js')],{env:{PATH:process.env.PATH||'',SCREENPIPE_LOCAL_API_KEY:'fixture',SCREENPIPE_LOCAL_API_URL:`http://127.0.0.1:${port}`,SCREENPIPE_DISABLE_TELEMETRY:'1'},stdio:'pipe'});
28
+ children.push(child);let output='';child.stdout.on('data',part=>output+=part);child.stderr.resume();
29
+ const send=(value:unknown)=>child.stdin.write(JSON.stringify(value)+'\n');
30
+ send({jsonrpc:'2.0',id:1,method:'initialize',params:{protocolVersion:'2024-11-05',capabilities:{},clientInfo:{name:'test',version:'1'}}});
31
+ await until(()=>output.includes('"id":1'));
32
+ send({jsonrpc:'2.0',method:'notifications/initialized'});
33
+ return {child,send,output:()=>output};
34
+ }
35
+ try{
36
+ const abandoned=await start(), live=await start();
37
+ abandoned.send({jsonrpc:'2.0',id:2,method:'tools/call',params:{name:'search-content',arguments:{q:'hang'}}});
38
+ await until(()=>hanging);abandoned.child.stdin.end();
39
+ await until(()=>abandoned.child.exitCode!==null);
40
+ expect(live.child.exitCode).toBeNull();
41
+ live.send({jsonrpc:'2.0',id:2,method:'tools/call',params:{name:'search-content',arguments:{}}});
42
+ await until(()=>live.output().includes('No results'));
43
+ live.child.stdin.end();await until(()=>live.child.exitCode!==null);
44
+ } finally {
45
+ for(const child of children)if(child.exitCode===null&&child.signalCode===null){child.kill('SIGKILL');await once(child,'exit');}
46
+ api.closeAllConnections();await new Promise<void>(resolve=>api.close(()=>resolve()));
47
+ }
48
+ },15000);
@@ -20,7 +20,7 @@ it("discovers and retrieves workflow targeting context through the built MCP tra
20
20
  if(req.url?.startsWith("/workflows?")) res.end(JSON.stringify({data:[{id,title:"Invoice review"},{id:uuidId,title:"Maintained workflow"}]}));
21
21
  else if(req.url?.startsWith(`/workflows/${id}?`)) res.end(JSON.stringify({id,workflow:{stages:[{name:"Review"}]},automationEvidence:[{status:"no_captured_frame",actionTarget:"unknown"}]}));
22
22
  else if(req.url?.startsWith(`/workflows/${uuidId}?`)) res.end(JSON.stringify({id:uuidId,workflow:{stages:[{name:"Review maintained workflow"}]}}));
23
- else if(req.url?.startsWith("/search?")) res.end(JSON.stringify({data:[{type:"Input",content:{id:8,event_type:"click",frame_id:7,x:40,y:60,element_role:"AXButton",element_name:"Approve"}}],pagination:{total:1,offset:0,limit:5}}));
23
+ else if(req.url?.startsWith("/search?")) res.end(JSON.stringify({data:[{starred:req.url.includes("starred_only=true"),type:"Input",content:{id:8,event_type:"click",frame_id:7,x:40,y:60,element_role:"AXButton",element_name:"Approve"}}],pagination:{total:1,offset:0,limit:5}}));
24
24
  else if(req.url === "/frames/7/context?include_empty=true") res.end(JSON.stringify({frame_id:7,text_source:"accessibility",nodes:[{role:"AXButton",text:"",bounds:{left:0.25,top:0.5,width:0.125,height:0.0625},properties:{automation_id:"approve"}}]}));
25
25
  else { res.writeHead(404);res.end("{}"); }
26
26
  });
@@ -33,6 +33,14 @@ it("discovers and retrieves workflow targeting context through the built MCP tra
33
33
  await client.connect(transport);
34
34
  const tools=await client.listTools();
35
35
  expect(tools.tools.map(t=>t.name)).toEqual(expect.arrayContaining(["list-workflows","get-workflow"]));
36
+ expect(tools.tools.some(t=>t.name === "list-starred-sessions")).toBe(false);
37
+ expect(tools.tools.find(t=>t.name === "search-content")?.inputSchema.properties).toHaveProperty("starred_only");
38
+ const before = requests.length;
39
+ const starred = await client.callTool({name:"search-content",arguments:{starred_only:true,start_time:"2026-10-02T00:00:00Z",end_time:"2026-10-03T00:00:00Z",limit:5}});
40
+ expect(starred.isError).not.toBe(true);
41
+ expect(JSON.stringify(starred)).toContain("[Starred moment]");
42
+ expect(requests.slice(before)).toHaveLength(1);
43
+ expect(requests.at(-1)).toContain("starred_only=true");
36
44
  const listed=await client.callTool({name:"list-workflows",arguments:{q:"invoice"}});
37
45
  expect(JSON.stringify(listed)).toContain(id);
38
46
  const detail=await client.callTool({name:"get-workflow",arguments:{id}});