@nogataka/claw-memory 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.
@@ -0,0 +1,34 @@
1
+ // src/core/embeddings.ts
2
+ //
3
+ // Fully local embeddings via Xenova Transformers.js (multilingual-e5-small,
4
+ // 384-dim). No API, no network at inference time, multilingual (Japanese OK).
5
+ /* eslint-disable @typescript-eslint/no-explicit-any */
6
+ let pipelineInstance = null;
7
+ let loadingPromise = null;
8
+ async function getEmbeddingPipeline() {
9
+ if (pipelineInstance)
10
+ return pipelineInstance;
11
+ if (loadingPromise)
12
+ return loadingPromise;
13
+ loadingPromise = (async () => {
14
+ const { pipeline } = await import("@xenova/transformers");
15
+ pipelineInstance = await pipeline("feature-extraction", "Xenova/multilingual-e5-small");
16
+ return pipelineInstance;
17
+ })();
18
+ pipelineInstance = await loadingPromise;
19
+ loadingPromise = null;
20
+ return pipelineInstance;
21
+ }
22
+ async function embed(text) {
23
+ const extractor = await getEmbeddingPipeline();
24
+ const output = await extractor(text, { pooling: "mean", normalize: true });
25
+ return new Float32Array(output.data);
26
+ }
27
+ /** Embed text for storage. e5 requires the "passage: " prefix. */
28
+ export async function embedPassage(text) {
29
+ return embed(`passage: ${text}`);
30
+ }
31
+ /** Embed text for search. e5 requires the "query: " prefix. */
32
+ export async function embedQuery(text) {
33
+ return embed(`query: ${text}`);
34
+ }
@@ -0,0 +1,16 @@
1
+ // src/core/excludes.ts
2
+ //
3
+ // Project exclusion: paths matching CLAW_MEMORY_EXCLUDED_PROJECTS (comma or
4
+ // path-separator separated substrings) are never recorded or recalled.
5
+ function patterns() {
6
+ const raw = process.env.CLAW_MEMORY_EXCLUDED_PROJECTS ?? "";
7
+ return raw
8
+ .split(/[,:]/)
9
+ .map((s) => s.trim())
10
+ .filter(Boolean);
11
+ }
12
+ export function isExcludedPath(cwd) {
13
+ if (!cwd)
14
+ return false;
15
+ return patterns().some((p) => cwd.includes(p));
16
+ }
@@ -0,0 +1,76 @@
1
+ // src/core/hooks.ts
2
+ //
3
+ // Claude Code lifecycle-hook handlers. Spawned per event (no daemon):
4
+ // - Stop / SessionEnd → runDistillHook (auto-distill the finished session)
5
+ // - SessionStart / Prompt → runRecallHook (inject memory block into context)
6
+ // Hook input arrives as JSON on stdin; recall output goes to stdout.
7
+ import { statSync } from "node:fs";
8
+ import { spawn } from "node:child_process";
9
+ import { fileURLToPath } from "node:url";
10
+ import { getOrCreateProjectByPath } from "./projects.js";
11
+ import { buildMemoryBlock } from "./recall.js";
12
+ import { shouldDistill } from "./watermark.js";
13
+ import { isExcludedPath } from "./excludes.js";
14
+ import { log } from "./logger.js";
15
+ export async function readStdinJson() {
16
+ const chunks = [];
17
+ for await (const c of process.stdin)
18
+ chunks.push(c);
19
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
20
+ if (!raw)
21
+ return {};
22
+ try {
23
+ return JSON.parse(raw);
24
+ }
25
+ catch {
26
+ return {};
27
+ }
28
+ }
29
+ const CLI_PATH = fileURLToPath(new URL("../cli.js", import.meta.url));
30
+ /**
31
+ * Stop / SessionEnd: distill the just-finished transcript. Detached and
32
+ * fire-and-forget so the user's session isn't blocked by the LLM call. The
33
+ * watermark is re-checked (and stamped) inside the spawned `distill --if-stale`.
34
+ */
35
+ export function runDistillHook(input) {
36
+ const transcriptPath = input.transcript_path;
37
+ const cwd = input.cwd ?? process.cwd();
38
+ if (!transcriptPath || isExcludedPath(cwd))
39
+ return;
40
+ let mtimeMs = 0;
41
+ try {
42
+ mtimeMs = statSync(transcriptPath).mtimeMs;
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ if (!shouldDistill(transcriptPath, mtimeMs))
48
+ return;
49
+ log("hook.distill.spawn", { cwd, session: input.session_id });
50
+ const child = spawn(process.execPath, [
51
+ CLI_PATH,
52
+ "distill",
53
+ "--path",
54
+ transcriptPath,
55
+ "--cwd",
56
+ cwd,
57
+ "--session",
58
+ input.session_id ?? transcriptPath,
59
+ "--if-stale",
60
+ ], { detached: true, stdio: "ignore" });
61
+ child.unref();
62
+ }
63
+ /**
64
+ * SessionStart / UserPromptSubmit: print the memory block to stdout. When the
65
+ * event carries the user's prompt it also pulls semantically similar past
66
+ * conversations; otherwise just preferences + recent summaries.
67
+ */
68
+ export async function runRecallHook(input) {
69
+ const cwd = input.cwd ?? process.cwd();
70
+ if (isExcludedPath(cwd))
71
+ return;
72
+ const project = getOrCreateProjectByPath(cwd);
73
+ const block = await buildMemoryBlock(project.id, input.prompt ?? "", 5);
74
+ if (block.fullText.trim())
75
+ process.stdout.write(block.fullText + "\n");
76
+ }
@@ -0,0 +1,87 @@
1
+ // src/core/installer/claude.ts
2
+ //
3
+ // Manual (non-plugin) Claude Code setup: merge the claw-memory MCP server and
4
+ // the recall/distill hooks into ~/.claude/settings.json. Use this only if you
5
+ // are NOT installing the Claude Code plugin (the plugin wires these up itself).
6
+ // Idempotent and reversible; settings.json is backed up before writing.
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { execFileSync } from "node:child_process";
10
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from "node:fs";
11
+ const CLAUDE_DIR = join(homedir(), ".claude");
12
+ const SETTINGS = join(CLAUDE_DIR, "settings.json");
13
+ const HOOK_EVENTS = [
14
+ ["SessionStart", "hook recall", false],
15
+ ["UserPromptSubmit", "hook recall", false],
16
+ ["Stop", "hook distill", true],
17
+ ];
18
+ /** Resolve how to invoke claw-memory: global binary or npx. */
19
+ function invoker() {
20
+ try {
21
+ const found = execFileSync("command", ["-v", "claw-memory"], {
22
+ shell: "/bin/bash",
23
+ encoding: "utf-8",
24
+ }).trim().split("\n")[0];
25
+ if (found)
26
+ return { mcp: { command: found, args: ["mcp"] }, cli: "claw-memory" };
27
+ }
28
+ catch {
29
+ // not on PATH
30
+ }
31
+ return {
32
+ mcp: { command: "npx", args: ["-y", "@nogataka/claw-memory@latest", "mcp"] },
33
+ cli: "npx -y @nogataka/claw-memory@latest",
34
+ };
35
+ }
36
+ function read() {
37
+ if (!existsSync(SETTINGS))
38
+ return {};
39
+ try {
40
+ return JSON.parse(readFileSync(SETTINGS, "utf-8"));
41
+ }
42
+ catch {
43
+ return {};
44
+ }
45
+ }
46
+ function isOurs(cmd) {
47
+ return cmd.includes("claw-memory") && cmd.includes("hook ");
48
+ }
49
+ function save(s) {
50
+ mkdirSync(CLAUDE_DIR, { recursive: true });
51
+ if (existsSync(SETTINGS))
52
+ copyFileSync(SETTINGS, SETTINGS + ".bak");
53
+ writeFileSync(SETTINGS, JSON.stringify(s, null, 2) + "\n");
54
+ }
55
+ export function installClaude() {
56
+ const { mcp, cli } = invoker();
57
+ const s = read();
58
+ s.mcpServers = { ...(s.mcpServers ?? {}), "claw-memory": mcp };
59
+ s.hooks = s.hooks ?? {};
60
+ for (const [event, sub, isAsync] of HOOK_EVENTS) {
61
+ const list = (s.hooks[event] ?? []).filter((g) => !g.hooks?.some((h) => isOurs(h.command)));
62
+ const entry = { hooks: [{ type: "command", command: `${cli} ${sub}`, async: isAsync }] };
63
+ list.push(entry);
64
+ s.hooks[event] = list;
65
+ }
66
+ save(s);
67
+ return [
68
+ `settings.json: mcpServers.claw-memory (${mcp.command})`,
69
+ "settings.json: SessionStart/UserPromptSubmit→recall, Stop→distill",
70
+ ];
71
+ }
72
+ export function uninstallClaude() {
73
+ const s = read();
74
+ if (s.mcpServers)
75
+ delete s.mcpServers["claw-memory"];
76
+ if (s.hooks) {
77
+ for (const event of Object.keys(s.hooks)) {
78
+ s.hooks[event] = s.hooks[event]
79
+ .map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !isOurs(h.command)) }))
80
+ .filter((g) => g.hooks.length > 0);
81
+ if (s.hooks[event].length === 0)
82
+ delete s.hooks[event];
83
+ }
84
+ }
85
+ save(s);
86
+ return ["settings.json: removed claw-memory mcp + hooks"];
87
+ }
@@ -0,0 +1,122 @@
1
+ // src/core/installer/codex.ts
2
+ //
3
+ // Codex has no third-party plugin marketplace, so "installing" claw-memory means
4
+ // idempotently editing ~/.codex config: register the MCP server in config.toml,
5
+ // drop a memory-recall skill, and add an AGENTS.md instruction. All edits live
6
+ // inside marker blocks so they can be cleanly removed and never clobber the
7
+ // user's own settings. config.toml is backed up before writing.
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { execFileSync } from "node:child_process";
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, rmSync, } from "node:fs";
13
+ const CODEX_DIR = join(homedir(), ".codex");
14
+ const CONFIG = join(CODEX_DIR, "config.toml");
15
+ const AGENTS = join(CODEX_DIR, "AGENTS.md");
16
+ const SKILL_DIR = join(CODEX_DIR, "skills", "memory-recall");
17
+ const BEGIN = "# >>> claw-memory >>>";
18
+ const END = "# <<< claw-memory <<<";
19
+ const A_BEGIN = "<!-- >>> claw-memory >>> -->";
20
+ const A_END = "<!-- <<< claw-memory <<< -->";
21
+ /** TOML basic-string literal with escaping. */
22
+ function tomlStr(s) {
23
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
24
+ }
25
+ /** Prefer a globally installed `claw-memory`; otherwise run via npx. */
26
+ function resolveCommand() {
27
+ try {
28
+ const found = execFileSync("command", ["-v", "claw-memory"], {
29
+ shell: "/bin/bash",
30
+ encoding: "utf-8",
31
+ }).trim();
32
+ if (found)
33
+ return { command: found.split("\n")[0], args: ["mcp"] };
34
+ }
35
+ catch {
36
+ // not on PATH
37
+ }
38
+ return { command: "npx", args: ["-y", "@nogataka/claw-memory@latest", "mcp"] };
39
+ }
40
+ /** Insert or replace the marker-delimited block in `content`. */
41
+ function upsertBlock(content, begin, end, block) {
42
+ const b = content.indexOf(begin);
43
+ const e = content.indexOf(end);
44
+ if (b !== -1 && e !== -1 && e > b) {
45
+ return content.slice(0, b) + block + content.slice(e + end.length);
46
+ }
47
+ const sep = content && !content.endsWith("\n") ? "\n\n" : content ? "\n" : "";
48
+ return content + sep + block + "\n";
49
+ }
50
+ /** Remove the marker-delimited block (and a trailing blank line) if present. */
51
+ function removeBlock(content, begin, end) {
52
+ const b = content.indexOf(begin);
53
+ const e = content.indexOf(end);
54
+ if (b === -1 || e === -1 || e < b)
55
+ return content;
56
+ let out = content.slice(0, b) + content.slice(e + end.length);
57
+ return out.replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
58
+ }
59
+ function skillSource() {
60
+ // dist/core/installer/codex.js -> package root /skills/memory-recall/SKILL.md
61
+ return fileURLToPath(new URL("../../../skills/memory-recall/SKILL.md", import.meta.url));
62
+ }
63
+ export function installCodex() {
64
+ const done = [];
65
+ mkdirSync(CODEX_DIR, { recursive: true });
66
+ // 1) MCP server registration in config.toml
67
+ const { command, args } = resolveCommand();
68
+ const block = [
69
+ BEGIN,
70
+ "[mcp_servers.claw-memory]",
71
+ `command = ${tomlStr(command)}`,
72
+ `args = [${args.map(tomlStr).join(", ")}]`,
73
+ END,
74
+ ].join("\n");
75
+ const prev = existsSync(CONFIG) ? readFileSync(CONFIG, "utf-8") : "";
76
+ if (existsSync(CONFIG))
77
+ copyFileSync(CONFIG, CONFIG + ".bak");
78
+ writeFileSync(CONFIG, upsertBlock(prev, BEGIN, END, block));
79
+ done.push(`config.toml: [mcp_servers.claw-memory] (${command})`);
80
+ // 2) memory-recall skill
81
+ try {
82
+ mkdirSync(SKILL_DIR, { recursive: true });
83
+ copyFileSync(skillSource(), join(SKILL_DIR, "SKILL.md"));
84
+ done.push(`skill: ${SKILL_DIR}/SKILL.md`);
85
+ }
86
+ catch (e) {
87
+ done.push(`skill: SKIPPED (${String(e)})`);
88
+ }
89
+ // 3) AGENTS.md recall instruction
90
+ const agentsBlock = [
91
+ A_BEGIN,
92
+ "## claw-memory",
93
+ "",
94
+ "セッション冒頭で MCP ツール `memory_recall`(query=ユーザーの依頼)を呼び、過去の好み・要約・類似会話を取得すること。",
95
+ "特定の話題を探すときは `memory_search`、過去の生ログ(Claude Code/Codex)は `memory_search_logs` を使う。",
96
+ A_END,
97
+ ].join("\n");
98
+ const agentsPrev = existsSync(AGENTS) ? readFileSync(AGENTS, "utf-8") : "";
99
+ writeFileSync(AGENTS, upsertBlock(agentsPrev, A_BEGIN, A_END, agentsBlock));
100
+ done.push("AGENTS.md: recall instruction");
101
+ return done;
102
+ }
103
+ export function uninstallCodex() {
104
+ const done = [];
105
+ if (existsSync(CONFIG)) {
106
+ copyFileSync(CONFIG, CONFIG + ".bak");
107
+ writeFileSync(CONFIG, removeBlock(readFileSync(CONFIG, "utf-8"), BEGIN, END));
108
+ done.push("config.toml: removed claw-memory block");
109
+ }
110
+ if (existsSync(AGENTS)) {
111
+ writeFileSync(AGENTS, removeBlock(readFileSync(AGENTS, "utf-8"), A_BEGIN, A_END));
112
+ done.push("AGENTS.md: removed claw-memory block");
113
+ }
114
+ try {
115
+ rmSync(SKILL_DIR, { recursive: true, force: true });
116
+ done.push("skill: removed");
117
+ }
118
+ catch {
119
+ // ignore
120
+ }
121
+ return done;
122
+ }
@@ -0,0 +1,163 @@
1
+ // src/core/llm.ts
2
+ //
3
+ // Pluggable single-turn LLM completion. distill() only ever needs a tool-less
4
+ // one-shot completion (maxTurns:1, allowedTools:[]), so the Claude Agent SDK is
5
+ // not required — it is just one of several interchangeable backends here.
6
+ //
7
+ // CLAW_MEMORY_LLM_BACKEND = agent-sdk (default) | codex-sdk | anthropic | openai-compatible
8
+ //
9
+ // - agent-sdk: zero-config; reuses Claude Code CLI's stored credentials
10
+ // (Claude Pro/Max/Team/Enterprise via the Agent SDK).
11
+ // - codex-sdk: reuses the Codex CLI's stored login (ChatGPT/Codex plan)
12
+ // via @openai/codex-sdk — no API key. Requires Codex CLI.
13
+ // - anthropic: plain Messages API over fetch (needs ANTHROPIC_API_KEY).
14
+ // - openai-compatible: OpenAI/Gemini/OpenRouter/LM Studio chat-completions.
15
+ //
16
+ // Tier routing lets cheap models handle simple work:
17
+ // CLAW_MEMORY_TIER_SMART / _SUMMARY / _SIMPLE (model id per tier)
18
+ // The two SDK backends reuse a subscription login; the HTTP backends need fetch
19
+ // (built into Node >=20).
20
+ import os from "node:os";
21
+ import { query } from "@anthropic-ai/claude-agent-sdk";
22
+ import { buildFullSdkEnv } from "./providers.js";
23
+ export function getBackend() {
24
+ return process.env.CLAW_MEMORY_LLM_BACKEND ?? "agent-sdk";
25
+ }
26
+ /**
27
+ * Resolve the env-configured model id for a tier, or undefined when unset.
28
+ * Each backend applies its own default when this is undefined.
29
+ */
30
+ export function modelForTier(tier) {
31
+ const def = process.env.AGENT_SDK_MODEL ?? process.env.CLAW_MEMORY_MODEL;
32
+ switch (tier) {
33
+ case "simple":
34
+ return process.env.CLAW_MEMORY_TIER_SIMPLE ?? def;
35
+ case "summary":
36
+ return process.env.CLAW_MEMORY_TIER_SUMMARY ?? def;
37
+ case "smart":
38
+ return process.env.CLAW_MEMORY_TIER_SMART ?? def;
39
+ }
40
+ }
41
+ export async function complete(opts) {
42
+ const tier = opts.tier ?? "summary";
43
+ const model = modelForTier(tier);
44
+ const backend = getBackend();
45
+ switch (backend) {
46
+ case "agent-sdk":
47
+ return completeAgentSdk(opts.prompt, model ?? "claude-sonnet-4-5");
48
+ case "codex-sdk":
49
+ return completeCodexSdk(opts.prompt, model);
50
+ case "anthropic":
51
+ return completeAnthropic(opts.prompt, model ?? "claude-sonnet-4-5", opts.maxTokens);
52
+ case "openai-compatible":
53
+ return completeOpenAi(opts.prompt, model, opts.maxTokens);
54
+ default:
55
+ throw new Error(`unknown CLAW_MEMORY_LLM_BACKEND: ${backend} (expected agent-sdk|codex-sdk|anthropic|openai-compatible)`);
56
+ }
57
+ }
58
+ async function completeAgentSdk(prompt, model) {
59
+ const sdkStream = query({
60
+ prompt,
61
+ options: {
62
+ model,
63
+ maxTurns: 1,
64
+ allowedTools: [],
65
+ permissionMode: "bypassPermissions",
66
+ env: buildFullSdkEnv(),
67
+ },
68
+ });
69
+ let text = "";
70
+ for await (const event of sdkStream) {
71
+ const ev = event;
72
+ if (ev.type === "assistant" && ev.message?.role === "assistant") {
73
+ const content = ev.message.content;
74
+ if (typeof content === "string")
75
+ text += content;
76
+ else if (Array.isArray(content)) {
77
+ for (const block of content) {
78
+ const b = block;
79
+ if (b.type === "text" && b.text)
80
+ text += b.text;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return text;
86
+ }
87
+ async function completeCodexSdk(prompt, model) {
88
+ // Dynamically imported so the Codex CLI is only spawned when this backend is
89
+ // actually selected. Uses the Codex CLI's stored login (no API key) unless
90
+ // CLAW_MEMORY_CODEX_API_KEY is set. Locked down for a pure text turn:
91
+ // read-only sandbox, no approvals, no network, no web search.
92
+ const { Codex } = await import("@openai/codex-sdk");
93
+ const apiKey = process.env.CLAW_MEMORY_CODEX_API_KEY;
94
+ const codex = new Codex(apiKey ? { apiKey } : {});
95
+ const thread = codex.startThread({
96
+ model: process.env.CLAW_MEMORY_CODEX_MODEL ?? model,
97
+ sandboxMode: "read-only",
98
+ skipGitRepoCheck: true,
99
+ approvalPolicy: "never",
100
+ networkAccessEnabled: false,
101
+ webSearchEnabled: false,
102
+ workingDirectory: os.tmpdir(),
103
+ });
104
+ const result = await thread.run(prompt);
105
+ return result.finalResponse ?? "";
106
+ }
107
+ async function completeAnthropic(prompt, model, maxTokens = 1024) {
108
+ const key = process.env.ANTHROPIC_API_KEY;
109
+ if (!key) {
110
+ throw new Error("CLAW_MEMORY_LLM_BACKEND=anthropic requires ANTHROPIC_API_KEY");
111
+ }
112
+ const base = process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com";
113
+ const res = await fetch(`${base.replace(/\/$/, "")}/v1/messages`, {
114
+ method: "POST",
115
+ headers: {
116
+ "content-type": "application/json",
117
+ "x-api-key": key,
118
+ "anthropic-version": "2023-06-01",
119
+ },
120
+ body: JSON.stringify({
121
+ model,
122
+ max_tokens: maxTokens,
123
+ messages: [{ role: "user", content: prompt }],
124
+ }),
125
+ });
126
+ if (!res.ok) {
127
+ throw new Error(`anthropic API ${res.status}: ${await res.text()}`);
128
+ }
129
+ const json = (await res.json());
130
+ return (json.content ?? [])
131
+ .filter((b) => b.type === "text" && b.text)
132
+ .map((b) => b.text)
133
+ .join("");
134
+ }
135
+ async function completeOpenAi(prompt, model, maxTokens = 1024) {
136
+ const key = process.env.CLAW_MEMORY_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY;
137
+ if (!key) {
138
+ throw new Error("CLAW_MEMORY_LLM_BACKEND=openai-compatible requires CLAW_MEMORY_OPENAI_API_KEY (or OPENAI_API_KEY)");
139
+ }
140
+ if (!model) {
141
+ throw new Error("CLAW_MEMORY_LLM_BACKEND=openai-compatible requires a model (set CLAW_MEMORY_MODEL)");
142
+ }
143
+ const base = process.env.CLAW_MEMORY_OPENAI_BASE_URL ??
144
+ process.env.OPENAI_BASE_URL ??
145
+ "https://api.openai.com/v1";
146
+ const res = await fetch(`${base.replace(/\/$/, "")}/chat/completions`, {
147
+ method: "POST",
148
+ headers: {
149
+ "content-type": "application/json",
150
+ authorization: `Bearer ${key}`,
151
+ },
152
+ body: JSON.stringify({
153
+ model,
154
+ max_tokens: maxTokens,
155
+ messages: [{ role: "user", content: prompt }],
156
+ }),
157
+ });
158
+ if (!res.ok) {
159
+ throw new Error(`openai-compatible API ${res.status}: ${await res.text()}`);
160
+ }
161
+ const json = (await res.json());
162
+ return json.choices?.[0]?.message?.content ?? "";
163
+ }
@@ -0,0 +1,19 @@
1
+ // src/core/logger.ts
2
+ //
3
+ // Best-effort structured daily log at ~/.claw-memory/logs/claw-YYYY-MM-DD.log.
4
+ // Never throws — logging must not break a memory operation or a hook.
5
+ import { appendFileSync, mkdirSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { dataDir } from "./paths.js";
8
+ const logsDir = path.join(dataDir, "logs");
9
+ export function log(event, data) {
10
+ try {
11
+ mkdirSync(logsDir, { recursive: true });
12
+ const now = new Date().toISOString();
13
+ const line = JSON.stringify({ t: now, event, ...(data ?? {}) }) + "\n";
14
+ appendFileSync(path.join(logsDir, `claw-${now.slice(0, 10)}.log`), line);
15
+ }
16
+ catch {
17
+ // logging is non-critical
18
+ }
19
+ }
Binary file
@@ -0,0 +1,11 @@
1
+ // src/core/logsearch/paths.ts
2
+ //
3
+ // Roots for raw agent transcript logs searched by the cc-search port.
4
+ // Read-only; these belong to Claude Code / Codex, not to claw-memory.
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ export const claudeProjectsRoot = path.join(os.homedir(), ".claude", "projects");
8
+ export const codexSessionsRoot = path.join(os.homedir(), ".codex", "sessions");
9
+ /** Max transcript size to scan; larger files are skipped (cc-search parity). */
10
+ export const MAX_LOG_FILE_SIZE = 10 * 1024 * 1024;
11
+ export const UUID_JSONL_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i;
@@ -0,0 +1,40 @@
1
+ // src/core/logsearch/recent.ts
2
+ //
3
+ // Enumerate Codex rollout transcripts newest-first, for batch distillation
4
+ // (claw-memory distill-codex). Read-only over ~/.codex/sessions.
5
+ import { readdir, stat } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { codexSessionsRoot } from "./paths.js";
8
+ /** All Codex `*.jsonl` rollout files under ~/.codex/sessions, newest first. */
9
+ export async function listCodexSessionFiles() {
10
+ const out = [];
11
+ const stack = [codexSessionsRoot];
12
+ while (stack.length) {
13
+ const cur = stack.pop();
14
+ let entries;
15
+ try {
16
+ entries = await readdir(cur, { withFileTypes: true });
17
+ }
18
+ catch {
19
+ continue;
20
+ }
21
+ for (const e of entries) {
22
+ const full = join(cur, e.name);
23
+ if (e.isDirectory()) {
24
+ stack.push(full);
25
+ }
26
+ else if (e.isFile() && e.name.endsWith(".jsonl")) {
27
+ const s = await stat(full).catch(() => null);
28
+ if (s)
29
+ out.push({ path: full, mtimeMs: s.mtimeMs });
30
+ }
31
+ }
32
+ }
33
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
34
+ return out;
35
+ }
36
+ /** Extract the session UUID from a rollout filename, else the path. */
37
+ export function codexSessionId(filePath) {
38
+ const m = filePath.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
39
+ return m ? m[1] : filePath;
40
+ }