@y0shua1ee/dsh-auto-memory 0.3.2 → 0.4.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/lib/rule-glob.js CHANGED
@@ -5,159 +5,184 @@
5
5
  * (`[abc]`). Patterns are relative to the rule's base directory and use `/`
6
6
  * separators on every platform.
7
7
  */
8
-
9
8
  const EXPANSION_BUDGET = 1000;
10
9
  const COMPILED_CACHE = new Map();
11
10
  const COMPILED_CACHE_LIMIT = 2000;
12
-
11
+ const NEVER = /^(?!)$/u;
13
12
  function findBrace(pattern, from) {
14
- let depth = 0;
15
- let start = -1;
16
- for (let index = from; index < pattern.length; index += 1) {
17
- const character = pattern[index];
18
- if (character === "\\") {
19
- index += 1;
20
- continue;
21
- }
22
- if (character === "{") {
23
- if (depth === 0) start = index;
24
- depth += 1;
25
- } else if (character === "}") {
26
- if (depth > 0) {
27
- depth -= 1;
28
- if (depth === 0 && start !== -1) return { start, end: index };
29
- }
13
+ let depth = 0;
14
+ let start = -1;
15
+ for (let index = from; index < pattern.length; index += 1) {
16
+ const character = pattern[index];
17
+ if (character === "\\") {
18
+ index += 1;
19
+ continue;
20
+ }
21
+ if (character === "{") {
22
+ if (depth === 0)
23
+ start = index;
24
+ depth += 1;
25
+ }
26
+ else if (character === "}") {
27
+ if (depth > 0) {
28
+ depth -= 1;
29
+ if (depth === 0 && start !== -1)
30
+ return { start, end: index };
31
+ }
32
+ }
30
33
  }
31
- }
32
- return undefined;
34
+ return undefined;
33
35
  }
34
-
35
36
  function splitAlternatives(inner) {
36
- const parts = [];
37
- let current = "";
38
- let depth = 0;
39
- for (const character of inner) {
40
- if (character === "{") depth += 1;
41
- if (character === "}") depth -= 1;
42
- if (character === "," && depth === 0) {
43
- parts.push(current);
44
- current = "";
45
- } else {
46
- current += character;
37
+ const parts = [];
38
+ let current = "";
39
+ let depth = 0;
40
+ for (const character of inner) {
41
+ if (character === "{")
42
+ depth += 1;
43
+ if (character === "}")
44
+ depth -= 1;
45
+ if (character === "," && depth === 0) {
46
+ parts.push(current);
47
+ current = "";
48
+ }
49
+ else {
50
+ current += character;
51
+ }
47
52
  }
48
- }
49
- parts.push(current);
50
- return parts;
53
+ parts.push(current);
54
+ return parts;
51
55
  }
52
-
53
56
  function expandInto(pattern, from, output) {
54
- if (output.length >= EXPANSION_BUDGET) return;
55
- const brace = findBrace(pattern, from);
56
- if (brace === undefined) {
57
- output.push(pattern);
58
- return;
59
- }
60
- const alternatives = splitAlternatives(pattern.slice(brace.start + 1, brace.end));
61
- if (alternatives.length < 2) {
62
- expandInto(pattern, brace.end + 1, output);
63
- return;
64
- }
65
- const prefix = pattern.slice(0, brace.start);
66
- const suffix = pattern.slice(brace.end + 1);
67
- for (const alternative of alternatives) {
68
- if (output.length >= EXPANSION_BUDGET) break;
69
- expandInto(`${prefix}${alternative}${suffix}`, brace.start, output);
70
- }
57
+ if (output.length >= EXPANSION_BUDGET)
58
+ return;
59
+ const brace = findBrace(pattern, from);
60
+ if (brace === undefined) {
61
+ output.push(pattern);
62
+ return;
63
+ }
64
+ const alternatives = splitAlternatives(pattern.slice(brace.start + 1, brace.end));
65
+ if (alternatives.length < 2) {
66
+ expandInto(pattern, brace.end + 1, output);
67
+ return;
68
+ }
69
+ const prefix = pattern.slice(0, brace.start);
70
+ const suffix = pattern.slice(brace.end + 1);
71
+ for (const alternative of alternatives) {
72
+ if (output.length >= EXPANSION_BUDGET)
73
+ break;
74
+ expandInto(`${prefix}${alternative}${suffix}`, brace.start, output);
75
+ }
71
76
  }
72
-
73
77
  /**
74
78
  * Expand brace alternatives with a hard budget. A pattern over budget is
75
79
  * returned unexpanded, so its literal braces match nothing rather than
76
80
  * consuming unbounded memory.
77
- *
78
- * @param {string} pattern - glob pattern.
79
- * @returns {string[]} expanded patterns.
80
81
  */
81
82
  export function expandBraces(pattern) {
82
- const output = [];
83
- expandInto(pattern, 0, output);
84
- return output.length > 0 ? output : [pattern];
83
+ const output = [];
84
+ expandInto(pattern, 0, output);
85
+ return output.length > 0 ? output : [pattern];
85
86
  }
86
-
87
87
  function normalizePattern(pattern) {
88
- return pattern.replaceAll("\\", "/").replace(/^\.\//u, "").replace(/^\/+/u, "");
88
+ let value = pattern.replace(/^\.\//u, "");
89
+ if (value.startsWith("/")) {
90
+ return { pattern: value.replace(/^\/+/, ""), anchored: true };
91
+ }
92
+ return { pattern: value, anchored: value.includes("/") };
93
+ }
94
+ function escapeRegex(character) {
95
+ return /[.*+?^${}()|[\]\\]/u.test(character) ? `\\${character}` : character;
89
96
  }
90
-
91
97
  /**
92
98
  * Compile one glob pattern into an anchored regular expression.
93
- *
94
- * @param {string} pattern - glob pattern.
95
- * @returns {RegExp} matcher for `/`-separated relative paths.
96
99
  */
97
100
  export function compileGlob(pattern) {
98
- const cached = COMPILED_CACHE.get(pattern);
99
- if (cached !== undefined) return cached;
100
- const source = normalizePattern(pattern);
101
- let expression = "^";
102
- for (let index = 0; index < source.length; index += 1) {
103
- const character = source[index];
104
- if (character === "*") {
105
- let end = index;
106
- while (source[end + 1] === "*") end += 1;
107
- if (end > index) {
108
- if (source[end + 1] === "/") {
109
- expression += "(?:.*/)?";
110
- index = end + 1;
111
- } else {
112
- expression += ".*";
113
- index = end;
101
+ const cached = COMPILED_CACHE.get(pattern);
102
+ if (cached !== undefined)
103
+ return cached;
104
+ const normalized = normalizePattern(pattern);
105
+ const source = normalized.pattern;
106
+ let expression = normalized.anchored ? "^" : "^(?:.*/)?";
107
+ for (let index = 0; index < source.length; index += 1) {
108
+ const character = source[index];
109
+ if (character === "\\") {
110
+ const next = source[index + 1];
111
+ if (next !== undefined) {
112
+ expression += escapeRegex(next);
113
+ index += 1;
114
+ }
115
+ else {
116
+ expression += "\\\\";
117
+ }
118
+ continue;
114
119
  }
115
- } else {
116
- expression += "[^/]*";
117
- }
118
- continue;
119
- }
120
- if (character === "?") {
121
- expression += "[^/]";
122
- continue;
123
- }
124
- if (character === "[") {
125
- const close = source.indexOf("]", index + 1);
126
- if (close === -1) {
127
- expression += "\\[";
128
- continue;
129
- }
130
- let characterClass = source.slice(index + 1, close);
131
- if (characterClass.startsWith("!")) characterClass = `^${characterClass.slice(1)}`;
132
- expression += `[${characterClass}]`;
133
- index = close;
134
- continue;
135
- }
136
- if (character === "{" || character === "}") {
137
- expression += `\\${character}`;
138
- continue;
139
- }
140
- if ("\\.+^$()|".includes(character)) {
141
- expression += `\\${character}`;
142
- continue;
120
+ if (character === "*") {
121
+ let end = index;
122
+ while (source[end + 1] === "*")
123
+ end += 1;
124
+ if (end > index) {
125
+ if (source[end + 1] === "/") {
126
+ expression += "(?:.*/)?";
127
+ index = end + 1;
128
+ }
129
+ else {
130
+ expression += ".*";
131
+ index = end;
132
+ }
133
+ }
134
+ else {
135
+ expression += "[^/]*";
136
+ }
137
+ continue;
138
+ }
139
+ if (character === "?") {
140
+ expression += "[^/]";
141
+ continue;
142
+ }
143
+ if (character === "[") {
144
+ let close = -1;
145
+ for (let cursor = index + 1; cursor < source.length; cursor += 1) {
146
+ if (source[cursor] === "\\") {
147
+ cursor += 1;
148
+ continue;
149
+ }
150
+ if (source[cursor] === "]") {
151
+ close = cursor;
152
+ break;
153
+ }
154
+ }
155
+ if (close === -1) {
156
+ return NEVER;
157
+ }
158
+ let characterClass = source.slice(index + 1, close);
159
+ if (characterClass.startsWith("!"))
160
+ characterClass = `^${characterClass.slice(1)}`;
161
+ expression += `[${characterClass}]`;
162
+ index = close;
163
+ continue;
164
+ }
165
+ if (character === "{" || character === "}") {
166
+ expression += `\\${character}`;
167
+ continue;
168
+ }
169
+ if ("\\.+^$()|".includes(character)) {
170
+ expression += `\\${character}`;
171
+ continue;
172
+ }
173
+ expression += character;
143
174
  }
144
- expression += character;
145
- }
146
- expression += "$";
147
- const compiled = new RegExp(expression);
148
- if (COMPILED_CACHE.size >= COMPILED_CACHE_LIMIT) COMPILED_CACHE.clear();
149
- COMPILED_CACHE.set(pattern, compiled);
150
- return compiled;
175
+ expression += "$";
176
+ const compiled = new RegExp(expression);
177
+ if (COMPILED_CACHE.size >= COMPILED_CACHE_LIMIT)
178
+ COMPILED_CACHE.clear();
179
+ COMPILED_CACHE.set(pattern, compiled);
180
+ return compiled;
151
181
  }
152
-
153
182
  /**
154
183
  * Test one `/`-separated relative path against a glob.
155
- *
156
- * @param {string} path - relative path.
157
- * @param {string} pattern - glob pattern.
158
- * @returns {boolean} whether the path matches.
159
184
  */
160
185
  export function matchGlob(path, pattern) {
161
- const normalized = path.replaceAll("\\", "/").replace(/^\.\//u, "");
162
- return expandBraces(pattern).some((expanded) => compileGlob(expanded).test(normalized));
186
+ const normalized = path.replaceAll("\\", "/").replace(/^\.\//u, "");
187
+ return expandBraces(pattern).some((expanded) => compileGlob(expanded).test(normalized));
163
188
  }
@@ -0,0 +1,14 @@
1
+ import type { ProjectFor } from "./inject.js";
2
+ import type { ResolvedConfig } from "./config.js";
3
+ import type { Logger, PreStepHandler, ToolExecEvent, ToolResultEvent } from "./types.js";
4
+ /**
5
+ * Register rule injection and file-touch tracking.
6
+ */
7
+ export declare function createRulesFeature({ projectFor, config, logger }: {
8
+ projectFor: ProjectFor;
9
+ config: ResolvedConfig;
10
+ logger?: Logger;
11
+ }): {
12
+ preStep: PreStepHandler;
13
+ toolResult: (exec: ToolExecEvent, result: ToolResultEvent) => void;
14
+ };
@@ -2,129 +2,137 @@ import { createUserMessage } from "@deepseek-ai/dsh-llm";
2
2
  import { isAbsolute, relative, resolve } from "node:path";
3
3
  import { enterWithMessage, isSubagent } from "./inject.js";
4
4
  import { RULE_MESSAGE_KIND, RuleIndex, renderRules } from "./rules.js";
5
-
6
5
  const FILE_TOOLS = new Set(["read", "write", "edit"]);
7
-
8
6
  function toPosix(value) {
9
- return value.replaceAll("\\", "/");
7
+ return value.replaceAll("\\", "/");
10
8
  }
11
-
12
- function ruleMatches(rule, touched, cwd, projectRoot) {
13
- if (rule.matchers.length === 0) return true;
14
- const base = rule.scope === "user" ? cwd : projectRoot;
15
- for (const raw of touched) {
16
- const absolute = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
17
- const rel = relative(base, absolute);
18
- if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) continue;
19
- const posix = toPosix(rel);
20
- if (rule.matchers.some((matcher) => matcher.test(posix))) return true;
21
- }
22
- return false;
9
+ function ruleMatches(rule, touched, cwd, ruleRoot) {
10
+ if (rule.matchers.length === 0)
11
+ return true;
12
+ const base = rule.scope === "user" ? cwd : ruleRoot;
13
+ for (const raw of touched) {
14
+ const absolute = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
15
+ const rel = relative(base, absolute);
16
+ if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel))
17
+ continue;
18
+ const posix = toPosix(rel);
19
+ if (rule.matchers.some((matcher) => matcher.test(posix)))
20
+ return true;
21
+ }
22
+ return false;
23
23
  }
24
-
25
24
  function visibleRuleDigests(agent, decision) {
26
- const visible = new Map();
27
- const collect = (message) => {
28
- if (message?.source?.kind !== RULE_MESSAGE_KIND) return;
29
- const entries = message.source.rules;
30
- if (!Array.isArray(entries)) return;
31
- for (const entry of entries) {
32
- if (typeof entry?.key === "string" && typeof entry.digest === "string") {
33
- visible.set(entry.key, entry.digest);
34
- }
25
+ const visible = new Map();
26
+ const collect = (message) => {
27
+ if (message?.source?.kind !== RULE_MESSAGE_KIND)
28
+ return;
29
+ const entries = message.source.rules;
30
+ if (!Array.isArray(entries))
31
+ return;
32
+ for (const entry of entries) {
33
+ if (typeof entry !== "object" || entry === null)
34
+ continue;
35
+ const candidate = entry;
36
+ if (typeof candidate.key === "string" && typeof candidate.digest === "string") {
37
+ visible.set(candidate.key, candidate.digest);
38
+ }
39
+ }
40
+ };
41
+ for (const message of decision.messages)
42
+ collect(message);
43
+ for (const seq of agent.session.surface.nodes) {
44
+ const event = agent.session.eventAt(seq);
45
+ if (event?.type === "user/message")
46
+ collect(event.data);
35
47
  }
36
- };
37
- for (const message of decision.messages) collect(message);
38
- for (const seq of agent.session.surface.nodes) {
39
- const event = agent.session.eventAt(seq);
40
- if (event?.type === "user/message") collect(event.data);
41
- }
42
- for (const message of agent.inbox.nextStep) collect(message);
43
- return visible;
48
+ for (const message of agent.inbox.nextStep)
49
+ collect(message);
50
+ return visible;
44
51
  }
45
-
46
52
  function isRulesMessage(message) {
47
- return message?.source?.kind === RULE_MESSAGE_KIND;
53
+ return message?.source?.kind === RULE_MESSAGE_KIND;
48
54
  }
49
-
50
55
  /**
51
56
  * Register rule injection and file-touch tracking.
52
- *
53
- * @param {object} input - feature wiring.
54
- * @param {(agent: object, signal: AbortSignal) => Promise<object>} input.projectFor - resolve the session project.
55
- * @param {object} input.config - normalized plugin configuration.
56
- * @param {object} [input.logger] - host logger.
57
- * @returns {{preStep: Function, toolResult: Function}} event listeners.
58
57
  */
59
58
  export function createRulesFeature({ projectFor, config, logger }) {
60
- const index = new RuleIndex({
61
- userRuleDirs: config.userRuleDirs,
62
- projectRuleDirs: config.projectRuleDirs,
63
- maxRuleBytes: config.maxRuleBytes,
64
- maxRules: config.maxRules,
65
- maxRulesBytes: config.maxRulesBytes
66
- });
67
- const touched = new WeakMap();
68
-
69
- const toolResult = (exec, result) => {
70
- if (result?.isError) return;
71
- if (!FILE_TOOLS.has(exec?.name)) return;
72
- const agent = exec.agent;
73
- if (agent === undefined) return;
74
- if (!config.injectInSubagents && isSubagent(agent)) return;
75
- const raw = exec.arguments?.file_path;
76
- if (typeof raw !== "string" || raw.trim().length === 0) return;
77
- let paths = touched.get(agent.session);
78
- if (paths === undefined) {
79
- paths = new Set();
80
- touched.set(agent.session, paths);
81
- }
82
- paths.add(raw.trim());
83
- };
84
-
85
- const preStep = async ({ agent, messages, signal }, next) => {
86
- const decision = await next();
87
- if (decision.kind === "reject") return decision;
88
- if (!config.rules) return decision;
89
- if (!config.injectInSubagents && isSubagent(agent)) return decision;
90
- try {
91
- const project = await projectFor(agent, signal);
92
- const { rules } = await index.collect({
93
- cwd: project.cwd,
94
- projectRoot: project.projectRoot
95
- });
96
- if (rules.length === 0) return decision;
97
- const sessionTouches = touched.get(agent.session) ?? new Set();
98
- const eligible = rules.filter((rule) =>
99
- ruleMatches(rule, sessionTouches, project.cwd, project.projectRoot)
100
- );
101
- if (eligible.length === 0) return decision;
102
- const visible = visibleRuleDigests(agent, decision);
103
- const fresh = eligible.filter((rule) => visible.get(rule.key) !== rule.digest);
104
- if (fresh.length === 0) return decision;
105
- const rendered = renderRules(fresh, config.maxRulesBytes);
106
- if (rendered === undefined) return decision;
107
- const message = createUserMessage({
108
- content: [{ type: "text", text: rendered.text }],
109
- source: {
110
- kind: RULE_MESSAGE_KIND,
111
- form: "instructions",
112
- digest: rendered.digest,
113
- rules: rendered.rules.map((rule) => ({
114
- key: rule.key,
115
- path: rule.path,
116
- scope: rule.scope,
117
- digest: rule.digest
118
- }))
59
+ const index = new RuleIndex({
60
+ userRuleDirs: config.userRuleDirs,
61
+ projectRuleDirs: config.projectRuleDirs,
62
+ maxRuleBytes: config.maxRuleBytes,
63
+ maxRules: config.maxRules,
64
+ maxRulesBytes: config.maxRulesBytes
65
+ });
66
+ const touched = new WeakMap();
67
+ const toolResult = (exec, result) => {
68
+ if (result?.isError)
69
+ return;
70
+ if (!FILE_TOOLS.has(exec?.name ?? ""))
71
+ return;
72
+ const agent = exec.agent;
73
+ if (agent === undefined)
74
+ return;
75
+ if (!config.injectInSubagents && isSubagent(agent))
76
+ return;
77
+ const raw = exec.arguments?.file_path;
78
+ if (typeof raw !== "string" || raw.trim().length === 0)
79
+ return;
80
+ let paths = touched.get(agent.session);
81
+ if (paths === undefined) {
82
+ paths = new Set();
83
+ touched.set(agent.session, paths);
119
84
  }
120
- });
121
- return enterWithMessage(agent, messages, decision, message, isRulesMessage);
122
- } catch (error) {
123
- if (signal?.aborted) return decision;
124
- logger?.warn?.("auto-memory: could not load rules for this step: %o", error);
125
- return decision;
126
- }
127
- };
128
-
129
- return { preStep, toolResult };
85
+ paths.add(raw.trim());
86
+ };
87
+ const preStep = async ({ agent, messages, signal }, next) => {
88
+ const decision = await next();
89
+ if (decision.kind === "reject")
90
+ return decision;
91
+ if (!config.rules)
92
+ return decision;
93
+ if (!config.injectInSubagents && isSubagent(agent))
94
+ return decision;
95
+ try {
96
+ const project = await projectFor(agent, signal);
97
+ const { rules, warnings } = await index.collect({
98
+ cwd: project.cwd,
99
+ projectRoot: project.ruleRoot
100
+ });
101
+ if (rules.length === 0)
102
+ return decision;
103
+ const sessionTouches = touched.get(agent.session) ?? new Set();
104
+ const eligible = rules.filter((rule) => ruleMatches(rule, sessionTouches, project.cwd, project.ruleRoot));
105
+ if (eligible.length === 0)
106
+ return decision;
107
+ const visible = visibleRuleDigests(agent, decision);
108
+ const fresh = eligible.filter((rule) => visible.get(rule.key) !== rule.digest);
109
+ if (fresh.length === 0)
110
+ return decision;
111
+ const rendered = renderRules(fresh, config.maxRulesBytes, warnings);
112
+ if (rendered === undefined)
113
+ return decision;
114
+ const message = createUserMessage({
115
+ content: [{ type: "text", text: rendered.text }],
116
+ source: {
117
+ kind: RULE_MESSAGE_KIND,
118
+ form: "instructions",
119
+ digest: rendered.digest,
120
+ rules: rendered.rules.map((rule) => ({
121
+ key: rule.key,
122
+ path: rule.path,
123
+ scope: rule.scope,
124
+ digest: rule.digest
125
+ }))
126
+ }
127
+ });
128
+ return enterWithMessage(agent, messages, decision, message, isRulesMessage);
129
+ }
130
+ catch (error) {
131
+ if (signal?.aborted)
132
+ return decision;
133
+ logger?.warn?.("auto-memory: could not load rules for this step: %o", error);
134
+ return decision;
135
+ }
136
+ };
137
+ return { preStep, toolResult };
130
138
  }
package/lib/rules.d.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { RuleScope } from "./types.js";
2
+ /** Message source kind used to recognize injected rule batches. */
3
+ export declare const RULE_MESSAGE_KIND = "auto-rules";
4
+ export interface Rule {
5
+ key: string;
6
+ path: string;
7
+ scope: RuleScope;
8
+ body: string;
9
+ paths?: string[];
10
+ matchers: RegExp[];
11
+ digest: string;
12
+ bytes: number;
13
+ }
14
+ export interface RuleIndexOptions {
15
+ userRuleDirs: string[];
16
+ projectRuleDirs: string[];
17
+ maxRuleBytes: number;
18
+ maxRules: number;
19
+ maxRulesBytes: number;
20
+ }
21
+ export interface CollectInput {
22
+ cwd: string;
23
+ projectRoot: string;
24
+ }
25
+ export interface CollectResult {
26
+ rules: Rule[];
27
+ warnings: string[];
28
+ }
29
+ /** The subset of a rule that rendering needs. */
30
+ export interface RenderableRule {
31
+ key: string;
32
+ path: string;
33
+ scope: RuleScope;
34
+ body: string;
35
+ digest: string;
36
+ }
37
+ export interface RenderedRules {
38
+ text: string;
39
+ digest: string;
40
+ rules: RenderableRule[];
41
+ }
42
+ /**
43
+ * Discover and cache rule files from user and project roots.
44
+ *
45
+ * Files are re-stat'ed on every collection; content is read again only when
46
+ * size or mtime changes. Symlinks are followed, which is required for the
47
+ * Home Manager-managed rules under `~/.claude/rules/`.
48
+ */
49
+ export declare class RuleIndex {
50
+ #private;
51
+ readonly userRuleDirs: string[];
52
+ readonly projectRuleDirs: string[];
53
+ readonly maxRuleBytes: number;
54
+ readonly maxRules: number;
55
+ readonly maxRulesBytes: number;
56
+ constructor({ userRuleDirs, projectRuleDirs, maxRuleBytes, maxRules, maxRulesBytes }: RuleIndexOptions);
57
+ /**
58
+ * Collect the rules visible to one session.
59
+ */
60
+ collect({ cwd, projectRoot }: CollectInput): Promise<CollectResult>;
61
+ }
62
+ export declare function renderRules(rules: RenderableRule[], maxBytes: number, warnings?: string[]): RenderedRules | undefined;