dsh-baize-rules 0.1.0

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.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * User-set rule data model, scope reconciliation, and the model-visible
3
+ * rendering shared by the pre-step injection and the /rules command.
4
+ *
5
+ * @module @deepseek-ai/dsh-rules/rules
6
+ */
7
+ export type RuleScope = 'global' | 'session' | 'project';
8
+ /** One user requirement. Rules are plain text — the must-do / must-not intent is
9
+ * expressed in the text itself (e.g. "用中文写注释" vs "不要改测试"), so there is
10
+ * no separate kind tag. */
11
+ export interface Rule {
12
+ /** Stable id minted once; the /rules command addresses rules by it. */
13
+ readonly id: string;
14
+ readonly text: string;
15
+ /** A rule may be temporarily paused without being deleted. */
16
+ readonly enabled: boolean;
17
+ readonly createdAt: number;
18
+ readonly updatedAt: number;
19
+ }
20
+ /** Ordered, deduplicated rule sets per scope for a single session view. */
21
+ export interface RuleView {
22
+ readonly global: readonly Rule[];
23
+ readonly session: readonly Rule[];
24
+ readonly project?: readonly Rule[];
25
+ }
26
+ /** Concatenate global then session rules, honoring scope precedence. */
27
+ export declare function activeRules(view: RuleView): readonly Rule[];
28
+ /** Escape literal closing tags so user text cannot close the plugin frame. */
29
+ export declare function escapeReminder(text: string): string;
30
+ /** Enforce the configured byte budget by keeping the *prefix* (up to `budgetBytes`
31
+ * UTF-8 bytes) and dropping the tail. Precedence is thus decided by callers:
32
+ * {@link renderRules} lays the most specific section first, so a tight budget
33
+ * sheds the broadest (global) rules before any specific (session/project) rule. */
34
+ export declare function enforceBudget(lines: readonly string[], budgetBytes: number): {
35
+ lines: readonly string[];
36
+ omitted: number;
37
+ };
38
+ /** Render the full model-visible <system-reminder> text, or undefined when empty.
39
+ * Sections are laid out specific-first (project > session > global) so prefix
40
+ * retention under {@link enforceBudget} keeps the specific rules and sheds the
41
+ * broadest (global) rules first. Returns undefined when no rule bullet survives
42
+ * the budget, so we never inject a boilerplate-only reminder. */
43
+ export declare function renderRules(view: RuleView, budgetBytes: number): string | undefined;
44
+ /** SHA-1 digest of the rendered text, used to suppress redundant injection. */
45
+ export declare function renderDigest(view: RuleView, budgetBytes: number): Promise<string | undefined>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Durable rule store: `global` and `session` rules both persist to JSON files
3
+ * under DSH_HOME — `global` to `$DSH_HOME/rules/global.json`, and each session's
4
+ * rules to `$DSH_HOME/rules/sessions/<sessionId>.json`. Files are read lazily at
5
+ * every view/pre-step and written on every mutating `/rules` command, so both
6
+ * scopes survive process restarts. `ctx.fs.writeText` creates parent dirs.
7
+ *
8
+ * Note: an event-sourced `rules/set` session event (方案 A) would be the cleanest
9
+ * "model-visible ⟺ logged" guarantee, but the public `Session.append` cannot mark
10
+ * an out-of-repo event type `ignorable`, so a harness reading such a log would
11
+ * refuse to reconstruct it. The per-session file is the pragmatic durable path.
12
+ *
13
+ * Pure CRUD helpers (addRule/removeRule/mutate/newRule) live in `core.ts`.
14
+ *
15
+ * @module dsh-baize-rules/store
16
+ */
17
+ import type { Context } from '@deepseek-ai/cordis';
18
+ import type { Agent } from '@deepseek-ai/dsh-agent';
19
+ import type { Rule, RuleView } from './rules.ts';
20
+ /** Read a project's rule set from its durable file (empty when missing). */
21
+ export declare function readProject(ctx: Context, projectId: unknown): Promise<Rule[]>;
22
+ /** Replace a project's rule set in its durable file. */
23
+ export declare function writeProject(ctx: Context, projectId: unknown, rules: readonly Rule[]): Promise<void>;
24
+ /** Read the global rule set, tolerating a missing file and failing loud on a malformed store. */
25
+ export declare function readGlobal(ctx: Context, config: {
26
+ globalRulesPath?: string;
27
+ }): Promise<Rule[]>;
28
+ /** Write the global rule set. */
29
+ export declare function writeGlobal(ctx: Context, config: {
30
+ globalRulesPath?: string;
31
+ }, rules: readonly Rule[]): Promise<void>;
32
+ /** Read the per-session rule set from its durable file (empty when missing). */
33
+ export declare function readSession(ctx: Context, sessionId: unknown): Promise<Rule[]>;
34
+ /** Replace the per-session rule set in its durable file. */
35
+ export declare function writeSession(ctx: Context, sessionId: unknown, rules: readonly Rule[]): Promise<void>;
36
+ /** Assemble the merged view used for injection and /rules list. */
37
+ export declare function view(ctx: Context, agent: Agent, config: {
38
+ globalRulesPath?: string;
39
+ }): Promise<RuleView>;
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "dsh-baize-rules",
3
+ "description": "User-set session/global must-do and must-not requirements injected at conversation start (Baize).",
4
+ "version": "0.1.0",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "main": "lib/index.js",
11
+ "types": "lib/types/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./lib/types/index.d.ts",
15
+ "default": "./lib/index.js"
16
+ },
17
+ "./invariant": {
18
+ "types": "./lib/types/invariant.d.ts",
19
+ "default": "./lib/invariant.js"
20
+ },
21
+ "./client": {
22
+ "types": "./lib/types/client/index.d.ts",
23
+ "default": "./lib/client.js"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib",
30
+ "src",
31
+ "cordis.patch.yml",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "dev:render": "tsx scripts/dev-render.ts",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest --watch",
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "typecheck": "tsc --noEmit"
44
+ },
45
+ "dsh": {
46
+ "bundle": {
47
+ "patch": "./cordis.patch.yml"
48
+ },
49
+ "client": {
50
+ "inject": [
51
+ "slots",
52
+ "locale",
53
+ "layout",
54
+ "connection"
55
+ ],
56
+ "platform": "web"
57
+ }
58
+ },
59
+ "peerDependencies": {
60
+ "@deepseek-ai/cordis": "^4.0.1",
61
+ "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
62
+ "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
63
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
64
+ "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
65
+ "@deepseek-ai/dsh-fs": "^0.1.1-rc.2",
66
+ "@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2",
67
+ "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
68
+ "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
69
+ "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
70
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
71
+ "@deepseek-ai/schemastery": "^3.18.1",
72
+ "react": "^18.2.0"
73
+ },
74
+ "devDependencies": {
75
+ "@deepseek-ai/cordis": "4.0.1",
76
+ "@deepseek-ai/dsh-agent": "0.1.1-rc.2",
77
+ "@deepseek-ai/dsh-commands": "0.1.1-rc.2",
78
+ "@deepseek-ai/dsh-fs": "0.1.1-rc.2",
79
+ "@deepseek-ai/dsh-home-paths": "0.1.1-rc.2",
80
+ "@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
81
+ "@deepseek-ai/dsh-invariants": "0.1.1-rc.2",
82
+ "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
83
+ "@deepseek-ai/dsh-session": "0.1.1-rc.2",
84
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.2",
85
+ "@deepseek-ai/schemastery": "3.18.1",
86
+ "@types/node": "^22.10.2",
87
+ "tsx": "^4.19.2",
88
+ "typescript": "^5.9.3",
89
+ "vitest": "^2.1.9"
90
+ }
91
+ }
package/src/api.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Host HTTP API for the rules panel. The client (`lib/client.js`) fetches rules
3
+ * via GET and applies mutations via POST, reusing the same store + core as the
4
+ * `/baize-rules` command (so the panel and command are the same source of truth).
5
+ *
6
+ * Routes (json):
7
+ * GET /baize-rules.api?sessionId=... -> { global, session }
8
+ * POST /baize-rules.api { sessionId, raw, scope } -> { ok, text, view }
9
+ *
10
+ * @module dsh-baize-rules/api
11
+ */
12
+
13
+ import type { Context } from '@deepseek-ai/cordis'
14
+ import type {} from '@deepseek-ai/dsh-host-webserver' // augments Context with `webServer`
15
+ import type { IncomingMessage, ServerResponse } from 'node:http'
16
+ import type { RuleScope } from './rules.ts'
17
+ import { runCommand } from './core.ts'
18
+ import { view, writeGlobal, writeSession, writeProject, readProject } from './store.ts'
19
+
20
+ type RulesApiOptions = { globalRulesPath?: string }
21
+
22
+ function sendJson(res: unknown, status: number, payload: unknown): void {
23
+ const r = res as { writeHead(s: number, h: Record<string, string>): void; end(body: string): void }
24
+ r.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
25
+ r.end(JSON.stringify(payload))
26
+ }
27
+
28
+ function readBody(req: unknown): Promise<string> {
29
+ return new Promise((resolve) => {
30
+ const r = req as { on(e: string, cb: (c: string) => void): void }
31
+ let body = ''
32
+ r.on('data', (chunk) => { body += chunk })
33
+ r.on('end', () => resolve(body))
34
+ r.on('error', () => resolve(''))
35
+ })
36
+ }
37
+
38
+ function parseBody(text: string): Record<string, unknown> {
39
+ try { return JSON.parse(text || '{}') } catch { return {} }
40
+ }
41
+
42
+ /** Register the rules panel API on the host web server. */
43
+ export function registerRulesApi(ctx: Context, options: RulesApiOptions = {}): () => void {
44
+ if (ctx.webServer === undefined) return () => {}
45
+ return ctx.webServer.register({
46
+ kind: 'exact',
47
+ path: '/baize-rules.api',
48
+ handler: async (req: IncomingMessage, res: ServerResponse) => {
49
+ const method = req.method ?? ''
50
+ const url = req.url ?? ''
51
+ const query = new URLSearchParams(url.split('?')[1] ?? '')
52
+ const sessionId = query.get('sessionId') ?? ''
53
+ const project = await resolveProject(ctx, sessionId, query.get('project') ?? '')
54
+
55
+ if (method === 'GET') {
56
+ try {
57
+ sendJson(res, 200, await viewFor(ctx, sessionId, project, options))
58
+ } catch (e) {
59
+ sendJson(res, 500, { error: String(e) })
60
+ }
61
+ return
62
+ }
63
+
64
+ if (method === 'POST') {
65
+ const body = parseBody(await readBody(req))
66
+ const pSessionId = String(body.sessionId ?? '')
67
+ const pProject = await resolveProject(ctx, pSessionId, String(body.project ?? ''))
68
+ const raw = String(body.raw ?? '')
69
+ const defaultScope = (body.scope ?? 'session') as RuleScope
70
+ if (!raw.trim()) { sendJson(res, 400, { error: 'empty raw' }); return }
71
+ try {
72
+ const v = await viewFor(ctx, pSessionId, pProject, options)
73
+ const out = runCommand({ raw, view: v, defaultScope })
74
+ if (out.nextView !== undefined) {
75
+ await writeGlobal(ctx, options, out.nextView.global)
76
+ if (pSessionId) await writeSession(ctx, pSessionId, out.nextView.session)
77
+ if (pProject) await writeProject(ctx, pProject, out.nextView.project ?? [])
78
+ }
79
+ sendJson(res, out.ok ? 200 : 400, { ok: out.ok, text: out.text, view: out.nextView ?? v })
80
+ } catch (e) {
81
+ sendJson(res, 500, { error: String(e) })
82
+ }
83
+ return
84
+ }
85
+
86
+ sendJson(res, 405, { error: 'method not allowed' })
87
+ },
88
+ })
89
+ }
90
+
91
+ /** Resolve the project key (cwd) from an explicit `project`, else the session's header cwd. */
92
+ async function resolveProject(ctx: Context, sessionId: string, project: string): Promise<string> {
93
+ if (project) return project
94
+ if (!sessionId) return ''
95
+ try {
96
+ const sess = (ctx as Context & { sessions?: { get?(id: string): { header?: { cwd?: string } } | undefined } }).sessions?.get?.(sessionId)
97
+ return (sess && sess.header?.cwd) || ''
98
+ } catch { return '' }
99
+ }
100
+
101
+ /** Load global (disk) + session (per-session file) + project (per-cwd file) rules. */
102
+ async function viewFor(ctx: Context, sessionId: string, project: string, options: RulesApiOptions) {
103
+ const v = await view(ctx, { id: sessionId } as never, options)
104
+ const proj = project ? await readProject(ctx, project) : []
105
+ return { ...v, project: proj }
106
+ }
package/src/command.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Thin dsh adapter for the `/rules` command: feeds the live `view` + current
3
+ * `defaultScope` into the dependency-free decision core (`core.ts`), then
4
+ * persists the resulting `nextView` (global → disk, session → memory) and any
5
+ * `/rules scope` default change.
6
+ *
7
+ * @module dsh-baize-rules/command
8
+ */
9
+
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+ import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
12
+ import type { RuleScope } from './rules.ts'
13
+ import { runCommand } from './core.ts'
14
+ import { view, writeGlobal, writeSession, readProject, writeProject } from './store.ts'
15
+
16
+ /** Runtime state the command handler needs from the plugin's `apply`. */
17
+ export interface RulesRuntime {
18
+ readonly globalRulesPath?: string
19
+ readonly getScope: () => RuleScope
20
+ readonly setScope: (scope: RuleScope) => void
21
+ }
22
+
23
+ /** The registered handler contract: adapt command input → core decision → persist. */
24
+ export async function handle(
25
+ ctx: Context,
26
+ invocation: CommandInvocation,
27
+ runtime: RulesRuntime,
28
+ ): Promise<CommandResult> {
29
+ const cwd = (invocation.agent.session?.header as { cwd?: string } | undefined)?.cwd ?? ''
30
+ const v = await view(ctx, invocation.agent, { globalRulesPath: runtime.globalRulesPath })
31
+ const viewWithProject = cwd ? { ...v, project: await readProject(ctx, cwd) } : v
32
+ const out = runCommand({
33
+ raw: invocation.rawInput,
34
+ view: viewWithProject,
35
+ defaultScope: runtime.getScope(),
36
+ })
37
+
38
+ if (out.nextView !== undefined) {
39
+ await writeGlobal(ctx, { globalRulesPath: runtime.globalRulesPath }, out.nextView.global)
40
+ await writeSession(ctx, invocation.agent.id, out.nextView.session)
41
+ if (cwd) await writeProject(ctx, cwd, out.nextView.project ?? [])
42
+ }
43
+ if (out.defaultScope !== undefined) runtime.setScope(out.defaultScope)
44
+
45
+ return out.ok ? { kind: 'success', text: out.text } : { kind: 'error', text: out.text }
46
+ }
package/src/core.ts ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Dependency-free rules-command core: parsing, scope resolution, and CRUD
3
+ * application over a {@link RuleView}. No dsh runtime needed, so it is testable
4
+ * in the Loop 0 harness (`pnpm test`). The dsh-facing adapter (`command.ts`)
5
+ * feeds in the live `view` + `defaultScope` and persists `nextView`/`defaultScope`.
6
+ *
7
+ * @module dsh-baize-rules/core
8
+ */
9
+
10
+ import type { Rule, RuleScope, RuleView } from './rules.ts'
11
+
12
+ export const RULE_SCOPES: readonly RuleScope[] = ['global', 'session', 'project']
13
+ const SCOPE_SET = new Set<RuleScope>(RULE_SCOPES)
14
+ /** Verbs where a trailing scope token is a *target* to modify, not an argument. */
15
+ const SCOPE_MODIFIER_VERBS = new Set<CommandVerb>(['add', 'remove', 'enable', 'disable'])
16
+
17
+ export type CommandVerb =
18
+ | 'list' | 'add' | 'remove' | 'enable' | 'disable' | 'edit' | 'scope' | 'clear' | 'export'
19
+
20
+ export interface ParsedCommand {
21
+ readonly verb: CommandVerb | 'help' | undefined
22
+ readonly args: readonly string[]
23
+ /** Explicit scope keyword (leading or trailing), if present. */
24
+ readonly scope?: RuleScope
25
+ }
26
+
27
+ export interface CommandInput {
28
+ readonly raw: string
29
+ readonly view: RuleView
30
+ readonly defaultScope: RuleScope
31
+ }
32
+
33
+ export interface CommandOutput {
34
+ readonly ok: boolean
35
+ readonly text: string
36
+ /** Present when the store should be persisted to this new view. */
37
+ readonly nextView?: RuleView
38
+ /** Present when `/rules scope` chose a new default scope. */
39
+ readonly defaultScope?: RuleScope
40
+ }
41
+
42
+ const USAGE =
43
+ 'Usage: /rules [list|add <text>|remove <id>|edit <id> <text>|enable|disable <id>|scope <global|session|project>|clear <scope>|export]'
44
+
45
+ /** Parse a `/rules` line into a verb + args, isolating an explicit scope keyword.
46
+ * Scope is accepted as a **leading** token (`/rules global add …`) or a
47
+ * **trailing** token (`/rules add … global`, only the LAST arg qualifies),
48
+ * so a rule whose text merely contains "global" is never misread as a scope. */
49
+ export function parseCommand(raw: string): ParsedCommand {
50
+ const tokens = raw.trim().split(/\s+/).filter(Boolean)
51
+ if (tokens.length === 0) return { verb: undefined, args: [] }
52
+
53
+ let verb: CommandVerb | 'help' | undefined
54
+ let scope: RuleScope | undefined
55
+ let rest: string[]
56
+
57
+ if (SCOPE_SET.has(tokens[0] as RuleScope)) {
58
+ scope = tokens[0] as RuleScope
59
+ verb = tokens[1] as CommandVerb | undefined
60
+ rest = tokens.slice(2)
61
+ } else {
62
+ verb = tokens[0] as CommandVerb | undefined
63
+ rest = tokens.slice(1)
64
+ // Strip a trailing scope only for verbs that take a *target* scope modifier,
65
+ // never for `scope`/`clear` where the scope is the argument itself.
66
+ if (verb !== undefined && SCOPE_MODIFIER_VERBS.has(verb) && rest.length > 0
67
+ && SCOPE_SET.has(rest[rest.length - 1] as RuleScope)) {
68
+ scope = rest[rest.length - 1] as RuleScope
69
+ rest = rest.slice(0, -1)
70
+ }
71
+ }
72
+ return { verb, args: rest.filter(Boolean), scope }
73
+ }
74
+
75
+ /** Explicit scope wins, else the caller's default. */
76
+ export function resolveScope(scope: RuleScope | undefined, fallback: RuleScope): RuleScope {
77
+ return scope ?? fallback
78
+ }
79
+
80
+ /** Mint a fully-specified rule (dependency-free; uses global `crypto.randomUUID`). */
81
+ export function newRule(text: string, now = Date.now()): Rule {
82
+ return {
83
+ id: crypto.randomUUID(),
84
+ text,
85
+ enabled: true,
86
+ createdAt: now,
87
+ updatedAt: now,
88
+ }
89
+ }
90
+
91
+ /** Return a new view with one rule appended to a scope. */
92
+ function listOf(view: RuleView, scope: RuleScope): readonly Rule[] {
93
+ return scope === 'global' ? view.global : scope === 'project' ? (view.project ?? []) : view.session
94
+ }
95
+ function withList(view: RuleView, scope: RuleScope, list: readonly Rule[]): RuleView {
96
+ if (scope === 'global') return { ...view, global: [...list] }
97
+ if (scope === 'project') return { ...view, project: [...list] }
98
+ return { ...view, session: [...list] }
99
+ }
100
+
101
+ export function addRule(view: RuleView, scope: RuleScope, rule: Rule): RuleView {
102
+ return withList(view, scope, [...listOf(view, scope), rule])
103
+ }
104
+
105
+ /** Return a new view with a rule removed from one scope. */
106
+ export function removeRule(view: RuleView, scope: RuleScope, id: string): RuleView {
107
+ return withList(view, scope, listOf(view, scope).filter(rule => rule.id !== id))
108
+ }
109
+
110
+ /** Mutate a copy of a scope's rules for the given id; returns whether found. */
111
+ export function mutate(view: RuleView, scope: RuleScope, id: string, fn: (rule: Rule) => Rule): RuleView {
112
+ return withList(view, scope, listOf(view, scope).map(rule => rule.id === id ? fn(rule) : rule))
113
+ }
114
+
115
+ /** Render a compact human-readable list of the active rules. */
116
+ export function formatList(view: RuleView): string {
117
+ const line = (rule: Rule) =>
118
+ `[${rule.id.slice(0, 8)}] ${rule.enabled ? '' : '(disabled) '}${rule.text}`
119
+ const parts: string[] = []
120
+ if (view.project && view.project.length > 0) { parts.push('Project:'); parts.push(...view.project.map(line)) }
121
+ if (view.global.length > 0) { parts.push('Global:'); parts.push(...view.global.map(line)) }
122
+ if (view.session.length > 0) { parts.push('Session:'); parts.push(...view.session.map(line)) }
123
+ return parts.length === 0 ? 'No active rules.' : parts.join('\n')
124
+ }
125
+
126
+ /** Apply one parsed `/rules` command to the given view + default scope. */
127
+ export function runCommand(input: CommandInput): CommandOutput {
128
+ const { verb, args, scope } = parseCommand(input.raw)
129
+ if (verb === undefined || verb === 'list') {
130
+ return { ok: true, text: formatList(input.view) }
131
+ }
132
+
133
+ const chosen = resolveScope(scope, input.defaultScope)
134
+
135
+ switch (verb) {
136
+ case 'add': {
137
+ const text = args.join(' ')
138
+ if (text.length === 0) {
139
+ return { ok: false, text: USAGE }
140
+ }
141
+ return {
142
+ ok: true,
143
+ text: `Added rule to ${chosen}: ${text}`,
144
+ nextView: addRule(input.view, chosen, newRule(text)),
145
+ }
146
+ }
147
+
148
+ case 'remove': {
149
+ const id = args[0] ?? ''
150
+ if (id.length === 0) return { ok: false, text: USAGE }
151
+ return {
152
+ ok: true,
153
+ text: `Removed rule ${id} from ${chosen}.`,
154
+ nextView: removeRule(input.view, chosen, id),
155
+ }
156
+ }
157
+
158
+ case 'enable':
159
+ case 'disable': {
160
+ const id = args[0] ?? ''
161
+ if (id.length === 0) return { ok: false, text: USAGE }
162
+ const enable = verb === 'enable'
163
+ const next = mutate(input.view, chosen, id, rule => ({ ...rule, enabled: enable, updatedAt: Date.now() }))
164
+ return { ok: true, text: `${enable ? 'Enabled' : 'Disabled'} rule ${id}.`, nextView: next }
165
+ }
166
+
167
+ case 'edit': {
168
+ const id = args[0] ?? ''
169
+ const text = args.slice(1).join(' ')
170
+ if (id.length === 0 || text.length === 0) return { ok: false, text: USAGE }
171
+ const list = listOf(input.view, chosen)
172
+ if (!list.some(rule => rule.id === id)) return { ok: false, text: `Rule ${id} not found.` }
173
+ const next = mutate(input.view, chosen, id, rule => ({ ...rule, text, updatedAt: Date.now() }))
174
+ return { ok: true, text: `Updated rule ${id}.`, nextView: next }
175
+ }
176
+
177
+ case 'scope': {
178
+ const nextScope = args[0] as RuleScope | undefined
179
+ if (nextScope === undefined || !SCOPE_SET.has(nextScope)) return { ok: false, text: USAGE }
180
+ return { ok: true, text: `Default scope is now ${nextScope}.`, defaultScope: nextScope }
181
+ }
182
+
183
+ case 'clear': {
184
+ const clearScope = args[0] as RuleScope | undefined
185
+ if (clearScope === undefined || !SCOPE_SET.has(clearScope)) return { ok: false, text: USAGE }
186
+ const next = clearScope === 'global' ? { ...input.view, global: [] } : { ...input.view, session: [] }
187
+ return { ok: true, text: `Cleared ${clearScope} rules.`, nextView: next }
188
+ }
189
+
190
+ case 'export': {
191
+ return { ok: true, text: JSON.stringify({ global: input.view.global, session: input.view.session }, null, 2) }
192
+ }
193
+
194
+ default:
195
+ return { ok: false, text: USAGE }
196
+ }
197
+ }
package/src/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * User-set session/global must-do and must-not requirements injected at
3
+ * conversation start. Named for 白泽 (Baize), the beast that knows all and
4
+ * distinguishes right from wrong — hence the must/mustNot framing.
5
+ *
6
+ * @module dsh-baize-rules
7
+ */
8
+
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+ import z from '@deepseek-ai/schemastery'
11
+ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
12
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
13
+ import type { RuleScope } from './rules.ts'
14
+ import { renderDigest, renderRules } from './rules.ts'
15
+ import { view } from './store.ts'
16
+ import { handle, type RulesRuntime } from './command.ts'
17
+ import { registerRulesApi } from './api.ts'
18
+
19
+ /** Cordis plugin name used by loader diagnostics + the injected message source. */
20
+ export const name = 'baize-rules'
21
+
22
+ /** Services granted on `ctx` by cordis before `apply` runs. We use `ctx.agents`
23
+ * (agent-plane scoping), `ctx.commands` (register /baize-rules), `ctx.fs`
24
+ * (persist global + session rule files), and `ctx.webServer` (rules panel API);
25
+ * each must be declared in `inject`. */
26
+ export const inject = ['agents', 'commands', 'fs', 'webServer', 'sessions']
27
+
28
+ /** Policy for the rules plugin. Invalid values fail plugin load. */
29
+ export interface Config {
30
+ /** Default scope that `/rules add|remove|...` edits when the line omits a scope keyword. */
31
+ scope: 'global' | 'session'
32
+ /** Model-visible byte budget; a tight budget sheds the broadest rules first. */
33
+ maxBytes: number
34
+ /** Override the global rules file path (default `$DSH_HOME/rules/global.json`). */
35
+ globalRulesPath?: string
36
+ /** When true, re-render an updated rules message on every step, not just on change. */
37
+ injectAtEveryStep?: boolean
38
+ }
39
+
40
+ /** Schemastery validation for {@link Config}. */
41
+ export const Config: z<Config> = z.object({
42
+ scope: z.union([z.const('global'), z.const('session')]),
43
+ maxBytes: z.number().required(),
44
+ globalRulesPath: z.string(),
45
+ injectAtEveryStep: z.boolean(),
46
+ })
47
+
48
+ /** Per-session digest of the last injected rules, used to suppress duplicate injection. */
49
+ const lastInjected = /* @__PURE__ */ new WeakMap<Agent['session'], string>()
50
+
51
+ /**
52
+ * Register a prepended pre-step listener that injects the rendered rules as a
53
+ * durable user message on conversation start, plus the `/rules` command.
54
+ * @param ctx - plugin context; listener and command dispose with it.
55
+ * @param config - scope, budget, and redundancy policy.
56
+ */
57
+ export function apply(ctx: Context, config: Config): void {
58
+ // `/rules scope` mutates this process-visible default, so the command and the
59
+ // pre-step view always read the current choice.
60
+ const mutableScope: { scope: RuleScope } = { scope: config.scope }
61
+
62
+ ctx.on('agent/pre-step', async (
63
+ { agent, signal },
64
+ next,
65
+ ): Promise<PreStepDecision> => {
66
+ const decision = await next()
67
+ if (decision.kind === 'reject' || signal.aborted) return decision
68
+ const v = await view(ctx, agent, config)
69
+ const text = renderRules(v, config.maxBytes)
70
+ if (text === undefined) return decision
71
+ const digest = await renderDigest(v, config.maxBytes)
72
+ const previous = lastInjected.get(agent.session)
73
+ if (!config.injectAtEveryStep && previous !== undefined && previous === digest) return decision
74
+ lastInjected.set(agent.session, digest ?? '')
75
+ return {
76
+ kind: 'enter',
77
+ messages: [
78
+ ...decision.messages,
79
+ createUserMessage({
80
+ content: [{ type: 'text', text }],
81
+ source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
82
+ }),
83
+ ],
84
+ }
85
+ }, { prepend: true })
86
+
87
+ ctx.effect(function* () {
88
+ const runtime: RulesRuntime = {
89
+ globalRulesPath: config.globalRulesPath,
90
+ getScope: () => mutableScope.scope,
91
+ setScope: (scope) => { mutableScope.scope = scope },
92
+ }
93
+ yield ctx.commands.register({
94
+ name: 'baize-rules',
95
+ description: '查看/增删改 会话或全局的 必须/禁止 要求',
96
+ input: {
97
+ hint: 'list | add <text> | remove <id> | enable|disable <id> | scope <global|session|project> | clear <scope> | export',
98
+ },
99
+ handler: invocation => handle(ctx, invocation, runtime),
100
+ })
101
+ }, 'baize-rules lifecycle')
102
+
103
+ // Host HTTP API for the rules panel (client reads/writes here).
104
+ ctx.effect(() => registerRulesApi(ctx, { globalRulesPath: config.globalRulesPath }), 'baize-rules api')
105
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Package-owned invariant companion for `dsh-baize-rules`.
3
+ *
4
+ * Follows the @deepseek-ai/dsh-invariants contract used by other dsh context
5
+ * plugins (e.g. `@deepseek-ai/dsh-agent-instructions`): a Cordis companion that
6
+ * exports `name` / `inject=['invariants']` / `apply`, and registers itself via
7
+ * `ctx.invariants.register(PACKAGE_NAME, install)`.
8
+ *
9
+ * @module dsh-baize-rules/invariant
10
+ */
11
+
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
14
+
15
+ const PACKAGE_NAME = 'dsh-baize-rules'
16
+
17
+ /** Cordis companion plugin name. */
18
+ export const name = 'baize-rules-invariant'
19
+ /** Service required before the companion can reserve package ownership. */
20
+ export const inject = ['invariants']
21
+
22
+ /**
23
+ * M1 hook: install a real check that any rules-tagged `user/message` in the
24
+ * session log carries the rules plugin's own source marker and reconstructs from
25
+ * the current rule set. For now it is an explainable no-op so it never blocks a
26
+ * session; the reference implementation's naive `registerInvariant` (a nonexistent
27
+ * export from a wrong package name) is replaced by this contract-correct shape.
28
+ */
29
+ const install: InvariantInstaller = () => {}
30
+
31
+ /**
32
+ * Register this package's invariant companion.
33
+ * @param ctx - Cordis context carrying the invariant service.
34
+ * @returns the installed registration's disposer after setup succeeds.
35
+ */
36
+ export const apply = (ctx: Context): Promise<() => void> =>
37
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))