diff --git a/README.md b/README.md index 8640393..177fc45 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,10 @@ A native companion to [be-concise](https://github.com/yannelli/be-concise) for C - Live hook decisions, searchable by tool, file, session, project, and reason. Pause the feed or open an event’s request and response. - A compact composer pill with the Be concise logo and one number. The number combines rejected and flagged decisions; the popup shows each decision count and a 30-minute chart. Turning enforcement off displays **Off** and sets the workspace's `softFail` override; flagged actions are allowed while hooks and test-output filtering remain active. Turn it on to restore enforcement. Agent environment overrides apply separately. - Call counts, interventions, sessions, average hook duration, and a 30-minute activity chart. -- User and project configuration with threshold controls, check switches, writing presets, an advanced JSON editor, and test filter settings. -- An isolated playground for file writes, Codex patches, shell commands, and final replies. Previews inspect hook responses without executing the pasted command or writing the target file. +- User and project configuration with sections for limits, checks, style, dictionary, exceptions, and logging, plus a JSON view and test filter settings. List controls follow the be-concise merge rule for each key. +- A dictionary editor for terms to flag, with match rules, fixes, and hook and scope limits. Entries from other files can be turned off or copied in. +- A writing tuner that scans pasted samples and proposes settings they pass. Apply the proposal to a draft, review it, then save. +- An isolated playground for file writes, Codex patches, shell commands, and final replies. Previews show the decision, reason, and findings of each hook without executing the pasted command or writing the target file. - A Plugin tab that installs, updates, downgrades, and removes be-concise for Claude Code and Codex from the be-concise GitHub releases. ## Screenshots @@ -62,16 +64,33 @@ All images are 1920×1080. They come from the Paseo 0.11.0-beta.3 web app in lig ## Install -Requires Paseo 0.9.0 or newer (tested against the 0.10.2 plugin SDK) and be-concise 0.7.0 or newer on the daemon machine. The adapter uses the configuration, monitoring, and preview APIs shipped with be-concise 0.7.0. Paseo supplies all runtime dependencies. +Requires Paseo 0.9.0 or newer (tested against the 0.10.2 plugin SDK) and be-concise 0.7.0 or newer on the daemon machine. The adapter uses the configuration, monitoring, and preview APIs shipped with be-concise 0.7.0. The dictionary and the writing tuner need be-concise 0.10.0; controls for settings that the installed version lacks are hidden. See [be-concise 0.10 support](docs/be-concise-0.10.md). Paseo supplies all runtime dependencies. + +Install the npm release: ```sh paseo plugin add npm:@yannelli/paseo-be-concise paseo plugin ls ``` -To install from GitHub instead, run `paseo plugin add yannelli/paseo-plugin-concise`. +`paseo plugin update paseo-be-concise` shows the installed and proposed revisions before it updates. To pin a version, add it to the source, for example `npm:@yannelli/paseo-be-concise@2.3.0`. + +Plugins must be enabled in Paseo Settings → Plugins. Installation runs trusted code with the daemon user’s access. + +The plugin detects installed be-concise versions in the Claude and Codex caches, respecting `CLAUDE_CONFIG_DIR` and `CODEX_HOME`. For a source checkout or custom installation, set `PASEO_CONCISE_ROOT` in the daemon environment to the repository or `plugins/concise` directory, then reload this plugin. + +### Older Paseo versions + +Paseo installs npm sources from 0.9.0. On older Paseo, install the Git tag built for that version: -For local development: +| Paseo | Plugin tag | Command | +| --- | --- | --- | +| 0.8.0 | `v1.0.0` | `paseo plugin add yannelli/paseo-plugin-concise --ref v1.0.0` | +| 0.7.2 | `v0.2.9` | `paseo plugin add yannelli/paseo-plugin-concise --ref v0.2.9` | + +These tags do not have later features, such as the Plugin tab and the be-concise 0.10 controls. On Paseo 0.9.0 or newer, `--ref` also installs a 2.x tag that is not on npm, for example `--ref v2.2.0`. See [npm publishing](docs/npm-publishing.md#paseo-versions) for the sources. + +### Local development ```sh git clone https://github.com/yannelli/paseo-plugin-concise.git @@ -82,10 +101,6 @@ paseo plugin install "$PWD" paseo plugin ls ``` -Plugins must be enabled in Paseo Settings → Plugins. Installation runs trusted code with the daemon user’s access. - -The plugin detects installed be-concise versions in the Claude and Codex caches, respecting `CLAUDE_CONFIG_DIR` and `CODEX_HOME`. For a source checkout or custom installation, set `PASEO_CONCISE_ROOT` in the daemon environment to the repository or `plugins/concise` directory, then reload this plugin. - ### Install be-concise from Paseo Open **Be concise → Plugin**, or use the panel that appears when be-concise is missing. Choose a release and press **Install**, **Update**, or **Downgrade** for Claude Code or Codex. The daemon host needs Git and the `claude` or `codex` CLI on `PATH` or in `~/.local/bin`. The plugin downloads the release from [yannelli/be-concise](https://github.com/yannelli/be-concise/releases) into `~/.local/share/paseo-be-concise/releases` and registers it as the `be-concise` marketplace. Claude Code changes use user scope. Restart Claude Code sessions after a change. For Codex, start a new session and review the hooks with `/hooks`. If the marketplace already uses another source, **Switch to GitHub releases** asks for confirmation before it points the marketplace at the release folder. Changes run in the background, and the tab shows the result when the host commands finish. See [be-concise installer](docs/plugin-installer.md) for the exact commands. @@ -96,6 +111,8 @@ The view refreshes every two seconds. It follows the project registry and saved Configuration controls edit the selected file and preserve its other keys. **Save changes** validates through be-concise and checks the file revision. An intervening edit preserves your draft and requires an explicit reload. The effective view uses the daemon’s environment; agent-specific environment overrides apply separately. The interface labels active and inactive layers because creating a higher-priority file changes which configuration is selected. +Session rules that be-concise sends at startup appear as **Rules sent** and do not count as flagged. The tuner reads pasted text only and writes nothing until you save. + Each preview starts with separate retry state. Preview results do not enter live activity. Only the Plugin tab changes the Claude or Codex installation, and only when you press one of its buttons. The plugin does not start another web console. ## Development @@ -106,11 +123,11 @@ paseo plugin reload paseo-be-concise paseo plugin logs paseo-be-concise ``` -Tests use Node.js 24 and temporary home/project directories. They cover activity normalization, retention, stats, installation discovery, config conflicts, file permissions, and preview isolation. Integration tests require an installed be-concise; set `PASEO_CONCISE_ROOT` to a checkout to select it explicitly. +Tests use Node.js 24 and temporary home/project directories. They cover activity normalization, retention, stats, installation discovery, config conflicts, file permissions, preview isolation, form and dictionary edits, and the tuner. Integration tests require an installed be-concise; the tuner and dictionary tests need 0.10.0. Set `PASEO_CONCISE_ROOT` to a checkout to select it explicitly. Paseo API reference: [v0.9 plugins](https://paseo.sh/docs/plugins/v0.9/reference). -See the [documentation index](docs/INDEX.md) for the current composer button contract, screenshot capture details, the be-concise installer commands, and npm publishing. Consult it when you change pill labels, update screenshots, change install behavior, or change the release workflow. +See the [documentation index](docs/INDEX.md) for the current composer button contract, screenshot capture details, be-concise 0.10 support, the be-concise installer commands, and npm publishing. Consult it when you change pill labels, update screenshots, add a configuration control, change install behavior, or change the release workflow. ## Releases diff --git a/backend.test.mts b/backend.test.mts index f6074cd..27f511b 100644 --- a/backend.test.mts +++ b/backend.test.mts @@ -5,6 +5,7 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { ActivityStore, activityStats, normalizeEvent } from "./server/activity.ts"; import { createBackend, discoverInstallation, visibleEnvironment } from "./server/adapter.ts"; +import { compareVersions } from "./shared/versions.ts"; const record = (extra: Record = {}) => ({ cwd: "/project", hook: "check-edit", ts: "2026-09-05T10:30:00.000Z", decision: "allow", @@ -68,12 +69,26 @@ test("stats count interventions, sessions, durations, and thirty minute buckets" assert.equal(activityStats([]).averageMs, 0); }); +test("injected session rules count as context while config warnings stay flagged", () => { + const lifecycle = (response: Record, decision = "flag") => normalizeEvent(record({ + hook: "session-context", event: "SessionStart", decision, request: { hook_event_name: "SessionStart", session_id: "s" }, response, + }), "id")!.event; + const rules = lifecycle({ hookSpecificOutput: { additionalContext: "[concise] Active rules:" } }); + assert.equal(rules.decision, "context"); + assert.equal(rules.tool, "SessionStart"); + assert.equal(lifecycle({ systemMessage: "[concise] config problem", hookSpecificOutput: { additionalContext: "rules" } }).decision, "flag"); + assert.equal(lifecycle({}, "bypass").decision, "bypass"); + assert.equal(normalizeEvent(record({ decision: "flag", response: { systemMessage: "kept" } }), "id")!.event.decision, "flag"); + assert.equal(activityStats([rules]).interventions, 0); +}); + test("environment output includes valid supported flags and excludes arbitrary values", () => { assert.deepEqual(visibleEnvironment({ BEC_MONITOR_PERSIST: "0", BEC_FEATURE_ALWAYS_ENABLE: "aiWriting,comments", BEC_LOG_MAX_SIZE: "5m", BEC_CONFIG_JSON: '{"secret":"hidden"}', BEC_CONFIG_PATH: "/private/path", BEC_API_KEY: "secret", BEC_LOG_ENABLED: "secret", BEC_FEATURE_DISABLE: "api-token", BEC_ALLOW_PHRASES: "private phrase", }), { BEC_MONITOR_PERSIST: "0", BEC_FEATURE_ALWAYS_ENABLE: "aiWriting,comments", BEC_LOG_MAX_SIZE: "5m" }); + assert.deepEqual(visibleEnvironment({ BEC_FEATURE_ENABLE: "dictionary,emDash" }), { BEC_FEATURE_ENABLE: "dictionary,emDash" }); }); async function fixture(t: test.TestContext) { @@ -205,3 +220,50 @@ test("preview isolates sessions and leaves pasted commands and project files une await assert.rejects(stat(join(cwd, "paseo-preview-marker")), { code: "ENOENT" }); assert.equal((await backend.getSnapshot()).events.length, 0); }); + +test("the tuner requires be-concise 0.10.0 and proposes settings from pasted samples", async (t) => { + const { root, cwd, env } = await fixture(t); + await fakeInstallation(join(root, "missing"), "0.9.0"); + const older = createBackend(env); + t.after(older.close); + await assert.rejects(older.runTune({ cwd, kind: "docs", texts: ["Text."] }), /requires be-concise 0\.10\.0 or newer\. This host has 0\.9\.0/); + const installed = await discoverInstallation(process.env); + if (!installed || compareVersions(installed.version, "0.10.0") < 0) return t.skip("be-concise >=0.10.0 is required for tuner coverage"); + const backend = createBackend({ ...env, PASEO_CONCISE_ROOT: installed.root }); + t.after(backend.close); + const sample = "The parser reads each file \u2014 then it writes the index. ".repeat(40); + const result = await backend.runTune({ cwd, kind: "docs", texts: [sample, sample] }); + assert.equal(result.samples, 2); + assert.ok(result.words > 300); + assert.ok(result.evidence.every((item) => typeof item.key === "string" && typeof item.reason === "string")); + assert.equal(typeof result.delta, "object"); + await assert.rejects(stat(join(cwd, ".claude/concise.json")), { code: "ENOENT" }); + await assert.rejects(backend.runTune({ cwd, kind: "essay" as "docs", texts: ["Text."] }), /^Error: unknown kind "essay"/); +}); + +test("settings written by the 0.10 controls pass upstream validation and name the bad entry", async (t) => { + const installed = await discoverInstallation(process.env); + if (!installed || compareVersions(installed.version, "0.10.0") < 0) return t.skip("be-concise >=0.10.0 is required for 0.10 settings coverage"); + const { cwd, env } = await fixture(t); + env.PASEO_CONCISE_ROOT = installed.root; + const backend = createBackend(env); + t.after(backend.close); + const layer = { + context: { perTurn: true }, subagentStop: { exemptAgentTypes: ["Explore"] }, testFilter: { codexPostToolUse: true }, + features: { dictionary: { mode: "deny", entries: [ + { id: "blacklist", match: "exact", value: "blacklist", fix: "denylist" }, + { id: "ticket", match: "regex", value: "\\bJIRA-\\d+\\b", fix: "link the ticket", flags: "i", scopes: ["commit"] }, + ] } }, + ignoreGlobs: ["**/dist/**"], allowList: { phrases: ["load-bearing"] }, log: { rotate: "daily", maxSize: "1m", maxFiles: 2 }, + }; + const saved = await backend.saveConfiguration({ cwd, id: "project-claude", text: JSON.stringify(layer), revision: null }); + const effective = saved.effective as typeof layer & { problems: unknown[] }; + assert.equal(effective.context.perTurn, true); + assert.deepEqual(effective.subagentStop.exemptAgentTypes, ["Explore"]); + assert.deepEqual(effective.features.dictionary.entries.map(({ id }) => id), ["blacklist", "ticket"]); + assert.deepEqual(effective.ignoreGlobs, ["**/dist/**"]); + assert.deepEqual(effective.problems, []); + const revision = saved.layers.find(({ id }) => id === "project-claude")!.revision; + const broken = { features: { dictionary: { entries: [{ id: "ok", match: "exact", value: "a", fix: "b" }, { id: "bad", match: "regex", value: "(", fix: "c" }] } } }; + await assert.rejects(backend.saveConfiguration({ cwd, id: "project-claude", text: JSON.stringify(broken), revision }), /features\.dictionary\.entries\[1\]: value does not compile/); +}); diff --git a/client/activity.tsx b/client/activity.tsx index 6f9ca82..77bd42c 100644 --- a/client/activity.tsx +++ b/client/activity.tsx @@ -7,11 +7,21 @@ import { Pressable, Text, TextInput, View } from "react-native"; import { type ActivityEvent, type ActivityStats, eventDetail } from "../shared/contracts"; import { Button, Card, decisionColor, Label, Row } from "./ui"; -const filters = [ +const ATTENTION = ["deny", "ask", "block", "error"]; +const knownFilters = [ { value: "all", label: "All decisions" }, { value: "attention", label: "Needs attention" }, { value: "allow", label: "Allowed" }, - { value: "flag", label: "Flagged" }, { value: "rewrite", label: "Rewritten" }, { value: "bypass", label: "Bypassed" }, + { value: "flag", label: "Flagged" }, { value: "rewrite", label: "Rewritten" }, { value: "filter", label: "Filtered" }, + { value: "bypass", label: "Bypassed" }, { value: "context", label: "Rules sent" }, ]; +function decisionFilters(stats: ActivityStats) { + const count = (names: string[]) => stats.decisions.reduce((sum, item) => sum + (names.includes(item.name) ? item.count : 0), 0); + const known = new Set([...knownFilters.map((item) => item.value), ...ATTENTION]); + const extra = stats.decisions.filter((item) => !known.has(item.name)).map((item) => ({ value: item.name, label: item.name })); + return [...knownFilters, ...extra].map((item) => ({ ...item, + count: item.value === "all" ? stats.total : count(item.value === "attention" ? ATTENTION : [item.value]) })); +} + export function Activity({ theme, compact, events, stats, paused, onPause }: { theme: PluginTheme; compact: boolean; events: ActivityEvent[]; stats: ActivityStats; paused: boolean; onPause: () => void; }) { @@ -20,8 +30,9 @@ export function Activity({ theme, compact, events, stats, paused, onPause }: { const [limit, setLimit] = useState(60); const [statsOpen, setStatsOpen] = useState(false); const [filtersOpen, setFiltersOpen] = useState(false); + const filters = decisionFilters(stats); const visible = useMemo(() => events.filter((event) => - (filter === "all" || (filter === "attention" ? ["deny", "ask", "block", "error"].includes(event.decision) : event.decision === filter)) && + (filter === "all" || (filter === "attention" ? ATTENTION.includes(event.decision) : event.decision === filter)) && `${event.tool} ${event.hook} ${event.target} ${event.summary} ${event.session} ${event.projectName}`.toLowerCase().includes(search.toLowerCase()) ), [events, filter, search]); const maximum = Math.max(1, ...stats.minutes.map((minute) => minute.count)); @@ -64,7 +75,7 @@ export function Activity({ theme, compact, events, stats, paused, onPause }: { setFiltersOpen(!filtersOpen)} style={{ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", gap: 6, minHeight: 30, paddingHorizontal: 8, borderWidth: 1, borderColor: theme.colors.border, borderRadius: 6 }}> - + {filtersOpen && @@ -73,7 +84,8 @@ export function Activity({ theme, compact, events, stats, paused, onPause }: { style={({ pressed }) => ({ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, minHeight: 30, borderRadius: 4, backgroundColor: pressed || filter === item.value ? theme.colors.surface2 : "transparent" })}> {filter === item.value && } - + + {item.count} )} } diff --git a/client/composer.tsx b/client/composer.tsx index 2704809..90c4692 100644 --- a/client/composer.tsx +++ b/client/composer.tsx @@ -24,6 +24,7 @@ const decisionGroups = [ { label: "rejected", names: ["deny", "block"], decision: "deny" }, { label: "flagged", names: ["ask", "flag"], decision: "flag" }, { label: "rewritten", names: ["rewrite"], decision: "rewrite" }, + { label: "filtered", names: ["filter"], decision: "filter" }, { label: "bypassed", names: ["bypass"], decision: "bypass" }, { label: "errors", names: ["error"], decision: "error" }, ]; diff --git a/client/config-form.tsx b/client/config-form.tsx new file mode 100644 index 0000000..ba89564 --- /dev/null +++ b/client/config-form.tsx @@ -0,0 +1,161 @@ +import type { PluginTheme } from "@getpaseo/plugin"; +import { Icon } from "@getpaseo/plugin/client/react-native"; +import React, { type ReactNode, useState } from "react"; +import { Pressable, Text, View } from "react-native"; +import { Button, Chips, Field, Label, Notice, Row, Toggle } from "./ui"; + +export type JsonObject = Record; +export type Form = { + theme: PluginTheme; compact: boolean; value: JsonObject; effective: JsonObject; defaults: JsonObject; top: boolean; + set(path: string, value: unknown): void; replace(value: JsonObject): void; +}; + +export const isObject = (value: unknown): value is JsonObject => value !== null && typeof value === "object" && !Array.isArray(value); +export const at = (value: unknown, path: string): unknown => path.split(".").reduce((node, key) => isObject(node) ? node[key] : undefined, value); +export const supports = (form: Form, path: string) => at(form.defaults, path) !== undefined; +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); +const show = (value: unknown) => value === null ? "default" : typeof value === "string" ? value + : Array.isArray(value) ? `${value.length} ${value.length === 1 ? "item" : "items"}` : JSON.stringify(value); +const strings = (value: unknown) => Array.isArray(value) ? value.map(String) : []; + +export function update(source: JsonObject, path: string, value: unknown): JsonObject { + const [key, ...rest] = path.split("."); + const next = { ...source }; + if (rest.length) { + const child = update(isObject(next[key]) ? next[key] : {}, rest.join("."), value); + if (Object.keys(child).length) next[key] = child; + else delete next[key]; + } else if (value === undefined) delete next[key]; + else next[key] = value; + return next; +} + +/** Mirrors be-concise mergeDelta: objects merge, lists gain new items, other values replace. */ +export function mergeDelta(target: JsonObject, delta: JsonObject): JsonObject { + const next = { ...target }; + for (const [key, value] of Object.entries(delta)) { + const current = next[key]; + if (isObject(value)) next[key] = mergeDelta(isObject(current) ? current : {}, value); + else if (Array.isArray(value) && Array.isArray(current)) next[key] = [...current, ...value.filter((item) => !current.some((prior) => same(prior, item)))]; + else next[key] = value; + } + return next; +} + +function Origin({ form, path, compare }: { form: Form; path: string; compare: boolean }) { + const own = at(form.value, path); + const effective = at(form.effective, path); + const text = own === undefined ? "Inherited" : !compare || same(own, effective) ? "Set in this file" : `Set in this file · Effective value: ${show(effective)}`; + return ; +} + +export function Setting({ form, path, label, hint, children, below, compare = true }: { + form: Form; path: string; label: string; hint?: string; children?: ReactNode; below?: ReactNode; compare?: boolean; +}) { + const own = at(form.value, path) !== undefined; + return + + + + {!!hint && } + + + {own &&