@modusensus/dsh-mneme 0.1.2 → 0.1.4

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,120 @@
1
+ // User-configurable settings: profile (user self-description), rules (behavior
2
+ // rules the agent must follow), and custom slash commands. Stored in the same
3
+ // SQLite database via dedicated tables, isolated from the memories store.
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ const SCHEMA = `
7
+ CREATE TABLE IF NOT EXISTS user_settings (
8
+ key TEXT PRIMARY KEY,
9
+ value TEXT NOT NULL
10
+ );
11
+ CREATE TABLE IF NOT EXISTS custom_commands (
12
+ id TEXT PRIMARY KEY,
13
+ name TEXT NOT NULL UNIQUE,
14
+ description TEXT NOT NULL DEFAULT '',
15
+ instruction TEXT NOT NULL,
16
+ created_at TEXT NOT NULL,
17
+ updated_at TEXT NOT NULL
18
+ );
19
+ `;
20
+
21
+ // DSH command names must match this (lowercase, start with a letter).
22
+ const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
23
+
24
+ /** Parse a JSON array out of a stored string, tolerant of corruption. */
25
+ function parseList(raw) {
26
+ try {
27
+ const value = JSON.parse(raw);
28
+ return Array.isArray(value) ? value : [];
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ export function createSettings(db) {
35
+ db.exec(SCHEMA);
36
+
37
+ function getSetting(key) {
38
+ const row = db.prepare("SELECT value FROM user_settings WHERE key = ?").get(key);
39
+ return row?.value ?? undefined;
40
+ }
41
+
42
+ function setSetting(key, value) {
43
+ db.prepare(
44
+ `INSERT INTO user_settings (key, value) VALUES (?, ?)
45
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
46
+ ).run(key, value);
47
+ }
48
+
49
+ function toCommand(row) {
50
+ if (!row) return undefined;
51
+ return {
52
+ id: row.id,
53
+ name: row.name,
54
+ description: row.description,
55
+ instruction: row.instruction,
56
+ created_at: row.created_at,
57
+ updated_at: row.updated_at
58
+ };
59
+ }
60
+
61
+ return {
62
+ /** The user's self-description (free text) or "" when unset. */
63
+ getProfile() {
64
+ return getSetting("profile") ?? "";
65
+ },
66
+ setProfile(text) {
67
+ setSetting("profile", String(text ?? ""));
68
+ },
69
+
70
+ /** Behavior rules as an array of strings. */
71
+ getRules() {
72
+ return parseList(getSetting("rules") ?? "[]").filter((r) => typeof r === "string");
73
+ },
74
+ setRules(rules) {
75
+ const list = Array.isArray(rules) ? rules.filter((r) => typeof r === "string") : [];
76
+ setSetting("rules", JSON.stringify(list));
77
+ },
78
+
79
+ /** All custom commands, sorted by name. */
80
+ listCommands() {
81
+ const rows = db.prepare("SELECT * FROM custom_commands ORDER BY name ASC").all();
82
+ return rows.map(toCommand);
83
+ },
84
+
85
+ /**
86
+ * Add or replace a custom command by name.
87
+ * @returns the stored command.
88
+ * @throws when name is invalid or does not match DSH's command-name grammar.
89
+ */
90
+ addCommand({ name, description = "", instruction }) {
91
+ const cmdName = String(name ?? "").trim();
92
+ if (!COMMAND_NAME.test(cmdName)) {
93
+ throw new Error(`invalid command name "${cmdName}": must match /^[a-z][a-z0-9_-]*$/`);
94
+ }
95
+ if (typeof instruction !== "string" || !instruction.trim()) {
96
+ throw new Error("command instruction must be a non-empty string");
97
+ }
98
+ const now = new Date().toISOString();
99
+ const existing = db.prepare("SELECT id FROM custom_commands WHERE name = ?").get(cmdName);
100
+ if (existing) {
101
+ db.prepare(
102
+ "UPDATE custom_commands SET description = ?, instruction = ?, updated_at = ? WHERE id = ?"
103
+ ).run(String(description ?? ""), instruction, now, existing.id);
104
+ return toCommand(db.prepare("SELECT * FROM custom_commands WHERE id = ?").get(existing.id));
105
+ }
106
+ const id = randomUUID();
107
+ db.prepare(
108
+ `INSERT INTO custom_commands (id, name, description, instruction, created_at, updated_at)
109
+ VALUES (?, ?, ?, ?, ?, ?)`
110
+ ).run(id, cmdName, String(description ?? ""), instruction, now, now);
111
+ return toCommand(db.prepare("SELECT * FROM custom_commands WHERE id = ?").get(id));
112
+ },
113
+
114
+ /** Remove a custom command by id; returns true when removed. */
115
+ removeCommand(id) {
116
+ const result = db.prepare("DELETE FROM custom_commands WHERE id = ?").run(id);
117
+ return result.changes > 0;
118
+ }
119
+ };
120
+ }
package/lib/store.js CHANGED
@@ -193,8 +193,9 @@ export function createStore(path) {
193
193
  function search(query, { limit = 20, includeArchived = false } = {}) {
194
194
  const q = String(query).trim();
195
195
  if (!q) return [];
196
- // FTS5 over unicode61 (English + long phrases); LIKE fallback covers CJK substring.
197
- // LIKE wildcards in the query are escaped so user input is matched literally.
196
+ // Plain LIKE substring scan over title/content/tags (wildcards escaped so
197
+ // user input matches literally). No FTS5: CJK substring matching needs
198
+ // LIKE, and typical memory stores are small enough that a scan is fine.
198
199
  const like = `%${escapeLike(q)}%`;
199
200
  const { limit: lim } = sanitizePage(limit, 0, 20);
200
201
  const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
- "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite+FTS5 store, Markdown mirrors, 6 model tools, automatic injection, session summarization, and a Web GUI panel",
4
- "version": "0.1.2",
3
+ "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 6 model tools, automatic injection, session summarization, and a Web GUI panel",
4
+ "version": "0.1.4",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
package/src/api.js CHANGED
@@ -5,19 +5,41 @@ function sendJson(res, status, payload) {
5
5
  res.end(JSON.stringify(payload));
6
6
  }
7
7
 
8
- export function createApi(ctx, service) {
8
+ /** Collect the request body as text (tolerant of empty/invalid bodies). */
9
+ function readBody(req) {
10
+ return new Promise((resolve) => {
11
+ let body = "";
12
+ req.on("data", (chunk) => { body += chunk; });
13
+ req.on("end", () => resolve(body));
14
+ req.on("error", () => resolve(""));
15
+ });
16
+ }
17
+
18
+ function parseBody(text) {
19
+ try {
20
+ return JSON.parse(text || "{}");
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ export function createApi(ctx, service, settings, commands) {
9
27
  const disposers = [];
10
28
 
29
+ const register = (route) => {
30
+ disposers.push(ctx.webServer.register(route));
31
+ };
32
+
11
33
  // /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
12
- disposers.push(ctx.webServer.register({
34
+ register({
13
35
  kind: "prefix",
14
36
  path: "/api/dsh-mneme",
15
37
  handler(req, res) {
16
38
  sendJson(res, 404, { error: "not-found" });
17
39
  }
18
- }));
40
+ });
19
41
 
20
- disposers.push(ctx.webServer.register({
42
+ register({
21
43
  kind: "exact",
22
44
  path: "/api/dsh-mneme/list",
23
45
  handler(req, res) {
@@ -32,9 +54,9 @@ export function createApi(ctx, service) {
32
54
  sendJson(res, 500, { error: "internal" });
33
55
  }
34
56
  }
35
- }));
57
+ });
36
58
 
37
- disposers.push(ctx.webServer.register({
59
+ register({
38
60
  kind: "exact",
39
61
  path: "/api/dsh-mneme/search",
40
62
  handler(req, res) {
@@ -48,10 +70,85 @@ export function createApi(ctx, service) {
48
70
  sendJson(res, 500, { error: "internal" });
49
71
  }
50
72
  }
51
- }));
73
+ });
74
+
75
+ // --- user profile ---
76
+ register({
77
+ kind: "exact",
78
+ path: "/api/dsh-mneme/profile",
79
+ handler(req, res) {
80
+ try {
81
+ if (req.method === "PUT" || req.method === "POST") {
82
+ return readBody(req).then((text) => {
83
+ const body = parseBody(text);
84
+ settings.setProfile(typeof body.profile === "string" ? body.profile : "");
85
+ sendJson(res, 200, { profile: settings.getProfile() });
86
+ });
87
+ }
88
+ sendJson(res, 200, { profile: settings.getProfile() });
89
+ } catch {
90
+ sendJson(res, 500, { error: "internal" });
91
+ }
92
+ }
93
+ });
94
+
95
+ // --- rules ---
96
+ register({
97
+ kind: "exact",
98
+ path: "/api/dsh-mneme/rules",
99
+ handler(req, res) {
100
+ try {
101
+ if (req.method === "PUT" || req.method === "POST") {
102
+ return readBody(req).then((text) => {
103
+ const body = parseBody(text);
104
+ settings.setRules(Array.isArray(body.rules) ? body.rules : []);
105
+ sendJson(res, 200, { rules: settings.getRules() });
106
+ });
107
+ }
108
+ sendJson(res, 200, { rules: settings.getRules() });
109
+ } catch {
110
+ sendJson(res, 500, { error: "internal" });
111
+ }
112
+ }
113
+ });
114
+
115
+ // --- custom commands ---
116
+ register({
117
+ kind: "exact",
118
+ path: "/api/dsh-mneme/commands",
119
+ handler(req, res) {
120
+ try {
121
+ if (req.method === "POST") {
122
+ return readBody(req).then((text) => {
123
+ const body = parseBody(text);
124
+ try {
125
+ const command = commands.add({
126
+ name: body.name,
127
+ description: body.description,
128
+ instruction: body.instruction
129
+ });
130
+ sendJson(res, 200, { command });
131
+ } catch (error) {
132
+ sendJson(res, 400, { error: error.message });
133
+ }
134
+ });
135
+ }
136
+ if (req.method === "DELETE") {
137
+ const url = new URL(req.url, "http://localhost");
138
+ const id = url.searchParams.get("id");
139
+ const removed = id ? commands.remove(id) : false;
140
+ sendJson(res, 200, { removed });
141
+ return;
142
+ }
143
+ sendJson(res, 200, { commands: commands.list() });
144
+ } catch {
145
+ sendJson(res, 500, { error: "internal" });
146
+ }
147
+ }
148
+ });
52
149
 
53
150
  return {
54
- routes: 3,
151
+ routes: 6,
55
152
  dispose: () => {
56
153
  for (const dispose of disposers) dispose();
57
154
  }
@@ -0,0 +1,64 @@
1
+ // Custom slash-command manager: keeps the DSH command registry in sync with
2
+ // user-defined commands persisted in SQLite. Commands are registered on boot
3
+ // and (re)registered on add/remove through the API.
4
+ //
5
+ // Each custom command's handler returns the user-authored instruction as a
6
+ // success result; the DSH UI surfaces it as a model-directed instruction.
7
+ export function createCommandManager({ ctx, settings, logger }) {
8
+ const registered = new Map(); // name -> disposer
9
+
10
+ function registerOne(command) {
11
+ if (registered.has(command.name)) return;
12
+ let dispose;
13
+ try {
14
+ dispose = ctx.commands.register({
15
+ name: command.name,
16
+ description: command.description || `自定义指令 ${command.name}`,
17
+ handler: () => ({ kind: "success", text: command.instruction })
18
+ });
19
+ } catch (error) {
20
+ logger?.warn?.(`dsh-mneme: failed to register command /${command.name}: ${String(error)}`);
21
+ return;
22
+ }
23
+ registered.set(command.name, dispose);
24
+ }
25
+
26
+ function unregisterOne(name) {
27
+ const dispose = registered.get(name);
28
+ if (dispose) {
29
+ try {
30
+ dispose();
31
+ } catch {
32
+ /* ignore double-dispose */
33
+ }
34
+ registered.delete(name);
35
+ }
36
+ }
37
+
38
+ /** Register every stored command (boot-time sync). */
39
+ function sync() {
40
+ for (const command of settings.listCommands()) registerOne(command);
41
+ }
42
+
43
+ /** Add (or replace) a command and register it live. */
44
+ function add({ name, description, instruction }) {
45
+ const command = settings.addCommand({ name, description, instruction });
46
+ registerOne(command);
47
+ return command;
48
+ }
49
+
50
+ /** Remove a command by id and unregister it live. */
51
+ function remove(id) {
52
+ const existing = settings.listCommands().find((c) => c.id === id);
53
+ if (!existing) return false;
54
+ if (!settings.removeCommand(id)) return false;
55
+ unregisterOne(existing.name);
56
+ return true;
57
+ }
58
+
59
+ function dispose() {
60
+ for (const name of [...registered.keys()]) unregisterOne(name);
61
+ }
62
+
63
+ return { sync, add, remove, list: () => settings.listCommands(), dispose };
64
+ }
package/src/dream.js CHANGED
@@ -62,6 +62,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
62
62
  let running = false;
63
63
  let disposed = false;
64
64
  let baseline = { count: 0, chars: 0 };
65
+ let inFlight = null;
65
66
 
66
67
  function shouldTrigger(service) {
67
68
  const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
@@ -81,8 +82,9 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
81
82
  running = true;
82
83
  // Defer the onRun invocation so a synchronous throw cannot escape the
83
84
  // timer callback (which would crash the process) and skip the teardown.
84
- // Errors are logged, never swallowed silently.
85
- Promise.resolve()
85
+ // Errors are logged, never swallowed silently. inFlight lets dispose()
86
+ // await the running consolidation before the caller closes the store.
87
+ inFlight = Promise.resolve()
86
88
  .then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })))
87
89
  .then((result) => {
88
90
  // Refresh the baseline only for a successful run (design §5.3: an
@@ -105,17 +107,19 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
105
107
  })
106
108
  .finally(() => {
107
109
  running = false;
110
+ inFlight = null;
108
111
  });
109
112
  }, delayMs);
110
113
  return true;
111
114
  }
112
115
 
113
- function dispose() {
116
+ async function dispose() {
114
117
  disposed = true;
115
118
  if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
116
- // An in-flight run is left to complete naturally: its LLM calls are
117
- // already paid for and aborting would discard the work. The caller is
118
- // responsible for closing the store only after the run has finished.
119
+ // An in-flight run is left to complete naturally (its LLM calls are
120
+ // already paid for and aborting would discard the work). Await it so the
121
+ // caller can close the store only after every write has landed.
122
+ if (inFlight) await inFlight.catch(() => {});
119
123
  }
120
124
 
121
125
  async function runDream(ctx, service, config) {
package/src/index.js CHANGED
@@ -6,13 +6,15 @@ import { createInjector } from "./inject.js";
6
6
  import { createSummarizer } from "./summarize.js";
7
7
  import { createDreamScheduler } from "./dream.js";
8
8
  import { createApi } from "./api.js";
9
+ import { createSettings } from "./settings.js";
10
+ import { createCommandManager } from "./commands.js";
9
11
  import { Config } from "./config.js";
10
12
  import { mkdirSync } from "node:fs";
11
13
  import { join } from "node:path";
12
14
  import { homedir } from "node:os";
13
15
 
14
16
  export const name = "dsh-mneme";
15
- export const inject = ["tools", "systemPrompt", "webServer", "llm", "agentDefaultModel"];
17
+ export const inject = ["tools", "systemPrompt", "webServer", "llm", "agentDefaultModel", "commands"];
16
18
  export { Config };
17
19
 
18
20
  // Arrow (not function declaration): cordis 4 treats any apply with a
@@ -33,10 +35,28 @@ export const apply = (ctx, config) => {
33
35
  const mirror = createMirror(memoryDir);
34
36
  const service = createService({ store, mirror, config: cfg });
35
37
 
38
+ // User-configurable settings (profile, rules) and custom commands share the
39
+ // same SQLite file but live in dedicated tables, isolated from memories.
40
+ const settings = createSettings(store.db);
41
+
42
+ // Custom commands: register persisted commands into the DSH command registry
43
+ // on boot; add/remove re-register live through the API.
44
+ let commands = null;
45
+ if (ctx.commands) {
46
+ commands = createCommandManager({ ctx, settings, logger: ctx.logger });
47
+ commands.sync();
48
+ }
49
+
36
50
  // Human edits in mirror files win on every sync; merge them back first.
37
- // TYPE_FILE maps each memory type to its mirror filename.
51
+ // TYPE_FILE maps each memory type to its mirror filename. Read every type's
52
+ // edits up front: mergeHumanEdits re-renders ALL mirror files on success, so
53
+ // a per-type read-then-merge loop would overwrite edits in files not yet read
54
+ // (e.g. preferences.md merging would clobber unsynced projects.md edits).
55
+ const humanEdits = new Map();
38
56
  for (const type of Object.keys(TYPE_FILE)) {
39
- const edits = mirror.readHumanEdits(type);
57
+ humanEdits.set(type, mirror.readHumanEdits(type));
58
+ }
59
+ for (const [type, edits] of humanEdits) {
40
60
  if (edits.length) service.mergeHumanEdits(type, edits);
41
61
  }
42
62
 
@@ -61,7 +81,7 @@ export const apply = (ctx, config) => {
61
81
  const disposers = [];
62
82
 
63
83
  ctx.inject(["systemPrompt"], (promptCtx) => {
64
- if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, cfg));
84
+ if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg));
65
85
  });
66
86
 
67
87
  ctx.inject(["tools"], (toolsCtx) => {
@@ -72,15 +92,23 @@ export const apply = (ctx, config) => {
72
92
  disposers.push(summarizer.dispose);
73
93
 
74
94
  if (ctx.webServer) {
75
- const api = createApi(ctx, service);
95
+ const api = createApi(ctx, service, settings, commands ?? {
96
+ add: () => { throw new Error("commands unavailable"); },
97
+ remove: () => false,
98
+ list: () => []
99
+ });
76
100
  disposers.push(api.dispose);
77
101
  }
78
102
 
79
- return () => {
103
+ // Async disposer: cordis awaits the returned promise on unload (runDisposable),
104
+ // so an in-flight dream run is allowed to finish before the SQLite store is
105
+ // closed — dream.dispose() resolves only after its current run settles.
106
+ return async () => {
80
107
  for (const dispose of disposers) {
81
108
  if (typeof dispose === "function") dispose();
82
109
  }
83
- if (dream) dream.dispose();
110
+ commands?.dispose();
111
+ if (dream) await dream.dispose();
84
112
  store.close();
85
113
  };
86
114
  };
package/src/inject.js CHANGED
@@ -1,4 +1,4 @@
1
- export function createInjector(ctx, service, config) {
1
+ export function createInjector(ctx, service, settings, config) {
2
2
  const maxItems = config.maxInjectedItems ?? 5;
3
3
  const threshold = config.importanceThreshold ?? 3;
4
4
 
@@ -11,12 +11,37 @@ export function createInjector(ctx, service, config) {
11
11
  return lines.join("\n");
12
12
  }
13
13
 
14
- return ctx.systemPrompt.context({
15
- name: "memory",
16
- order: 90,
17
- text: () => {
18
- const candidates = service.injectCandidates({ maxItems, threshold });
19
- return render(candidates);
14
+ // User profile + rules: injected ahead of the memory block because they are
15
+ // always-relevant instructions the agent should follow every turn.
16
+ function renderUserSettings() {
17
+ const profile = settings.getProfile().trim();
18
+ const rules = settings.getRules();
19
+ if (!profile && !rules.length) return "";
20
+ const lines = ["[用户设置] 来自 dsh-mneme 的用户画像与规则:"];
21
+ if (profile) lines.push(`- 用户画像:${profile}`);
22
+ for (const rule of rules) lines.push(`- 规则:${rule}`);
23
+ return lines.join("\n");
24
+ }
25
+
26
+ const disposers = [
27
+ ctx.systemPrompt.context({
28
+ name: "memory",
29
+ order: 90,
30
+ text: () => {
31
+ const candidates = service.injectCandidates({ maxItems, threshold });
32
+ return render(candidates);
33
+ }
34
+ }),
35
+ ctx.systemPrompt.context({
36
+ name: "user-settings",
37
+ order: 85,
38
+ text: renderUserSettings
39
+ })
40
+ ];
41
+
42
+ return () => {
43
+ for (const dispose of disposers) {
44
+ if (typeof dispose === "function") dispose();
20
45
  }
21
- });
46
+ };
22
47
  }
@@ -0,0 +1,120 @@
1
+ // User-configurable settings: profile (user self-description), rules (behavior
2
+ // rules the agent must follow), and custom slash commands. Stored in the same
3
+ // SQLite database via dedicated tables, isolated from the memories store.
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ const SCHEMA = `
7
+ CREATE TABLE IF NOT EXISTS user_settings (
8
+ key TEXT PRIMARY KEY,
9
+ value TEXT NOT NULL
10
+ );
11
+ CREATE TABLE IF NOT EXISTS custom_commands (
12
+ id TEXT PRIMARY KEY,
13
+ name TEXT NOT NULL UNIQUE,
14
+ description TEXT NOT NULL DEFAULT '',
15
+ instruction TEXT NOT NULL,
16
+ created_at TEXT NOT NULL,
17
+ updated_at TEXT NOT NULL
18
+ );
19
+ `;
20
+
21
+ // DSH command names must match this (lowercase, start with a letter).
22
+ const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
23
+
24
+ /** Parse a JSON array out of a stored string, tolerant of corruption. */
25
+ function parseList(raw) {
26
+ try {
27
+ const value = JSON.parse(raw);
28
+ return Array.isArray(value) ? value : [];
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ export function createSettings(db) {
35
+ db.exec(SCHEMA);
36
+
37
+ function getSetting(key) {
38
+ const row = db.prepare("SELECT value FROM user_settings WHERE key = ?").get(key);
39
+ return row?.value ?? undefined;
40
+ }
41
+
42
+ function setSetting(key, value) {
43
+ db.prepare(
44
+ `INSERT INTO user_settings (key, value) VALUES (?, ?)
45
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
46
+ ).run(key, value);
47
+ }
48
+
49
+ function toCommand(row) {
50
+ if (!row) return undefined;
51
+ return {
52
+ id: row.id,
53
+ name: row.name,
54
+ description: row.description,
55
+ instruction: row.instruction,
56
+ created_at: row.created_at,
57
+ updated_at: row.updated_at
58
+ };
59
+ }
60
+
61
+ return {
62
+ /** The user's self-description (free text) or "" when unset. */
63
+ getProfile() {
64
+ return getSetting("profile") ?? "";
65
+ },
66
+ setProfile(text) {
67
+ setSetting("profile", String(text ?? ""));
68
+ },
69
+
70
+ /** Behavior rules as an array of strings. */
71
+ getRules() {
72
+ return parseList(getSetting("rules") ?? "[]").filter((r) => typeof r === "string");
73
+ },
74
+ setRules(rules) {
75
+ const list = Array.isArray(rules) ? rules.filter((r) => typeof r === "string") : [];
76
+ setSetting("rules", JSON.stringify(list));
77
+ },
78
+
79
+ /** All custom commands, sorted by name. */
80
+ listCommands() {
81
+ const rows = db.prepare("SELECT * FROM custom_commands ORDER BY name ASC").all();
82
+ return rows.map(toCommand);
83
+ },
84
+
85
+ /**
86
+ * Add or replace a custom command by name.
87
+ * @returns the stored command.
88
+ * @throws when name is invalid or does not match DSH's command-name grammar.
89
+ */
90
+ addCommand({ name, description = "", instruction }) {
91
+ const cmdName = String(name ?? "").trim();
92
+ if (!COMMAND_NAME.test(cmdName)) {
93
+ throw new Error(`invalid command name "${cmdName}": must match /^[a-z][a-z0-9_-]*$/`);
94
+ }
95
+ if (typeof instruction !== "string" || !instruction.trim()) {
96
+ throw new Error("command instruction must be a non-empty string");
97
+ }
98
+ const now = new Date().toISOString();
99
+ const existing = db.prepare("SELECT id FROM custom_commands WHERE name = ?").get(cmdName);
100
+ if (existing) {
101
+ db.prepare(
102
+ "UPDATE custom_commands SET description = ?, instruction = ?, updated_at = ? WHERE id = ?"
103
+ ).run(String(description ?? ""), instruction, now, existing.id);
104
+ return toCommand(db.prepare("SELECT * FROM custom_commands WHERE id = ?").get(existing.id));
105
+ }
106
+ const id = randomUUID();
107
+ db.prepare(
108
+ `INSERT INTO custom_commands (id, name, description, instruction, created_at, updated_at)
109
+ VALUES (?, ?, ?, ?, ?, ?)`
110
+ ).run(id, cmdName, String(description ?? ""), instruction, now, now);
111
+ return toCommand(db.prepare("SELECT * FROM custom_commands WHERE id = ?").get(id));
112
+ },
113
+
114
+ /** Remove a custom command by id; returns true when removed. */
115
+ removeCommand(id) {
116
+ const result = db.prepare("DELETE FROM custom_commands WHERE id = ?").run(id);
117
+ return result.changes > 0;
118
+ }
119
+ };
120
+ }
package/src/store.js CHANGED
@@ -193,8 +193,9 @@ export function createStore(path) {
193
193
  function search(query, { limit = 20, includeArchived = false } = {}) {
194
194
  const q = String(query).trim();
195
195
  if (!q) return [];
196
- // FTS5 over unicode61 (English + long phrases); LIKE fallback covers CJK substring.
197
- // LIKE wildcards in the query are escaped so user input is matched literally.
196
+ // Plain LIKE substring scan over title/content/tags (wildcards escaped so
197
+ // user input matches literally). No FTS5: CJK substring matching needs
198
+ // LIKE, and typical memory stores are small enough that a scan is fine.
198
199
  const like = `%${escapeLike(q)}%`;
199
200
  const { limit: lim } = sanitizePage(limit, 0, 20);
200
201
  const archivedFilter = includeArchived ? "" : "archived = 0 AND ";