Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
843 changes: 843 additions & 0 deletions docs/PLAN_SENSE_SOCIAL_CONNECTORS_v1.0.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ INSPECTION
all off): list, grant <signal>, revoke <signal>,
revoke-all.
lisa sense [list] Recent ambient sense events + granted signals.
lisa sense social Social connector manifests + publish drafts.
lisa agents Snapshot of agent sessions across all observers.
lisa pair [--host H] Show a QR to pair a phone (Lisa Pocket) — mints a
per-device token via a running serve (localhost).
Expand Down
43 changes: 42 additions & 1 deletion src/cli/sense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
*/
import { readSenseEvents } from "../sense/log.js";
import { listGrants } from "../consent/store.js";
import { discoverSocialConnectors } from "../sense/social/manifest.js";
import { listSocialDrafts } from "../sense/social/drafts.js";

function rel(ms: number): string {
if (ms < 60_000) return `${Math.round(ms / 1000)}s ago`;
Expand All @@ -16,6 +18,45 @@ function rel(ms: number): string {
export async function runSenseCommand(subargs: string[]): Promise<number> {
const sub = subargs[0] ?? "list";

if (sub === "social") {
const [connectors, drafts] = await Promise.all([
discoverSocialConnectors(),
listSocialDrafts(),
]);
console.log("Sense · Connected media\n");
console.log("Connectors:");
if (connectors.length === 0) {
console.log(" (none — install a plugin with social-connector.json)");
} else {
for (const connector of connectors) {
if (connector.manifest) {
console.log(
` ✓ ${connector.manifest.displayName} [${connector.manifest.platform}] via ${connector.plugin}`,
);
} else {
console.log(` ✗ ${connector.plugin}: ${connector.error ?? "invalid manifest"}`);
}
}
}
console.log("\nDrafts:");
if (drafts.length === 0) {
console.log(" (none)");
} else {
for (const draft of drafts.slice(-20).reverse()) {
const targets = draft.targets
.map((target) => `${target.platform}:${target.accountId}`)
.join(", ");
console.log(
` ${draft.id.slice(0, 8)} r${draft.revision} ${draft.state.padEnd(17)} ${targets}`,
);
}
}
console.log(
"\nPublishing remains host-confirmed: connecting an account never authorizes an automatic post.",
);
return 0;
}

if (sub === "list" || sub === "recent" || sub === "status") {
const granted = listGrants().filter((g) => g.granted).map((g) => g.signal);
console.log(`Sense — granted: ${granted.length ? granted.join(", ") : "(none; all off — `lisa consent grant <signal>`)"}\n`);
Expand All @@ -31,6 +72,6 @@ export async function runSenseCommand(subargs: string[]): Promise<number> {
return 0;
}

console.error(`unknown sense subcommand "${sub}" — use list.`);
console.error(`unknown sense subcommand "${sub}" — use list or social.`);
return 1;
}
23 changes: 23 additions & 0 deletions src/mcp/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,29 @@ describe("mcpToolToLisaTool — mapping", () => {
const t2 = mcpToolToLisaTool("x", fakeClient(() => ({ content: [] })), { name: "y", inputSchema: { type: "object", properties: { a: { type: "string" } } } }, () => {});
assert.equal((t2.inputSchema as { type: string }).type, "object");
});

test("preserves MCP annotations as untrusted policy metadata", () => {
const t = mcpToolToLisaTool(
"social",
fakeClient(() => ({ content: [] })),
{
name: "publish",
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
() => {},
);
assert.deepEqual(t.annotations, {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
});
});
});

describe("mcpToolToLisaTool — execute() result flattening", () => {
Expand Down
14 changes: 13 additions & 1 deletion src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,18 @@ async function connectOne(
export function mcpToolToLisaTool(
serverName: string,
client: Client,
mcpTool: { name: string; description?: string; inputSchema?: object },
mcpTool: {
name: string;
description?: string;
inputSchema?: object;
annotations?: {
title?: string;
readOnlyHint?: boolean;
destructiveHint?: boolean;
idempotentHint?: boolean;
openWorldHint?: boolean;
};
},
log: (msg: string) => void,
): ToolDefinition {
const name = `mcp__${serverName}__${mcpTool.name}`;
Expand All @@ -69,6 +80,7 @@ export function mcpToolToLisaTool(
return {
name,
description,
...(mcpTool.annotations ? { annotations: { ...mcpTool.annotations } } : {}),
inputSchema: ((mcpTool.inputSchema as { type?: string; properties?: object } | undefined)?.type === "object"
? (mcpTool.inputSchema as { type: "object"; properties?: object })
: { type: "object" as const, properties: {} }) as { type: "object"; properties?: Record<string, unknown> },
Expand Down
117 changes: 117 additions & 0 deletions src/sense/social/drafts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, test } from "node:test";
import {
approveSocialDraft,
canonicalJson,
claimApprovedSocialDraft,
createSocialDraft,
listSocialDrafts,
requestSocialDraftApproval,
socialDraftDigest,
updateSocialDraft,
} from "./drafts.js";
import type { NewSocialDraft } from "./types.js";

let home: string;
let previousHome: string | undefined;

beforeEach(() => {
previousHome = process.env.LISA_HOME;
home = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-social-drafts-"));
process.env.LISA_HOME = home;
});

afterEach(() => {
if (previousHome === undefined) delete process.env.LISA_HOME;
else process.env.LISA_HOME = previousHome;
fs.rmSync(home, { recursive: true, force: true });
});

function input(): NewSocialDraft {
return {
targets: [
{
connectorId: "bluesky-official",
accountId: "did:plc:alice",
platform: "bluesky",
visibility: "public",
},
],
canonical: {
text: "Hello",
link: "https://example.com",
media: [],
},
};
}

describe("social drafts", () => {
test("canonical JSON is stable across object key order", () => {
assert.equal(
canonicalJson({ z: 1, a: { y: 2, x: 3 } }),
canonicalJson({ a: { x: 3, y: 2 }, z: 1 }),
);
});

test("creates a 0600 store without tokens or media bytes", async () => {
const draft = await createSocialDraft(input(), 1000);
assert.equal(draft.state, "draft");
assert.equal((await listSocialDrafts()).length, 1);
const file = path.join(home, "sense", "social", "drafts.json");
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
const raw = fs.readFileSync(file, "utf8");
assert.doesNotMatch(raw, /access[_-]?token|refresh[_-]?token/i);
});

test("editing invalidates an approval request and changes the digest", async () => {
const created = await createSocialDraft(input(), 1000);
const requested = await requestSocialDraftApproval(created.id, 1, 2000);
const updated = await updateSocialDraft(
created.id,
1,
{ canonical: { text: "Changed", media: [] } },
3000,
);
assert.equal(updated.state, "draft");
assert.equal(updated.approval, undefined);
assert.notEqual(socialDraftDigest(updated), requested.digest);
await assert.rejects(
approveSocialDraft(created.id, requested.digest, 4000),
/cannot be approved/,
);
});

test("digest mismatch is rejected", async () => {
const draft = await createSocialDraft(input(), 1000);
await requestSocialDraftApproval(draft.id, 1, 2000);
await assert.rejects(
approveSocialDraft(draft.id, "0".repeat(64), 3000),
/changed after preview/,
);
});

test("approval can be claimed exactly once", async () => {
const draft = await createSocialDraft(input(), 1000);
const { digest } = await requestSocialDraftApproval(draft.id, 1, 2000);
await approveSocialDraft(draft.id, digest, 3000);
const claimed = await claimApprovedSocialDraft(draft.id, digest, 4000);
assert.equal(claimed.state, "publishing");
await assert.rejects(
claimApprovedSocialDraft(draft.id, digest, 5000),
/is not publishable/,
);
});

test("expired approval fails closed", async () => {
const draft = await createSocialDraft(input(), 1000);
const { digest } = await requestSocialDraftApproval(draft.id, 1, 2000);
await approveSocialDraft(draft.id, digest, 3000, 100);
await assert.rejects(
claimApprovedSocialDraft(draft.id, digest, 3100),
/approval expired/,
);
});
});
Loading