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/src/rules.ts ADDED
@@ -0,0 +1,116 @@
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
+
8
+ export type RuleScope = 'global' | 'session' | 'project'
9
+
10
+ /** One user requirement. Rules are plain text — the must-do / must-not intent is
11
+ * expressed in the text itself (e.g. "用中文写注释" vs "不要改测试"), so there is
12
+ * no separate kind tag. */
13
+ export interface Rule {
14
+ /** Stable id minted once; the /rules command addresses rules by it. */
15
+ readonly id: string
16
+ readonly text: string
17
+ /** A rule may be temporarily paused without being deleted. */
18
+ readonly enabled: boolean
19
+ readonly createdAt: number
20
+ readonly updatedAt: number
21
+ }
22
+
23
+ /** Ordered, deduplicated rule sets per scope for a single session view. */
24
+ export interface RuleView {
25
+ readonly global: readonly Rule[]
26
+ readonly session: readonly Rule[]
27
+ readonly project?: readonly Rule[]
28
+ }
29
+
30
+ /** Concatenate global then session rules, honoring scope precedence. */
31
+ export function activeRules(view: RuleView): readonly Rule[] {
32
+ return [...view.global, ...view.session].filter(rule => rule.enabled)
33
+ }
34
+
35
+ /** Escape literal closing tags so user text cannot close the plugin frame. */
36
+ export function escapeReminder(text: string): string {
37
+ return text.replace(/<\/(?:system-reminder)\s*>/gi, '<\\/system-reminder>')
38
+ }
39
+
40
+ /** Enforce the configured byte budget by keeping the *prefix* (up to `budgetBytes`
41
+ * UTF-8 bytes) and dropping the tail. Precedence is thus decided by callers:
42
+ * {@link renderRules} lays the most specific section first, so a tight budget
43
+ * sheds the broadest (global) rules before any specific (session/project) rule. */
44
+ export function enforceBudget(
45
+ lines: readonly string[],
46
+ budgetBytes: number,
47
+ ): { lines: readonly string[]; omitted: number } {
48
+ if (budgetBytes <= 0) return { lines: [], omitted: lines.length }
49
+ let bytes = 0
50
+ const kept: string[] = []
51
+ for (const line of lines) {
52
+ const next = bytes + Buffer.byteLength(line, 'utf8')
53
+ if (next > budgetBytes) {
54
+ return { lines: kept, omitted: lines.length - kept.length }
55
+ }
56
+ kept.push(line)
57
+ bytes = next
58
+ }
59
+ return { lines: kept, omitted: 0 }
60
+ }
61
+
62
+ /** Section order by precedence, most specific first. Global stays last so a tight
63
+ * budget drops the broadest rules before any specific (session/project) rule. */
64
+ const SCOPE_ORDER: readonly RuleScope[] = ['project', 'session', 'global']
65
+
66
+ function sectionHeader(scope: RuleScope): string {
67
+ switch (scope) {
68
+ case 'project': return 'Project requirements (this directory only):'
69
+ case 'session': return 'Session requirements (this conversation only):'
70
+ default: return 'Global requirements:'
71
+ }
72
+ }
73
+
74
+ const RULE_BULLET = /^- /
75
+
76
+ /** Render the full model-visible <system-reminder> text, or undefined when empty.
77
+ * Sections are laid out specific-first (project > session > global) so prefix
78
+ * retention under {@link enforceBudget} keeps the specific rules and sheds the
79
+ * broadest (global) rules first. Returns undefined when no rule bullet survives
80
+ * the budget, so we never inject a boilerplate-only reminder. */
81
+ export function renderRules(view: RuleView, budgetBytes: number): string | undefined {
82
+ const enabled = (rules: readonly Rule[] | undefined): readonly Rule[] =>
83
+ (rules ?? []).filter(rule => rule.enabled)
84
+
85
+ const sections = SCOPE_ORDER
86
+ .map(scope => ({
87
+ scope,
88
+ rules: enabled(scope === 'project' ? (view as { project?: readonly Rule[] }).project
89
+ : scope === 'session' ? view.session : view.global),
90
+ }))
91
+ .filter(section => section.rules.length > 0)
92
+ if (sections.length === 0) return undefined
93
+
94
+ const bullet = (rule: Rule): string => `- ${escapeReminder(rule.text)}`
95
+ const lines: string[] = [
96
+ 'The following user requirements apply to every step of this conversation. Obey them.',
97
+ 'More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.',
98
+ ]
99
+ for (const section of sections) {
100
+ lines.push('', sectionHeader(section.scope))
101
+ lines.push(...section.rules.map(bullet))
102
+ }
103
+
104
+ const { lines: kept } = enforceBudget(lines, budgetBytes)
105
+ if (!kept.some(line => RULE_BULLET.test(line))) return undefined
106
+ return `<system-reminder>\n${kept.join('\n')}\n</system-reminder>`
107
+ }
108
+
109
+ /** SHA-1 digest of the rendered text, used to suppress redundant injection. */
110
+ export async function renderDigest(view: RuleView, budgetBytes: number): Promise<string | undefined> {
111
+ const text = renderRules(view, budgetBytes)
112
+ if (text === undefined) return undefined
113
+ const bytes = new TextEncoder().encode(text)
114
+ const hash = await crypto.subtle.digest('SHA-1', bytes)
115
+ return [...new Uint8Array(hash)].map(byte => byte.toString(16).padStart(2, '0')).join('')
116
+ }
package/src/store.ts ADDED
@@ -0,0 +1,133 @@
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
+
18
+ import type { Context } from '@deepseek-ai/cordis'
19
+ import type { Agent } from '@deepseek-ai/dsh-agent'
20
+ import type {} from '@deepseek-ai/dsh-fs' // augments Context with `fs`
21
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
22
+ import type { Rule, RuleView } from './rules.ts'
23
+
24
+ /** Resolve the global rules path (config override, else `$DSH_HOME/rules/global.json`). */
25
+ function globalRulesPath(ctx: Context, config: { globalRulesPath?: string }): string {
26
+ return config.globalRulesPath ?? dshHomePath('rules', 'global.json')
27
+ }
28
+
29
+ /** Resolve one session's rules file: `$DSH_HOME/rules/sessions/<sessionId>.json`. */
30
+ function sessionRulesPath(sessionId: unknown): string {
31
+ return dshHomePath('rules', 'sessions', `${String(sessionId)}.json`)
32
+ }
33
+
34
+ /** Slug a project (cwd) into a safe filename for `$DSH_HOME/rules/projects/<slug>.json`. */
35
+ function projectSlug(projectId: unknown): string {
36
+ return String(projectId).replace(/[\\/:*?"<>|]/g, '_').replace(/^_+|_+$/g, '') || '_'
37
+ }
38
+ /** Resolve one project's rules file (project = session cwd). */
39
+ function projectRulesPath(projectId: unknown): string {
40
+ return dshHomePath('rules', 'projects', `${projectSlug(projectId)}.json`)
41
+ }
42
+
43
+ /** Read a project's rule set from its durable file (empty when missing). */
44
+ export async function readProject(ctx: Context, projectId: unknown): Promise<Rule[]> {
45
+ if (ctx.fs === undefined || projectId === undefined || String(projectId).length === 0) return []
46
+ const path = projectRulesPath(projectId)
47
+ const target = await ctx.fs.resolve(path)
48
+ const info = await ctx.fs.stat(target)
49
+ if (info === undefined) return []
50
+ const raw = await ctx.fs.readText(target)
51
+ const parsed: unknown = JSON.parse(raw)
52
+ if (!Array.isArray(parsed)) throw new Error(`rules: project rules file is not a JSON array: ${path}`)
53
+ return sanitizeRules(parsed)
54
+ }
55
+
56
+ /** Replace a project's rule set in its durable file. */
57
+ export async function writeProject(ctx: Context, projectId: unknown, rules: readonly Rule[]): Promise<void> {
58
+ if (ctx.fs === undefined || projectId === undefined || String(projectId).length === 0) return
59
+ const path = projectRulesPath(projectId)
60
+ const target = await ctx.fs.resolve(path)
61
+ await ctx.fs.writeText(target, `${JSON.stringify(rules, null, 2)}\n`)
62
+ }
63
+
64
+ /** Validate a parsed array into well-formed rules; drop the malformed entries loudly.
65
+ * Rules are plain text — there is no `kind` field anymore, but legacy stored
66
+ * entries that carried `kind` are accepted (the field is ignored on rewrite). */
67
+ function sanitizeRules(parsed: readonly unknown[]): Rule[] {
68
+ return parsed.flatMap((item) => {
69
+ if (typeof item !== 'object' || item === null) throw new Error('rules: entry is not an object')
70
+ const rule = item as Record<string, unknown>
71
+ const { text, id } = rule
72
+ if (typeof text !== 'string' || typeof id !== 'string') {
73
+ throw new Error('rules: entry must carry string id and text')
74
+ }
75
+ return [{
76
+ id,
77
+ text,
78
+ enabled: rule.enabled !== false,
79
+ createdAt: typeof rule.createdAt === 'number' ? rule.createdAt : 0,
80
+ updatedAt: typeof rule.updatedAt === 'number' ? rule.updatedAt : 0,
81
+ } satisfies Rule]
82
+ })
83
+ }
84
+
85
+ /** Read the global rule set, tolerating a missing file and failing loud on a malformed store. */
86
+ export async function readGlobal(ctx: Context, config: { globalRulesPath?: string }): Promise<Rule[]> {
87
+ if (ctx.fs === undefined) return []
88
+ const path = globalRulesPath(ctx, config)
89
+ const target = await ctx.fs.resolve(path)
90
+ const info = await ctx.fs.stat(target)
91
+ if (info === undefined) return []
92
+ const raw = await ctx.fs.readText(target)
93
+ const parsed: unknown = JSON.parse(raw)
94
+ if (!Array.isArray(parsed)) throw new Error(`rules: global rules file is not a JSON array: ${path}`)
95
+ return sanitizeRules(parsed)
96
+ }
97
+
98
+ /** Write the global rule set. */
99
+ export async function writeGlobal(ctx: Context, config: { globalRulesPath?: string }, rules: readonly Rule[]): Promise<void> {
100
+ if (ctx.fs === undefined) return
101
+ const path = globalRulesPath(ctx, config)
102
+ const target = await ctx.fs.resolve(path)
103
+ await ctx.fs.writeText(target, `${JSON.stringify(rules, null, 2)}\n`)
104
+ }
105
+
106
+ /** Read the per-session rule set from its durable file (empty when missing). */
107
+ export async function readSession(ctx: Context, sessionId: unknown): Promise<Rule[]> {
108
+ if (ctx.fs === undefined) return []
109
+ const path = sessionRulesPath(sessionId)
110
+ const target = await ctx.fs.resolve(path)
111
+ const info = await ctx.fs.stat(target)
112
+ if (info === undefined) return []
113
+ const raw = await ctx.fs.readText(target)
114
+ const parsed: unknown = JSON.parse(raw)
115
+ if (!Array.isArray(parsed)) throw new Error(`rules: session rules file is not a JSON array: ${path}`)
116
+ return sanitizeRules(parsed)
117
+ }
118
+
119
+ /** Replace the per-session rule set in its durable file. */
120
+ export async function writeSession(ctx: Context, sessionId: unknown, rules: readonly Rule[]): Promise<void> {
121
+ if (ctx.fs === undefined) return
122
+ const path = sessionRulesPath(sessionId)
123
+ const target = await ctx.fs.resolve(path)
124
+ await ctx.fs.writeText(target, `${JSON.stringify(rules, null, 2)}\n`)
125
+ }
126
+
127
+ /** Assemble the merged view used for injection and /rules list. */
128
+ export async function view(ctx: Context, agent: Agent, config: { globalRulesPath?: string }): Promise<RuleView> {
129
+ return {
130
+ global: await readGlobal(ctx, config),
131
+ session: await readSession(ctx, agent.id),
132
+ }
133
+ }