From 75a63032d0cc719ecc273d711d5a294268008d63 Mon Sep 17 00:00:00 2001 From: Burak <8755484+kriptoburak@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:15:37 +0300 Subject: [PATCH] feat: add TweetClaw source-note workflow and harden setup --- README.md | 33 ++++++++++ SECURITY.md | 3 +- command.test.ts | 22 +++++++ command.ts | 36 +++++++++++ commands/slash.test.ts | 27 ++++++++ commands/slash.ts | 18 +++--- .../basic-memory-context-engine.test.ts | 5 +- context-engine/basic-memory-context-engine.ts | 6 +- index.test.ts | 1 + index.ts | 62 +++++++++++-------- package.json | 1 + 11 files changed, 179 insertions(+), 35 deletions(-) create mode 100644 command.test.ts create mode 100644 command.ts create mode 100644 commands/slash.test.ts diff --git a/README.md b/README.md index f52b001..ad29196 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,39 @@ Rolling JWT middleware to all API routes. Set `status: done` to mark complete. Done tasks are filtered out of active task results. +## Source notes from other plugins + +Basic Memory works well as the durable note layer for research gathered by other OpenClaw plugins. For X/Twitter research, install [TweetClaw](https://github.com/Xquik-dev/tweetclaw) from its verified [ClawHub page](https://clawhub.ai/xquik/plugins/tweetclaw). TweetClaw requires OpenClaw 2026.7.1 or newer. The [npm package](https://www.npmjs.com/package/@xquik/tweetclaw) remains available as a fallback: + +```bash +openclaw plugins install clawhub:@xquik/tweetclaw +``` + +Then use TweetClaw to search tweets, search tweet replies, export followers, or look up users, and save the useful results as Basic Memory notes: + +```markdown +--- +title: x-twitter-feedback-openclaw-memory +type: Research +source: x-twitter +captured_with: tweetclaw +captured_at: 2026-05-14 +--- + +## Query +"openclaw memory plugin" + +## Findings +- Users ask for durable task state after context compaction. +- Source each finding with the tweet URL, tweet ID, author, and capture date. + +## Next Steps +- [ ] Link related findings to the active project note. +- [ ] Revisit the search before release notes ship. +``` + +Keep raw exports, secrets, cookies, direct messages, and private account material out of notes. Save the query, source links, IDs, summary, and next action so future `memory_search` calls can recover the context without replaying the scrape. + ## Basic Memory Cloud Everything works locally. Cloud adds cross-device sync, team workspaces, and persistent memory for hosted agents. diff --git a/SECURITY.md b/SECURITY.md index 647e791..1b2796c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,7 +12,8 @@ On first startup, this plugin checks whether the `bm` (Basic Memory) CLI is avai ### What this means -- The plugin uses Node.js `child_process.execSync` to run `uv` as a shell command +- The plugin uses `execFileSync` with a fixed executable and argument array +- Command paths are resolved without interpolating configuration into a shell - This requires `uv` (the Python package manager from Astral) to be installed on your system - The installation pulls from the public `basic-memory` GitHub repository - If `uv` is not installed, the step is skipped gracefully — no error, no crash diff --git a/command.test.ts b/command.test.ts new file mode 100644 index 0000000..3d5d669 --- /dev/null +++ b/command.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test" +import { basename, dirname } from "node:path" +import { isCommandAvailable } from "./command.ts" + +describe("isCommandAvailable", () => { + const executable = basename(process.execPath) + const executableDirectory = dirname(process.execPath) + + it("finds an executable by absolute path", () => { + expect(isCommandAvailable(process.execPath)).toBe(true) + }) + + it("finds an executable on the supplied path", () => { + expect(isCommandAvailable(executable, executableDirectory)).toBe(true) + }) + + it("rejects missing commands and shell syntax", () => { + expect( + isCommandAvailable(`${executable}; exit 0`, executableDirectory), + ).toBe(false) + }) +}) diff --git a/command.ts b/command.ts new file mode 100644 index 0000000..a16feb6 --- /dev/null +++ b/command.ts @@ -0,0 +1,36 @@ +import { accessSync, constants } from "node:fs" +import { delimiter, isAbsolute, resolve } from "node:path" + +function isExecutable(path: string): boolean { + try { + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} + +export function isCommandAvailable( + command: string, + pathValue = process.env.PATH, +): boolean { + if (command.length === 0) { + return false + } + + if (isAbsolute(command) || command.includes("/")) { + return isExecutable(command) + } + + if (!pathValue) { + return false + } + + for (const directory of pathValue.split(delimiter)) { + if (isExecutable(resolve(directory, command))) { + return true + } + } + + return false +} diff --git a/commands/slash.test.ts b/commands/slash.test.ts new file mode 100644 index 0000000..5cc2a9f --- /dev/null +++ b/commands/slash.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { runSetupScript } from "./slash.ts" + +describe("runSetupScript", () => { + const temporaryDirectories: string[] = [] + + afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { force: true, recursive: true }) + } + }) + + it("runs paths containing shell syntax as literal arguments", () => { + const directory = mkdtempSync(join(tmpdir(), "bm-setup-")) + temporaryDirectories.push(directory) + + const quotedDirectory = join(directory, 'path"; exit 7; #') + mkdirSync(quotedDirectory) + const scriptPath = join(quotedDirectory, "setup.sh") + writeFileSync(scriptPath, 'printf "setup complete\\n"\n') + + expect(runSetupScript(scriptPath)).toBe("setup complete\n") + }) +}) diff --git a/commands/slash.ts b/commands/slash.ts index b9f2994..cff752d 100644 --- a/commands/slash.ts +++ b/commands/slash.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process" +import { execFileSync } from "node:child_process" import { dirname, resolve } from "node:path" import { fileURLToPath } from "node:url" import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry" @@ -7,6 +7,15 @@ import { log } from "../logger.ts" const __dirname = dirname(fileURLToPath(import.meta.url)) +export function runSetupScript(scriptPath: string): string { + return execFileSync("bash", [scriptPath], { + encoding: "utf-8", + timeout: 180_000, + stdio: "pipe", + env: { ...process.env }, + }) +} + export function registerCommands( api: OpenClawPluginApi, client: BmClient, @@ -22,12 +31,7 @@ export function registerCommands( log.info(`/bm-setup: running ${scriptPath}`) try { - const output = execSync(`bash "${scriptPath}"`, { - encoding: "utf-8", - timeout: 180_000, - stdio: "pipe", - env: { ...process.env }, - }) + const output = runSetupScript(scriptPath) return { text: output.trim() } } catch (err: unknown) { const execErr = err as { stderr?: string; stdout?: string } diff --git a/context-engine/basic-memory-context-engine.test.ts b/context-engine/basic-memory-context-engine.test.ts index 3e2fd66..176bde0 100644 --- a/context-engine/basic-memory-context-engine.test.ts +++ b/context-engine/basic-memory-context-engine.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, jest } from "bun:test" -import type { AgentMessage } from "@mariozechner/pi-agent-core" import type { BmClient } from "../bm-client.ts" import type { BasicMemoryConfig } from "../config.ts" import { @@ -7,6 +6,10 @@ import { MAX_ASSEMBLE_RECALL_CHARS, } from "./basic-memory-context-engine.ts" +type AgentMessage = Parameters< + BasicMemoryContextEngine["assemble"] +>[0]["messages"][number] + function makeConfig(overrides?: Partial): BasicMemoryConfig { return { project: "test-project", diff --git a/context-engine/basic-memory-context-engine.ts b/context-engine/basic-memory-context-engine.ts index ca52c65..e9aa378 100644 --- a/context-engine/basic-memory-context-engine.ts +++ b/context-engine/basic-memory-context-engine.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@mariozechner/pi-agent-core" +import type { ContextEngine as OpenClawContextEngine } from "openclaw/plugin-sdk" import { delegateCompactionToRuntime } from "openclaw/plugin-sdk/core" import type { BmClient } from "../bm-client.ts" import type { BasicMemoryConfig } from "../config.ts" @@ -6,6 +6,10 @@ import { selectCaptureTurn } from "../hooks/capture.ts" import { loadRecallState } from "../hooks/recall.ts" import { log } from "../logger.ts" +type AgentMessage = Parameters< + OpenClawContextEngine["assemble"] +>[0]["messages"][number] + export const MAX_ASSEMBLE_RECALL_CHARS = 1200 const TRUNCATED_RECALL_SUFFIX = "\n\n[Basic Memory recall truncated]" const SUBAGENT_HANDOFF_FOLDER = "agent/subagents" diff --git a/index.test.ts b/index.test.ts index 2cbd765..b71437f 100644 --- a/index.test.ts +++ b/index.test.ts @@ -32,6 +32,7 @@ describe("plugin service lifecycle", () => { const api = { pluginConfig: { + bmPath: "true", project: "test-project", projectPath: "memory/", }, diff --git a/index.ts b/index.ts index 5f53c4c..30b6960 100644 --- a/index.ts +++ b/index.ts @@ -1,7 +1,11 @@ -import { execSync } from "node:child_process" +import { execFileSync } from "node:child_process" -import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry" +import { + definePluginEntry, + type OpenClawPluginDefinition, +} from "openclaw/plugin-sdk/plugin-entry" import { BmClient } from "./bm-client.ts" +import { isCommandAvailable } from "./command.ts" import { registerCli } from "./commands/cli.ts" import { registerSkillCommands } from "./commands/skills.ts" import { registerCommands } from "./commands/slash.ts" @@ -33,7 +37,7 @@ import { registerWriteTool } from "./tools/write-note.ts" const BASIC_MEMORY_RELEASE_TAG = "v0.20.2" -export default definePluginEntry({ +const plugin: OpenClawPluginDefinition = definePluginEntry({ id: "openclaw-basic-memory", name: "Basic Memory", description: @@ -87,32 +91,38 @@ export default definePluginEntry({ // Auto-install bm CLI if not found const bmBin = cfg.bmPath || "bm" - try { - execSync(`command -v ${bmBin}`, { stdio: "ignore" }) - } catch { + if (!isCommandAvailable(bmBin)) { log.info("bm CLI not found on PATH — attempting auto-install...") - try { - execSync("command -v uv", { stdio: "ignore" }) - log.info( - "installing basic-memory via uv (this may take a minute)...", - ) - const result = execSync( - `uv tool install "basic-memory @ git+https://github.com/basicmachines-co/basic-memory.git@${BASIC_MEMORY_RELEASE_TAG}" --force`, - { encoding: "utf-8", timeout: 120_000, stdio: "pipe" }, - ) - log.info( - `basic-memory installed: ${result.trim().split("\n").pop()}`, - ) - // Verify it worked + if (isCommandAvailable("uv")) { try { - execSync(`command -v ${bmBin}`, { stdio: "ignore" }) - log.info("bm CLI now available on PATH") - } catch { - log.error( - "bm installed but not found on PATH. You may need to add uv's bin directory to your PATH (typically ~/.local/bin).", + log.info( + "installing basic-memory via uv (this may take a minute)...", ) + const result = execFileSync( + "uv", + [ + "tool", + "install", + `basic-memory @ git+https://github.com/basicmachines-co/basic-memory.git@${BASIC_MEMORY_RELEASE_TAG}`, + "--force", + ], + { encoding: "utf-8", timeout: 120_000, stdio: "pipe" }, + ) + log.info( + `basic-memory installed: ${result.trim().split("\n").pop()}`, + ) + // Verify it worked + if (isCommandAvailable(bmBin)) { + log.info("bm CLI now available on PATH") + } else { + log.error( + "bm installed but not found on PATH. You may need to add uv's bin directory to your PATH (typically ~/.local/bin).", + ) + } + } catch (err) { + log.error("Failed to auto-install basic-memory with uv.", err) } - } catch (_uvErr) { + } else { log.error( "Cannot auto-install basic-memory: uv not found. " + "Install uv first (brew install uv, or curl -LsSf https://astral.sh/uv/install.sh | sh), " + @@ -159,3 +169,5 @@ export default definePluginEntry({ }) }, }) + +export default plugin diff --git a/package.json b/package.json index 9aa40c5..0bcc137 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "files": [ "index.ts", + "command.ts", "bm-client.ts", "context-engine/basic-memory-context-engine.ts", "config.ts",