@nogataka/claw-memory 0.1.3 → 0.3.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/dist/ui/page.js CHANGED
@@ -42,17 +42,37 @@ export const PAGE = /* html */ `<!doctype html>
42
42
  .pref b { color:var(--accent); }
43
43
  .empty { color:var(--muted); padding:30px; text-align:center; }
44
44
  .cmeta { color:var(--muted); font-size:11px; margin:2px 0; word-break:break-all; }
45
- header button#logsBtn { background:var(--chip); color:var(--fg); border:1px solid var(--border);
46
- border-radius:6px; padding:6px 10px; cursor:pointer; font-size:12px; }
47
- header button#logsBtn.on { background:var(--accent); color:#0d1117; border-color:var(--accent); }
45
+ header button#logsBtn, header button#lessonsBtn { background:var(--chip); color:var(--fg);
46
+ border:1px solid var(--border); border-radius:6px; padding:6px 10px; cursor:pointer; font-size:12px; }
47
+ header button#logsBtn.on, header button#lessonsBtn.on { background:var(--accent); color:#0d1117; border-color:var(--accent); }
48
48
  .src { color:var(--accent); font-size:10px; }
49
49
  .hl { background:#9e6a03; color:#fff; border-radius:2px; padding:0 1px; }
50
+ .lstatus { border-radius:4px; padding:1px 6px; font-size:10px; font-weight:600; }
51
+ .lstatus.candidate { background:#3a2d00; color:#e3b341; }
52
+ .lstatus.approved { background:#03361a; color:#3fb950; }
53
+ .lstatus.rejected { background:#3a0d0d; color:#f85149; }
54
+ .lstatus.archived { background:#21262d; color:#8b949e; }
55
+ .lstatus.superseded{ background:#26203a; color:#a371f7; }
56
+ .lact { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; }
57
+ .lact button, .lact select { background:var(--chip); color:var(--fg); border:1px solid var(--border);
58
+ border-radius:6px; padding:4px 9px; cursor:pointer; font-size:11px; }
59
+ .lact button:hover { background:var(--accent); color:#0d1117; }
60
+ .lact button.danger:hover { background:#f85149; color:#fff; }
61
+ .lbody { white-space:pre-wrap; word-break:break-word; margin:6px 0; }
62
+ .lcond { font-size:12px; color:var(--muted); margin:3px 0; }
63
+ .lcond b { color:var(--fg); font-weight:600; }
64
+ .lhist { font-size:11px; color:var(--muted); margin-top:8px; border-top:1px solid var(--border); padding-top:6px; }
65
+ .lfilter { display:flex; gap:6px; margin-bottom:14px; flex-wrap:wrap; }
66
+ .lfilter button { background:var(--chip); color:var(--fg); border:1px solid var(--border);
67
+ border-radius:14px; padding:4px 12px; cursor:pointer; font-size:12px; }
68
+ .lfilter button.on { background:var(--accent); color:#0d1117; border-color:var(--accent); }
50
69
  </style>
51
70
  </head>
52
71
  <body>
53
72
  <header>
54
73
  <h1>🧠 claw-memory</h1>
55
74
  <span class="muted" id="stats"></span>
75
+ <button id="lessonsBtn" title="レッスン (再利用可能な知識のレビュー)">📚 Lessons</button>
56
76
  <button id="logsBtn" title="生ログ全文検索 (Claude Code + Codex)">🔎 ログ検索</button>
57
77
  <input id="search" placeholder="フィルタ (本文を絞り込み)" />
58
78
  </header>
@@ -90,13 +110,14 @@ function renderNav() {
90
110
  '<button data-id="'+p.id+'" class="'+(p.id===current?'active':'')+'">'
91
111
  + '<div class="nm">'+esc(p.name)+'</div>'
92
112
  + '<div class="pth">'+esc(p.path)+'</div>'
93
- + '<div class="pth">'+p.counts.summaries+' sum · '+p.counts.chunks+' chunk · '+p.counts.preferences+' pref</div>'
113
+ + '<div class="pth">'+p.counts.summaries+' sum · '+p.counts.chunks+' chunk · '+p.counts.preferences+' pref · '+(p.counts.lessons||0)+' lesson</div>'
94
114
  + '</button>').join("");
95
115
  el("nav").querySelectorAll("button").forEach(b =>
96
116
  b.onclick = () => select(b.dataset.id));
97
117
  }
98
118
  async function select(id) {
99
119
  current = id; renderNav();
120
+ if (lessonMode) { loadLessons(); return; }
100
121
  const d = await (await fetch("/api/memory?project="+encodeURIComponent(id))).json();
101
122
  render(d);
102
123
  }
@@ -136,6 +157,7 @@ function currentPath() { const p = projects.find(x => x.id === current); return
136
157
  el("logsBtn").addEventListener("click", () => {
137
158
  logMode = !logMode;
138
159
  el("logsBtn").classList.toggle("on", logMode);
160
+ if (logMode && lessonMode) { lessonMode = false; el("lessonsBtn").classList.remove("on"); }
139
161
  el("search").value = "";
140
162
  el("search").placeholder = logMode
141
163
  ? "生ログ全文検索 (このプロジェクトのCC+Codexログ)"
@@ -175,6 +197,105 @@ async function runLogSearch(q) {
175
197
  el("main").innerHTML = '<div class="sect"><h2>Raw Log Search — ' + d.total + ' hits (showing ' + d.results.length + ')</h2>' + cards + '</div>';
176
198
  }
177
199
 
200
+ // --- Lessons view ---------------------------------------------------------
201
+ let lessonMode = false, lessonStatus = "candidate";
202
+ const LSTATUSES = ["candidate","approved","conflicts","rejected","archived","superseded"];
203
+
204
+ el("lessonsBtn").addEventListener("click", () => {
205
+ lessonMode = !lessonMode;
206
+ el("lessonsBtn").classList.toggle("on", lessonMode);
207
+ if (lessonMode && logMode) { logMode = false; el("logsBtn").classList.remove("on"); }
208
+ el("search").value = ""; filter = "";
209
+ el("search").placeholder = lessonMode ? "レッスンを絞り込み (本文)" : "フィルタ (本文を絞り込み)";
210
+ if (current) select(current);
211
+ else el("main").innerHTML = '<div class="empty">プロジェクトを選択してください</div>';
212
+ });
213
+
214
+ async function loadLessons() {
215
+ if (!current) return;
216
+ el("main").innerHTML = '<div class="empty">読み込み中…</div>';
217
+ const url = "/api/lessons?project=" + encodeURIComponent(current) + "&status=" + encodeURIComponent(lessonStatus);
218
+ let d;
219
+ try { d = await (await fetch(url)).json(); }
220
+ catch { el("main").innerHTML = '<div class="empty">読み込みに失敗しました</div>'; return; }
221
+ renderLessons(d);
222
+ }
223
+
224
+ function lfilterBar(counts) {
225
+ return '<div class="lfilter">' + LSTATUSES.map(s =>
226
+ '<button class="' + (s===lessonStatus?'on':'') + '" data-st="' + s + '">'
227
+ + s + ' (' + (counts[s]||0) + ')</button>').join("") + '</div>';
228
+ }
229
+
230
+ function renderLessons(d) {
231
+ const f = filter.toLowerCase();
232
+ const match = (t) => !f || (t||"").toLowerCase().includes(f);
233
+ const lessons = (d.lessons||[]).filter(l => match(l.title + " " + l.lesson));
234
+ const heading = lessonStatus === "candidate" ? "Candidate Review" : (lessonStatus + " lessons");
235
+ let h = lfilterBar(d.counts||{});
236
+ h += '<div class="sect"><h2>' + esc(heading) + '</h2>';
237
+ h += lessons.length ? lessons.map(lessonCard).join("") : '<div class="empty">該当するレッスンがありません</div>';
238
+ h += '</div>';
239
+ el("main").innerHTML = h;
240
+ el("main").querySelectorAll(".lfilter button").forEach(b =>
241
+ b.onclick = () => { lessonStatus = b.dataset.st; loadLessons(); });
242
+ bindLessonActions();
243
+ }
244
+
245
+ function lessonCard(l) {
246
+ const cond = (label, arr) => (arr && arr.length)
247
+ ? '<div class="lcond"><b>' + label + ':</b> ' + arr.map(esc).join("; ") + '</div>' : "";
248
+ const tags = [];
249
+ if (l.concepts && l.concepts.length) tags.push('🏷 ' + l.concepts.map(esc).join(", "));
250
+ if (l.files && l.files.length) tags.push('📄 ' + l.files.map(esc).join(", "));
251
+ return '<div class="card" data-id="' + l.id + '">'
252
+ + '<div class="meta">'
253
+ + '<span class="lstatus ' + esc(l.status) + '">' + esc(l.status) + '</span>'
254
+ + '<span class="tag">' + esc(l.scope) + '</span>'
255
+ + '<span class="tag">conf ' + Number(l.confidence).toFixed(2) + '</span>'
256
+ + (l.createdAt||"").split("T")[0] + '</div>'
257
+ + '<div class="u" style="font-weight:600">' + esc(l.title) + '</div>'
258
+ + '<div class="lbody">' + esc(l.lesson) + '</div>'
259
+ + cond("Applies when", l.appliesWhen)
260
+ + cond("Avoid when", l.avoidWhen)
261
+ + (l.evidence ? '<div class="lcond"><b>Evidence:</b> ' + esc(l.evidence) + '</div>' : "")
262
+ + (tags.length ? '<div class="cmeta">' + tags.join(" · ") + '</div>' : "")
263
+ + lessonActions(l)
264
+ + '</div>';
265
+ }
266
+
267
+ function lessonActions(l) {
268
+ const scopeOpts = ["global","project","repo","file","task","user_preference","team"]
269
+ .map(s => '<option ' + (s===l.scope?'selected':'') + '>' + s + '</option>').join("");
270
+ let btns = "";
271
+ if (l.status !== "approved") btns += '<button data-act="approve">✓ Approve</button>';
272
+ if (l.status !== "rejected") btns += '<button class="danger" data-act="reject">✕ Reject</button>';
273
+ if (l.status !== "archived") btns += '<button data-act="archive">🗄 Archive</button>';
274
+ btns += '<select data-act="scope" title="scope変更">' + scopeOpts + '</select>';
275
+ return '<div class="lact">' + btns + '</div>';
276
+ }
277
+
278
+ function bindLessonActions() {
279
+ el("main").querySelectorAll(".card[data-id]").forEach(card => {
280
+ const id = card.dataset.id;
281
+ card.querySelectorAll(".lact button").forEach(b =>
282
+ b.onclick = () => lessonAction(id, b.dataset.act));
283
+ const sel = card.querySelector('select[data-act="scope"]');
284
+ if (sel) sel.onchange = () => lessonAction(id, "scope", { scope: sel.value });
285
+ });
286
+ }
287
+
288
+ async function lessonAction(id, action, body) {
289
+ try {
290
+ await fetch("/api/lessons/" + encodeURIComponent(id) + "/" + action, {
291
+ method: "POST",
292
+ headers: { "content-type": "application/json" },
293
+ body: JSON.stringify(body||{}),
294
+ });
295
+ } catch { /* ignore */ }
296
+ loadLessons();
297
+ }
298
+
178
299
  boot();
179
300
  connectEvents();
180
301
  </script>
package/dist/ui/server.js CHANGED
@@ -10,6 +10,7 @@ import { listProjects } from "../core/projects.js";
10
10
  import { getRecentSummaries, getPreferences } from "../core/memory.js";
11
11
  import { listChunks, getChunkCount } from "../core/vector-memory.js";
12
12
  import { searchLogs } from "../core/logsearch/search.js";
13
+ import { listLessons, listConflicts, getConflictCount, getLesson, getLessonCount, getEvents, getLinks, setStatus, supersede, updateLesson, } from "../core/lessons.js";
13
14
  import { PAGE } from "./page.js";
14
15
  function countSummaries(projectId) {
15
16
  return sqlite
@@ -30,7 +31,7 @@ export function buildUiApp() {
30
31
  // cheap, I/O-free change signal — no client-side polling required.
31
32
  app.get("/api/events", (c) => {
32
33
  return streamSSE(c, async (stream) => {
33
- const dataVersion = () => sqlite.pragma("data_version", { simple: true });
34
+ const dataVersion = () => sqlite.prepare("PRAGMA data_version").get().data_version;
34
35
  let last = dataVersion();
35
36
  await stream.writeSSE({ event: "ready", data: String(last) });
36
37
  while (!stream.closed && !stream.aborted) {
@@ -62,6 +63,7 @@ export function buildUiApp() {
62
63
  summaries: countSummaries(p.id),
63
64
  chunks: getChunkCount(p.id),
64
65
  preferences: countPreferences(p.id),
66
+ lessons: getLessonCount({ projectId: p.id }),
65
67
  },
66
68
  }));
67
69
  return c.json(out);
@@ -76,6 +78,68 @@ export function buildUiApp() {
76
78
  chunks: listChunks(projectId, 300),
77
79
  });
78
80
  });
81
+ // --- Lesson layer -------------------------------------------------------
82
+ // The viewer is read-only for chunks/summaries, but lessons are reviewable:
83
+ // these POST routes are the only writes the UI performs, and only ever touch
84
+ // the lessons tables.
85
+ app.get("/api/lessons", (c) => {
86
+ const projectId = c.req.query("project") || undefined;
87
+ const status = c.req.query("status") || undefined;
88
+ // "conflicts" is a virtual view (lessons in a conflicts_with link), not a
89
+ // real status column value.
90
+ const lessons = status === "conflicts"
91
+ ? listConflicts(projectId, 500)
92
+ : listLessons({ projectId, status }, 500);
93
+ return c.json({
94
+ lessons,
95
+ counts: {
96
+ candidate: getLessonCount({ projectId, status: "candidate" }),
97
+ approved: getLessonCount({ projectId, status: "approved" }),
98
+ rejected: getLessonCount({ projectId, status: "rejected" }),
99
+ archived: getLessonCount({ projectId, status: "archived" }),
100
+ superseded: getLessonCount({ projectId, status: "superseded" }),
101
+ conflicts: getConflictCount(projectId),
102
+ },
103
+ });
104
+ });
105
+ app.get("/api/lessons/:id", (c) => {
106
+ const lesson = getLesson(c.req.param("id"));
107
+ if (!lesson)
108
+ return c.json({ error: "not found" }, 404);
109
+ return c.json({ lesson, events: getEvents(lesson.id), links: getLinks(lesson.id) });
110
+ });
111
+ app.post("/api/lessons/:id/:action", async (c) => {
112
+ const id = c.req.param("id");
113
+ const action = c.req.param("action");
114
+ const body = await c.req.json().catch(() => ({}));
115
+ let ok = false;
116
+ switch (action) {
117
+ case "approve":
118
+ ok = setStatus(id, "approved");
119
+ break;
120
+ case "reject":
121
+ ok = setStatus(id, "rejected", body.reason);
122
+ break;
123
+ case "archive":
124
+ ok = setStatus(id, "archived", body.reason);
125
+ break;
126
+ case "status":
127
+ ok = setStatus(id, body.status, body.note);
128
+ break;
129
+ case "scope":
130
+ ok = updateLesson(id, { scope: String(body.scope) });
131
+ break;
132
+ case "confidence":
133
+ ok = updateLesson(id, { confidence: Number(body.confidence) });
134
+ break;
135
+ case "supersede":
136
+ ok = supersede(id, String(body.newId));
137
+ break;
138
+ default:
139
+ return c.json({ error: "unknown action" }, 400);
140
+ }
141
+ return c.json({ ok }, ok ? 200 : 404);
142
+ });
79
143
  // Raw transcript search (cc-search port) — Claude Code + Codex logs.
80
144
  app.get("/api/logs", async (c) => {
81
145
  const query = c.req.query("q") ?? "";
@@ -4,18 +4,42 @@
4
4
  # mcp: claw-hook.sh mcp (stdio MCP server)
5
5
  # Prefers a globally installed `claw-memory`; falls back to npx. stdin/stdout are
6
6
  # inherited so hook input and MCP stdio pass through. Never blocks a session:
7
- # hook failures are swallowed, but `mcp` must exec directly (no error masking).
7
+ # hook failures don't propagate, but they are LOGGED (not swallowed) — a silent
8
+ # 2>/dev/null here once hid a 3-week total outage. `mcp` execs directly.
8
9
  set -euo pipefail
9
10
 
10
- run() {
11
- if command -v claw-memory >/dev/null 2>&1; then
12
- exec claw-memory "$@"
13
- fi
14
- exec npx -y @nogataka/claw-memory@latest "$@"
15
- }
11
+ HOOK_ERR_LOG="${CLAW_MEMORY_DIR:-$HOME/.claw-memory}/logs/hook-error.log"
12
+
13
+ # node:sqlite still carries an ExperimentalWarning on stderr; silence it so the
14
+ # error log only contains real failures.
15
+ export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--disable-warning=ExperimentalWarning"
16
+
17
+ # Resolve the binary into $CMD (array, npx form needs multiple words).
18
+ if command -v claw-memory >/dev/null 2>&1; then
19
+ CMD=(claw-memory)
20
+ else
21
+ CMD=(npx -y @nogataka/claw-memory@latest)
22
+ fi
16
23
 
17
24
  if [ "${1:-}" = "mcp" ]; then
18
- run "$@"
25
+ exec "${CMD[@]}" "$@"
26
+ fi
27
+
28
+ mkdir -p "$(dirname "$HOOK_ERR_LOG")" 2>/dev/null || true
29
+ # Capture stderr synchronously to a temp file, then append it timestamped.
30
+ # (An async process-substitution logger here raced script exit and dropped
31
+ # lines — exactly the silent-failure mode this log exists to prevent.)
32
+ ERR_TMP="$(mktemp "${TMPDIR:-/tmp}/claw-hook-err.XXXXXX" 2>/dev/null)" || ERR_TMP=""
33
+ if [ -n "$ERR_TMP" ]; then
34
+ "${CMD[@]}" "$@" 2>"$ERR_TMP" || true
35
+ if [ -s "$ERR_TMP" ]; then
36
+ TS="$(date '+%Y-%m-%dT%H:%M:%S%z')"
37
+ while IFS= read -r line || [ -n "$line" ]; do
38
+ printf '%s %s\n' "$TS" "$line"
39
+ done < "$ERR_TMP" >> "$HOOK_ERR_LOG" || true
40
+ fi
41
+ rm -f "$ERR_TMP"
19
42
  else
20
- run "$@" 2>/dev/null || true
43
+ # mktemp failed; still never silent — append raw stderr directly.
44
+ "${CMD[@]}" "$@" 2>>"$HOOK_ERR_LOG" || true
21
45
  fi
@@ -11,17 +11,6 @@
11
11
  ]
12
12
  }
13
13
  ],
14
- "UserPromptSubmit": [
15
- {
16
- "hooks": [
17
- {
18
- "type": "command",
19
- "command": "\"$CLAUDE_PLUGIN_ROOT/hooks/run-hook.cmd\" claw-hook.sh hook recall",
20
- "async": false
21
- }
22
- ]
23
- }
24
- ],
25
14
  "Stop": [
26
15
  {
27
16
  "hooks": [
package/hooks/hooks.json CHANGED
@@ -11,17 +11,6 @@
11
11
  ]
12
12
  }
13
13
  ],
14
- "UserPromptSubmit": [
15
- {
16
- "hooks": [
17
- {
18
- "type": "command",
19
- "command": "\"$CLAUDE_PLUGIN_ROOT/hooks/run-hook.cmd\" claw-hook.sh hook recall",
20
- "async": false
21
- }
22
- ]
23
- }
24
- ],
25
14
  "Stop": [
26
15
  {
27
16
  "hooks": [
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@nogataka/claw-memory",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
- "description": "Independent, in-process semantic memory MCP server (sqlite-vec + local Xenova e5) with a lightweight web viewer. No daemon, no Python. Installable as a Claude Code plugin and a Codex MCP server.",
5
+ "description": "Independent, in-process semantic memory MCP server (node:sqlite + sqlite-vec + local Xenova e5) with a lightweight web viewer. No daemon, no Python, no native ABI. Installable as a Claude Code plugin and a Codex MCP server.",
6
6
  "license": "MIT",
7
7
  "author": "nogataka",
8
8
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "README.ja.md"
33
33
  ],
34
34
  "engines": {
35
- "node": ">=20.0.0"
35
+ "node": ">=24.0.0"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"
@@ -53,14 +53,12 @@
53
53
  "@modelcontextprotocol/sdk": "^1.0.0",
54
54
  "@openai/codex-sdk": "^0.135.0",
55
55
  "@xenova/transformers": "^2.17.2",
56
- "better-sqlite3": "12.8.0",
57
56
  "hono": "^4.6.0",
58
57
  "sqlite-vec": "^0.1.7-alpha.2",
59
58
  "zod": "^3.24.0"
60
59
  },
61
60
  "devDependencies": {
62
- "@types/better-sqlite3": "^7.6.11",
63
- "@types/node": "^22.0.0",
61
+ "@types/node": "^24.0.0",
64
62
  "tsx": "^4.19.0",
65
63
  "typescript": "^5.6.0"
66
64
  }