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/src/index.ts
CHANGED
|
@@ -32,8 +32,9 @@ import {
|
|
|
32
32
|
} from "./qualified-value";
|
|
33
33
|
import { discoverTeamConfig } from "./team-config";
|
|
34
34
|
import { teamFrameContent, teamFramePath } from "./team-frame";
|
|
35
|
-
import { WORKFLOW_TOOLS, readWorkflowTool, frameAutomationContent
|
|
35
|
+
import { WORKFLOW_TOOLS, readWorkflowTool, frameAutomationContent } from "./workflow-tools";
|
|
36
36
|
import { PKG_VERSION } from "./version";
|
|
37
|
+
import { BUNDLED_SKILLS_TOOL, readBundledSkills } from "./bundled-skills";
|
|
37
38
|
import { formatForElementPurpose } from "./element-format";
|
|
38
39
|
import { buildActivitySummaryResult } from "./activity-summary-tool";
|
|
39
40
|
import {
|
|
@@ -41,6 +42,8 @@ import {
|
|
|
41
42
|
normalizeTime,
|
|
42
43
|
normalizeTimeFields,
|
|
43
44
|
} from "./time-normalization";
|
|
45
|
+
import { createCallObserver, currentMcpRequestId, observeMcpError, observeSearchResult } from "./call-observation";
|
|
46
|
+
import { formatSearchHit, searchContentCap, searchResultHeader } from "./search-result";
|
|
44
47
|
import { resolveScreenpipeApiBase } from "./api-base";
|
|
45
48
|
|
|
46
49
|
initMcpTelemetry({ transport: "stdio" });
|
|
@@ -323,10 +326,11 @@ const server = new Server(
|
|
|
323
326
|
// ---------------------------------------------------------------------------
|
|
324
327
|
const TOOLS: Tool[] = [
|
|
325
328
|
...WORKFLOW_TOOLS,
|
|
329
|
+
BUNDLED_SKILLS_TOOL,
|
|
326
330
|
{
|
|
327
331
|
name: "search-content",
|
|
328
332
|
description:
|
|
329
|
-
"Search screen text, audio transcriptions, input events, memories, and parsed app data. Returns timestamped results with app context. " +
|
|
333
|
+
"Search screen text, audio transcriptions, input events, memories, and parsed app data. Returns timestamped results with app context and starred markers. For starred work, set starred_only=true and a time range; no session lookup needed. " +
|
|
330
334
|
"USE WHEN: you need the actual text/content of a moment — quotes, screen text, transcript lines, or compact parsed messages, emails, tasks, documents, and code review — or want to filter by speaker/window. " +
|
|
331
335
|
"DO NOT USE for: broad questions like 'what was I doing?' (use activity-summary, it pre-summarizes apps + windows + transcripts). " +
|
|
332
336
|
"Also DO NOT USE for: targeted UI controls (use search-elements). " +
|
|
@@ -356,6 +360,8 @@ const TOOLS: Tool[] = [
|
|
|
356
360
|
type: "string",
|
|
357
361
|
description: "ISO 8601, relative time, or local calendar ('today', 'yesterday', 'tomorrow', 'YYYY-MM-DD'). Defaults to now.",
|
|
358
362
|
},
|
|
363
|
+
starred_only: { type: "boolean", description: "Only captures inside starred intervals. Use for starred work; omit for ordinary searches. Applied before pagination." },
|
|
364
|
+
starred_session_id: { type: "string", description: "Optional exact session ID already known from activity-summary. Usually use starred_only instead." },
|
|
359
365
|
app_name: { type: "string", description: "Filter by app name (e.g. 'Google Chrome', 'Slack', 'zoom.us'). Case-sensitive." },
|
|
360
366
|
window_name: { type: "string", description: "Filter by window title substring" },
|
|
361
367
|
frame_id: { type: "integer", description: "With content_type='parsed', return parsed data attached to one frame." },
|
|
@@ -441,6 +447,7 @@ const TOOLS: Tool[] = [
|
|
|
441
447
|
{
|
|
442
448
|
name: "activity-summary",
|
|
443
449
|
description:
|
|
450
|
+
"Includes up to ten recent starred intervals for this time range, even without captured content. Stars indicate user intent, not duration or completion. " +
|
|
444
451
|
"Rich activity overview: authoritative active minutes, app/window time, edited document paths, key text, and audio transcriptions, with optional parsed task context when available. " +
|
|
445
452
|
"USE WHEN: any broad question about what the user did — 'what was I doing?', 'how long on X?', 'which apps?', 'recap my morning'. " +
|
|
446
453
|
"This is almost always the right first call for time-range questions — usually sufficient without follow-up searches. " +
|
|
@@ -452,6 +459,7 @@ const TOOLS: Tool[] = [
|
|
|
452
459
|
properties: {
|
|
453
460
|
start_time: { type: "string", description: "ISO 8601, relative (e.g. '3h ago'), or local calendar ('today', 'yesterday', 'tomorrow', 'YYYY-MM-DD')" },
|
|
454
461
|
end_time: { type: "string", description: "ISO 8601, relative (e.g. 'now'), or local calendar ('today', 'yesterday', 'tomorrow', 'YYYY-MM-DD')" },
|
|
462
|
+
include_starred: { type: "boolean", description: "Include bounded starred-session context (default true; omitted for app/data-restricted reads)." },
|
|
455
463
|
app_name: { type: "string", description: "Optional app name filter to focus on one app" },
|
|
456
464
|
include_parsed_context: {
|
|
457
465
|
type: "boolean",
|
|
@@ -1318,6 +1326,7 @@ async function fetchAPI(
|
|
|
1318
1326
|
headers: {
|
|
1319
1327
|
"Content-Type": "application/json",
|
|
1320
1328
|
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
1329
|
+
...(currentMcpRequestId() ? { "x-screenpipe-request-id": currentMcpRequestId()! } : {}),
|
|
1321
1330
|
"x-screenpipe-client": "mcp",
|
|
1322
1331
|
"x-screenpipe-agent": currentMcpClient(),
|
|
1323
1332
|
...options.headers,
|
|
@@ -1399,11 +1408,18 @@ function screenTag(textSource: unknown): string {
|
|
|
1399
1408
|
// ---------------------------------------------------------------------------
|
|
1400
1409
|
// Tool handlers
|
|
1401
1410
|
// ---------------------------------------------------------------------------
|
|
1402
|
-
|
|
1411
|
+
const observeCall = createCallObserver({
|
|
1412
|
+
tools: [...TOOLS.map(tool => tool.name), ...TEAM_TOOLS.map(tool => tool.name), ...WORKFLOW_TOOLS.map(tool => tool.name), BUNDLED_SKILLS_TOOL.name],
|
|
1413
|
+
transport: "stdio", client: currentMcpClient,
|
|
1414
|
+
send: (payload, signal) => callAPI("/internal/telemetry/mcp-call", { method: "POST", body: JSON.stringify(payload), signal }),
|
|
1415
|
+
});
|
|
1416
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => observeCall(request.params.name, async () => {
|
|
1403
1417
|
const { name, arguments: args } = request.params;
|
|
1404
1418
|
|
|
1419
|
+
if (name === BUNDLED_SKILLS_TOOL.name) return readBundledSkills(args);
|
|
1420
|
+
|
|
1405
1421
|
if (!args) {
|
|
1406
|
-
throw new Error("Missing arguments");
|
|
1422
|
+
throw Object.assign(new Error("Missing arguments"), { status: 400 });
|
|
1407
1423
|
}
|
|
1408
1424
|
|
|
1409
1425
|
try {
|
|
@@ -1600,12 +1616,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1600
1616
|
// Default text cap if the caller didn't pass max_content_length.
|
|
1601
1617
|
// Keeps single calls under Claude Code's per-tool output limit.
|
|
1602
1618
|
const userCap = normalized.max_content_length;
|
|
1603
|
-
const effectiveCap =
|
|
1604
|
-
typeof userCap === "number"
|
|
1605
|
-
? userCap
|
|
1606
|
-
: userCap === undefined
|
|
1607
|
-
? DEFAULT_SEARCH_CONTENT_TRUNCATE
|
|
1608
|
-
: Number(userCap);
|
|
1619
|
+
const effectiveCap = searchContentCap(userCap);
|
|
1609
1620
|
const params = new URLSearchParams();
|
|
1610
1621
|
for (const [key, value] of Object.entries(normalized)) {
|
|
1611
1622
|
if (value !== null && value !== undefined) {
|
|
@@ -1617,6 +1628,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1617
1628
|
const data = await response.json();
|
|
1618
1629
|
const results = data.data || [];
|
|
1619
1630
|
const pagination = data.pagination || {};
|
|
1631
|
+
observeSearchResult(results.length, false);
|
|
1620
1632
|
|
|
1621
1633
|
if (results.length === 0) {
|
|
1622
1634
|
return {
|
|
@@ -1638,71 +1650,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1638
1650
|
|
|
1639
1651
|
const formattedResults: string[] = [];
|
|
1640
1652
|
const images: Array<{ data: string; context: string }> = [];
|
|
1653
|
+
let truncated = false;
|
|
1641
1654
|
|
|
1642
1655
|
for (const result of results) {
|
|
1643
1656
|
const content = result.content;
|
|
1644
1657
|
if (!content) continue;
|
|
1645
|
-
|
|
1646
|
-
if (
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
// tells us if the text actually came from the accessibility tree
|
|
1650
|
-
// (primary path) or OCR (fallback). Use it to label honestly.
|
|
1651
|
-
const tag = screenTag(content.text_source);
|
|
1652
|
-
formattedResults.push(
|
|
1653
|
-
`${tag} ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
|
|
1654
|
-
`${content.timestamp || ""}\n` +
|
|
1655
|
-
`${truncateMiddle(content.text || "", effectiveCap)}` +
|
|
1656
|
-
tagsStr
|
|
1657
|
-
);
|
|
1658
|
-
if (includeFrames && content.frame) {
|
|
1659
|
-
images.push({
|
|
1660
|
-
data: content.frame,
|
|
1661
|
-
context: `${content.app_name} at ${content.timestamp}`,
|
|
1662
|
-
});
|
|
1663
|
-
}
|
|
1664
|
-
} else if (result.type === "Audio") {
|
|
1665
|
-
const tagsStr = content.tags?.length ? `\nTags: ${content.tags.join(", ")}` : "";
|
|
1666
|
-
formattedResults.push(
|
|
1667
|
-
`[Audio] ${content.device_name || "?"}\n` +
|
|
1668
|
-
`${content.timestamp || ""}\n` +
|
|
1669
|
-
`${truncateMiddle(content.transcription || "", effectiveCap)}` +
|
|
1670
|
-
tagsStr
|
|
1671
|
-
);
|
|
1672
|
-
} else if (result.type === "UI" || result.type === "Accessibility") {
|
|
1673
|
-
formattedResults.push(
|
|
1674
|
-
`[Accessibility] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
|
|
1675
|
-
`${content.timestamp || ""}\n` +
|
|
1676
|
-
`${truncateMiddle(content.text || "", effectiveCap)}`
|
|
1677
|
-
);
|
|
1678
|
-
} else if (result.type === "Input") {
|
|
1679
|
-
formattedResults.push(inputEventContent(content, effectiveCap));
|
|
1680
|
-
} else if (result.type === "Memory") {
|
|
1681
|
-
const tagsStr = content.tags?.length ? ` [${content.tags.join(", ")}]` : "";
|
|
1682
|
-
const importance =
|
|
1683
|
-
content.importance != null ? ` (importance: ${content.importance})` : "";
|
|
1684
|
-
// frame_id links a memory back to the exact moment — jump there with
|
|
1685
|
-
// frame-context / get-frame-elements (frame_id=N).
|
|
1686
|
-
const frameRef = content.frame_id != null ? ` frame:${content.frame_id}` : "";
|
|
1687
|
-
formattedResults.push(
|
|
1688
|
-
`[Memory #${content.id}]${tagsStr}${importance}${frameRef}\n` +
|
|
1689
|
-
`${content.created_at || ""}\n` +
|
|
1690
|
-
`${truncateMiddle(content.content || "", effectiveCap)}`
|
|
1691
|
-
);
|
|
1692
|
-
} else if (result.type === "Parsed") {
|
|
1693
|
-
formattedResults.push(
|
|
1694
|
-
`[Parsed] ${content.app_name || "?"} | ${content.window_name || "?"} | frame ${content.frame_id || "?"}\n` +
|
|
1695
|
-
`${content.timestamp || ""}\n` +
|
|
1696
|
-
`${truncateMiddle(content.text || "", effectiveCap)}`
|
|
1697
|
-
);
|
|
1658
|
+
const hit = formatSearchHit(result, effectiveCap);
|
|
1659
|
+
if (hit) { formattedResults.push(hit.text); truncated ||= hit.truncated; }
|
|
1660
|
+
if (includeFrames && result.type === "OCR" && content.frame) {
|
|
1661
|
+
images.push({ data: content.frame, context: `${content.app_name} at ${content.timestamp}` });
|
|
1698
1662
|
}
|
|
1699
1663
|
}
|
|
1700
1664
|
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
(pagination.total > results.length
|
|
1704
|
-
? ` (use offset=${(pagination.offset || 0) + results.length} for more)`
|
|
1705
|
-
: "");
|
|
1665
|
+
observeSearchResult(results.length, truncated);
|
|
1666
|
+
const header = searchResultHeader(results.length, pagination);
|
|
1706
1667
|
|
|
1707
1668
|
// Co-occurring tags (only present when include_related=true + tags set).
|
|
1708
1669
|
// Compact one-liner per namespace so it's cheap to read.
|
|
@@ -2432,6 +2393,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2432
2393
|
throw new Error(`Unknown tool: ${name}`);
|
|
2433
2394
|
}
|
|
2434
2395
|
} catch (error) {
|
|
2396
|
+
observeMcpError(error);
|
|
2435
2397
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
2436
2398
|
if (!(error instanceof BackendDownError) && !(error instanceof BackendHttpError)) {
|
|
2437
2399
|
captureMcpMessage("tool call failed", "error", { phase: "tool_call", tool: name });
|
|
@@ -2443,7 +2405,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2443
2405
|
content: [{ type: "text", text: `Error executing ${name}: ${errorMessage}` }],
|
|
2444
2406
|
};
|
|
2445
2407
|
}
|
|
2446
|
-
});
|
|
2408
|
+
}));
|
|
2447
2409
|
|
|
2448
2410
|
// Run the server
|
|
2449
2411
|
async function main() {
|
|
@@ -2453,6 +2415,16 @@ async function main() {
|
|
|
2453
2415
|
console.error(`[screenpipe-mcp] v${PKG_VERSION} phase=connect target=${SCREENPIPE_API}`);
|
|
2454
2416
|
const transport = new StdioServerTransport();
|
|
2455
2417
|
await server.connect(transport);
|
|
2418
|
+
// The SDK does not close its transport on stdin EOF. An outstanding fetch
|
|
2419
|
+
// can otherwise keep an abandoned helper alive indefinitely after its host
|
|
2420
|
+
// exits. This process belongs to this one stdio client only.
|
|
2421
|
+
process.stdin.once("end", () => {
|
|
2422
|
+
const deadline = setTimeout(() => process.exit(0), 500);
|
|
2423
|
+
void server.close()
|
|
2424
|
+
.then(() => flushMcpTelemetry(250))
|
|
2425
|
+
.catch(() => {})
|
|
2426
|
+
.finally(() => { clearTimeout(deadline); process.exit(0); });
|
|
2427
|
+
});
|
|
2456
2428
|
console.error("[screenpipe-mcp] phase=connected transport=stdio");
|
|
2457
2429
|
// Warm the API key in the background so the first tool call doesn't pay the
|
|
2458
2430
|
// discovery latency. Never awaited here — key discovery must not gate attach.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// screenpipe — AI that knows everything you've seen, said, or heard
|
|
2
|
+
// https://screenpipe.com
|
|
3
|
+
import { expect, it } from "vitest";
|
|
4
|
+
import { formatSearchHit, searchContentCap, searchResultHeader } from "./search-result";
|
|
5
|
+
it("preserves real references through truncation, without inventing missing IDs", () => {
|
|
6
|
+
const hit = formatSearchHit({type:"OCR",content:{frame_id:72,text:"HEAD"+"x".repeat(3000)+"TAIL",text_source:"accessibility"}},10)!;
|
|
7
|
+
expect(hit.truncated).toBe(true); expect(hit.text).toContain("frame_id=72"); expect(hit.text).toContain("screenpipe://frame/72");
|
|
8
|
+
expect(hit.text).toContain("HEAD"); expect(hit.text).toContain("TAIL"); expect(hit.text.length).toBeLessThan(250);
|
|
9
|
+
expect(formatSearchHit({type:"OCR",content:{text:"no source"}},100)!.text).not.toContain("Source:");
|
|
10
|
+
});
|
|
11
|
+
it("keeps audio references and input text, including explicit unlimited reads", () => {
|
|
12
|
+
expect(formatSearchHit({type:"Audio",content:{chunk_id:31,timestamp:"2026-10-01T00:00:00Z",transcription:"speech"}},100)!.text).toContain("chunk_id=31");
|
|
13
|
+
const input={type:"Input",content:{frame_id:72,text_content:"long input"}};
|
|
14
|
+
expect(formatSearchHit(input,3)!.truncated).toBe(true);
|
|
15
|
+
expect(formatSearchHit(input,0)!.text).toContain("long input"); expect(formatSearchHit(input,0)!.truncated).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
it("only suggests an offset when a next page exists", () => {
|
|
18
|
+
expect(searchResultHeader(5,{total:10,offset:5})).not.toContain("offset=");
|
|
19
|
+
expect(searchResultHeader(5,{total:10,offset:0})).toContain("offset=5");
|
|
20
|
+
expect(searchResultHeader(0,{total:10,offset:0})).not.toContain("offset=");
|
|
21
|
+
expect(searchContentCap(undefined)).toBe(1000); expect(searchContentCap(-1)).toBe(1000);
|
|
22
|
+
});
|
|
@@ -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}});
|