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.
- package/LICENSE +21 -0
- package/README.md +228 -0
- package/cordis.patch.yml +17 -0
- package/lib/api.js +108 -0
- package/lib/client.js +295 -0
- package/lib/command.js +30 -0
- package/lib/core.js +165 -0
- package/lib/index.js +81 -0
- package/lib/invariant.js +29 -0
- package/lib/rules.js +83 -0
- package/lib/store.js +130 -0
- package/lib/types/api.d.ts +18 -0
- package/lib/types/command.d.ts +19 -0
- package/lib/types/core.d.ts +48 -0
- package/lib/types/index.d.ts +36 -0
- package/lib/types/invariant.d.ts +21 -0
- package/lib/types/rules.d.ts +45 -0
- package/lib/types/store.d.ts +39 -0
- package/package.json +91 -0
- package/src/api.ts +106 -0
- package/src/command.ts +46 -0
- package/src/core.ts +197 -0
- package/src/index.ts +105 -0
- package/src/invariant.ts +37 -0
- package/src/rules.ts +116 -0
- package/src/store.ts +133 -0
|
@@ -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
|
+
}
|
package/src/invariant.ts
ADDED
|
@@ -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))
|