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
4 changes: 3 additions & 1 deletion deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"tasks": {
"fmt": "deno run -A npm:@biomejs/biome check --write --error-on-warnings",
"fmt:check": "deno lint && deno check && deno run -A npm:@biomejs/biome check --error-on-warnings",
"kvctl": "deno run -A --env-file=.env packages/kvctl/kvctl.ts",
"rust:check": "cargo check",
"rust:clippy": "cargo clippy --all-targets -- -D warnings",
"wasm:build": "wasm-pack build crates/wasm --target web --out-dir pkg --out-name wasm",
Expand All @@ -33,6 +34,7 @@
"./crates/wasm",
"./packages/web",
"./packages/core",
"./packages/core/integration"
"./packages/core/integration",
"./packages/kvctl"
]
}
156 changes: 80 additions & 76 deletions deno.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/core/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
ReviewPassSchema,
ToolNameSchema,
} from "@/config/types.ts";
export { pluralize } from "@/format.ts";
export { measure, measureAsync } from "@/measure.ts";
export { KvAdapter } from "@/persistence/kv_adapter.ts";
export {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Singular for exactly 1, plural otherwise. Pass `plural` for irregular
// forms ("entry" / "entries").
export function pluralize(count: number, singular: string, plural?: string) {
return count === 1 ? singular : (plural ?? `${singular}s`);
}
14 changes: 14 additions & 0 deletions packages/core/src/format_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { assertEquals } from "@std/assert";
import { pluralize } from "./format.ts";

Deno.test("pluralize -- singular for 1, plural otherwise", () => {
assertEquals(pluralize(0, "word"), "words");
assertEquals(pluralize(1, "word"), "word");
assertEquals(pluralize(2, "word"), "words");
assertEquals(pluralize(1234, "word"), "words");
});

Deno.test("pluralize -- irregular form", () => {
assertEquals(pluralize(1, "entry", "entries"), "entry");
assertEquals(pluralize(3, "entry", "entries"), "entries");
});
20 changes: 20 additions & 0 deletions packages/kvctl/commands/explore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Command } from "@cliffy/command";
import { pluralize } from "@essayist/core";
import type { KvctlGlobals } from "@/globals.ts";
import { withKv } from "@/kv.ts";
import { pprint } from "@/utils/pprint.ts";

export const explore = new Command<KvctlGlobals>()
.description("List keys, optionally under a tuple prefix.")
.arguments("[prefix...:string]")
.action(({ target, local }, ...prefix: string[]) =>
withKv({ target, local }, async ({ kv }) => {
let n = 0;
for await (const entry of kv.list({ prefix })) {
n++;
pprint([entry.key, entry.value]);
}
// footer goes to stderr so piped stdout stays pure JSON
console.error(`(${n} ${pluralize(n, "entry", "entries")})`);
}),
);
24 changes: 24 additions & 0 deletions packages/kvctl/commands/grant-role.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Command, EnumType } from "@cliffy/command";
import { USER_ROLES } from "@essayist/core";
import type { KvctlGlobals } from "@/globals.ts";
import { withKv } from "@/kv.ts";

const ROLE = new EnumType([...USER_ROLES]);

export const grantRole = new Command<KvctlGlobals>()
.description("Set a user's site-wide role.")
.type("role", ROLE)
.arguments("<emailOrId:string> <role:role>")
.action(({ target, local }, emailOrId: string, role: "admin" | "writer") =>
withKv({ target, local }, async ({ workspaceStore }) => {
let user = await workspaceStore.getUserByEmail(emailOrId);
if (!user && /^[0-9a-f-]{36}$/i.test(emailOrId))
user = await workspaceStore.getUser(emailOrId);
if (!user) {
console.error(`no user matching "${emailOrId}"`);
Deno.exit(1);
}
const updated = await workspaceStore.setUserRole(user.id, role);
console.log(`granted ${role} to ${updated?.email} (${updated?.id})`);
}),
);
20 changes: 20 additions & 0 deletions packages/kvctl/commands/list-users.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Command } from "@cliffy/command";
import type { User } from "@essayist/core";
import type { KvctlGlobals } from "@/globals.ts";
import { withKv } from "@/kv.ts";

export const listUsers = new Command<KvctlGlobals>()
.description("List users.")
.action(({ target, local }) =>
withKv({ target, local }, async ({ kv }) => {
let n = 0;
for await (const entry of kv.list<User>({ prefix: ["users"] })) {
const u = entry.value;
console.log(
`${u.id} ${u.email.padEnd(24)} role=${u.role ?? "writer"}`,
);
n++;
}
console.log(`(${n} users)`);
}),
);
91 changes: 91 additions & 0 deletions packages/kvctl/commands/seed-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Command } from "@cliffy/command";
import type { KvctlGlobals } from "@/globals.ts";
import { withKv } from "@/kv.ts";

export const seedConfig = new Command<KvctlGlobals>()
.description("Seed default review config.")
.action(({ target, local }) =>
withKv({ target, local }, async ({ config }) => {
const poolId = "free-pool";
await config.saveModelPool({
id: poolId,
name: "Free pool",
models: [
"poolside/laguna-s-2.1:free",
"nvidia/nemotron-3.5-lightning:free",
],
});

// Default prompts are generic placeholders.
const systemPromptKey = "system.reviewer";
const instructionsPromptKey = "instructions.mark";
const directivePromptKey = "directive.review";
const prompts = [
{
key: systemPromptKey,
body: "You are an experienced editor and writing teacher. You review the user's literary work and leave constructive, specific annotations. You never rewrite the work; you only read and mark it.",
},
{
key: instructionsPromptKey,
body: "Read the relevant files, then place all annotations for a file in a single mark call, passing every mark in the marks array. Each mark must use one of the allowed labels and a concise, actionable comment.",
},
{
key: directivePromptKey,
body: 'Review the file "{{file}}". Read it, then mark issues using the allowed labels.',
},
];
for (const p of prompts) await config.savePrompt(p);

const categories = [
{
id: "thesis",
label: "thesis",
description: "Thesis and argument clarity",
color: "oklch(65% 0.4 260)",
},
{
id: "evidence",
label: "evidence",
description: "Evidence and support",
color: "oklch(65% 0.4 130)",
},
{
id: "structure",
label: "structure",
description: "Organization and flow",
color: "oklch(65% 0.4 90)",
},
{
id: "tone",
label: "tone",
description: "Voice, tone, and register",
color: "oklch(65% 0.4 300)",
},
{
id: "grammar",
label: "grammar",
description: "Grammar, mechanics, usage",
color: "oklch(65% 0.4 355)",
},
] as const;
for (const c of categories) await config.saveCategory(c);

const reviewPassId = "essay-review";
await config.saveReviewPass({
id: reviewPassId,
name: "Essay review",
modelPoolId: poolId,
systemPromptKey,
directivePromptKey,
instructionsPromptKey,
enabledTools: ["read_file", "list_files", "grep", "mark"],
allowedCategoryIds: categories.map((c) => c.id),
maxRounds: 5,
});
await config.setActiveReviewPass(reviewPassId);

console.log(
`seeded default config: model pool '${poolId}', ${prompts.length} prompts, ${categories.length} categories, review pass '${reviewPassId}' (active)`,
);
}),
);
62 changes: 62 additions & 0 deletions packages/kvctl/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Command, EnumType } from "@cliffy/command";
import { ConfigStore, KvAdapter } from "@essayist/core";
import {
FAMILIES,
FAMILY_ORDER,
type FamilyKey,
type SyncCtx,
warnBrokenRefs,
} from "@/families.ts";
import type { KvctlGlobals } from "@/globals.ts";
import { CATEGORIES_EPOCH, LOCAL_KV, resolveTarget } from "@/kv.ts";

const FAMILY = new EnumType([...FAMILY_ORDER, "all"]);

export const sync = new Command<KvctlGlobals>()
.description(
"Copy config entities from a source KV into the target. Non-destructive unless --prune. Review passes are checked for dangling references after syncing.",
)
.type("family", FAMILY)
.arguments("<family:family>")
.option(
"--from <kv:string>",
"Source KV path or URL. Defaults to the local playground KV.",
{ default: LOCAL_KV },
)
.option("--prune", "Also delete target entries missing from the source.", {
default: false,
})
.action(async ({ target, local, from, prune }, family) => {
const sourcePath = from ?? LOCAL_KV;
const targetPath = resolveTarget({ target, local });
if (sourcePath === targetPath) {
console.error(
`source and target are both ${sourcePath}; nothing to sync`,
);
Deno.exit(1);
}
const keys: FamilyKey[] =
family === "all" ? FAMILY_ORDER : [family as FamilyKey];
const sourceKv = await Deno.openKv(sourcePath);
const targetKv = await Deno.openKv(targetPath);
try {
const ctx: SyncCtx = {
source: new ConfigStore(new KvAdapter(sourceKv)),
target: new ConfigStore(new KvAdapter(targetKv)),
kv: targetKv,
};
for (const key of keys) {
const { line, changed } = await FAMILIES[key](ctx, prune);
console.log(line);
if (key === "categories" && changed) {
// A running instance watches this key; bump it so the new
// categories are picked up immediately.
await ctx.kv.set(CATEGORIES_EPOCH, Date.now());
}
}
if (keys.includes("passes") || prune) await warnBrokenRefs(ctx);
} finally {
sourceKv.close();
targetKv.close();
}
});
17 changes: 17 additions & 0 deletions packages/kvctl/commands/wipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Command } from "@cliffy/command";
import type { KvctlGlobals } from "@/globals.ts";
import { withKv } from "@/kv.ts";

export const wipe = new Command<KvctlGlobals>()
.description("Delete keys, optionally under a tuple prefix.")
.arguments("[prefix...:string]")
.action(({ target, local }, ...prefix: string[]) =>
withKv({ target, local }, async ({ kv }) => {
let n = 0;
for await (const entry of kv.list({ prefix })) {
await kv.delete(entry.key);
n++;
}
console.log(`deleted ${n} keys`);
}),
);
13 changes: 13 additions & 0 deletions packages/kvctl/deno.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"exports": "./kvctl.ts",
"imports": {
"@/": "./",
"@cliffy/command": "jsr:@cliffy/command@^1.2.1",
"node:url": "node:url"
},
"name": "@essayist/kvctl",
"tasks": {
"kvctl": "deno run -A --env-file=../../.env kvctl.ts"
},
"version": "0.1.0"
}
Loading