@ectplsm/relic 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.
- package/README.md +261 -103
- package/dist/adapters/shells/claude-hook.d.ts +15 -0
- package/dist/adapters/shells/claude-hook.js +163 -0
- package/dist/adapters/shells/claude-shell.d.ts +5 -2
- package/dist/adapters/shells/claude-shell.js +19 -3
- package/dist/adapters/shells/codex-hook.d.ts +15 -0
- package/dist/adapters/shells/codex-hook.js +144 -0
- package/dist/adapters/shells/codex-shell.d.ts +7 -4
- package/dist/adapters/shells/codex-shell.js +24 -7
- package/dist/adapters/shells/copilot-shell.d.ts +2 -2
- package/dist/adapters/shells/copilot-shell.js +3 -3
- package/dist/adapters/shells/gemini-hook.d.ts +15 -0
- package/dist/adapters/shells/gemini-hook.js +111 -0
- package/dist/adapters/shells/gemini-shell.d.ts +6 -4
- package/dist/adapters/shells/gemini-shell.js +105 -14
- package/dist/adapters/shells/index.d.ts +0 -1
- package/dist/adapters/shells/index.js +0 -1
- package/dist/adapters/shells/spawn-shell.d.ts +1 -1
- package/dist/adapters/shells/spawn-shell.js +10 -3
- package/dist/adapters/shells/trust-registrar.d.ts +19 -0
- package/dist/adapters/shells/trust-registrar.js +141 -0
- package/dist/core/ports/shell-launcher.d.ts +17 -2
- package/dist/core/usecases/archive-cursor-update.d.ts +21 -0
- package/dist/core/usecases/archive-cursor-update.js +44 -0
- package/dist/core/usecases/archive-pending.d.ts +25 -0
- package/dist/core/usecases/archive-pending.js +61 -0
- package/dist/core/usecases/archive-search.d.ts +20 -0
- package/dist/core/usecases/archive-search.js +46 -0
- package/dist/core/usecases/inbox-search.d.ts +20 -0
- package/dist/core/usecases/inbox-search.js +46 -0
- package/dist/core/usecases/inbox-write.d.ts +27 -0
- package/dist/core/usecases/inbox-write.js +72 -0
- package/dist/core/usecases/index.d.ts +2 -1
- package/dist/core/usecases/index.js +2 -1
- package/dist/core/usecases/summon.d.ts +6 -1
- package/dist/core/usecases/summon.js +8 -2
- package/dist/interfaces/cli/commands/config.d.ts +2 -0
- package/dist/interfaces/cli/commands/config.js +83 -0
- package/dist/interfaces/cli/commands/extract.js +3 -2
- package/dist/interfaces/cli/commands/init.js +47 -0
- package/dist/interfaces/cli/commands/inject.js +3 -2
- package/dist/interfaces/cli/commands/shell.js +13 -11
- package/dist/interfaces/cli/index.js +2 -0
- package/dist/interfaces/mcp/index.js +68 -305
- package/dist/shared/config.d.ts +31 -0
- package/dist/shared/config.js +86 -16
- package/dist/shared/engram-composer.d.ts +16 -3
- package/dist/shared/engram-composer.js +50 -5
- package/dist/shared/memory-inbox.d.ts +78 -0
- package/dist/shared/memory-inbox.js +168 -0
- package/dist/shared/session-recorder.d.ts +47 -0
- package/dist/shared/session-recorder.js +112 -0
- package/package.json +5 -5
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { spawnShell } from "./spawn-shell.js";
|
|
4
|
+
import { setupClaudeHook, isClaudeHookSetup, writeClaudeHookScript } from "./claude-hook.js";
|
|
4
5
|
const execAsync = promisify(exec);
|
|
5
6
|
/**
|
|
6
7
|
* Claude Code CLI アダプター
|
|
7
8
|
* --system-prompt フラグでEngramを直接注入する。
|
|
9
|
+
*
|
|
10
|
+
* 初回起動時に Stop フックを ~/.claude/settings.json に登録し、
|
|
11
|
+
* 各ターン終了後に会話ログを Engram archive に自動記録する。
|
|
8
12
|
*/
|
|
9
13
|
export class ClaudeShell {
|
|
10
14
|
command;
|
|
@@ -22,12 +26,24 @@ export class ClaudeShell {
|
|
|
22
26
|
return false;
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
|
-
async launch(prompt,
|
|
29
|
+
async launch(prompt, options) {
|
|
30
|
+
// フックスクリプトを毎回最新に更新
|
|
31
|
+
writeClaudeHookScript();
|
|
32
|
+
// settings.json への登録は初回のみ
|
|
33
|
+
if (!isClaudeHookSetup()) {
|
|
34
|
+
console.log("Setting up Claude Code Stop hook (first run only)...");
|
|
35
|
+
setupClaudeHook();
|
|
36
|
+
console.log("Hook registered to ~/.claude/settings.json");
|
|
37
|
+
console.log();
|
|
38
|
+
}
|
|
26
39
|
const args = [
|
|
27
40
|
"--system-prompt",
|
|
28
41
|
prompt,
|
|
29
|
-
...extraArgs,
|
|
42
|
+
...(options?.extraArgs ?? []),
|
|
30
43
|
];
|
|
31
|
-
|
|
44
|
+
const env = {};
|
|
45
|
+
if (options?.engramId)
|
|
46
|
+
env.RELIC_ENGRAM_ID = options.engramId;
|
|
47
|
+
await spawnShell(this.command, args, options?.cwd, Object.keys(env).length > 0 ? env : undefined);
|
|
32
48
|
}
|
|
33
49
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const CODEX_HOOK_SCRIPT_PATH: string;
|
|
2
|
+
/**
|
|
3
|
+
* フックスクリプトを最新の内容で書き出す。
|
|
4
|
+
* 毎回呼ばれ、ソース変更がデプロイされることを保証する。
|
|
5
|
+
*/
|
|
6
|
+
export declare function writeCodexHookScript(): void;
|
|
7
|
+
/**
|
|
8
|
+
* Codex CLI の Stop フックを settings.json に登録する。
|
|
9
|
+
* 既にセットアップ済みの場合はスキップ。
|
|
10
|
+
*/
|
|
11
|
+
export declare function setupCodexHook(): void;
|
|
12
|
+
/**
|
|
13
|
+
* Stop フックがセットアップ済みか確認する。
|
|
14
|
+
*/
|
|
15
|
+
export declare function isCodexHookSetup(): boolean;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
const RELIC_DIR = join(homedir(), ".relic");
|
|
5
|
+
const HOOKS_DIR = join(RELIC_DIR, "hooks");
|
|
6
|
+
export const CODEX_HOOK_SCRIPT_PATH = join(HOOKS_DIR, "codex-stop.js");
|
|
7
|
+
const CODEX_HOOKS_PATH = join(homedir(), ".codex", "hooks.json");
|
|
8
|
+
const RELIC_HOOK_COMMAND = `node ${join(HOOKS_DIR, "codex-stop.js")}`;
|
|
9
|
+
/**
|
|
10
|
+
* Stop hook スクリプトの内容。
|
|
11
|
+
* Codex CLI の各ターン終了後に発火し、会話ログを Engram archive に追記する。
|
|
12
|
+
* RELIC_ENGRAM_ID 環境変数で対象 Engram ID を受け取る。
|
|
13
|
+
* stdin には { last_assistant_message, transcript_path, session_id, ... } が渡される。
|
|
14
|
+
* Claude の Stop hook と異なり last_assistant_message が直接取得できるため wait 不要。
|
|
15
|
+
*/
|
|
16
|
+
const HOOK_SCRIPT = `#!/usr/bin/env node
|
|
17
|
+
// Relic Stop hook for Codex CLI
|
|
18
|
+
// Automatically logs each conversation turn to the Engram archive.
|
|
19
|
+
// Receives Stop hook JSON on stdin.
|
|
20
|
+
const { appendFileSync, existsSync, mkdirSync, readFileSync } = require("node:fs");
|
|
21
|
+
const { join, dirname } = require("node:path");
|
|
22
|
+
const { homedir } = require("node:os");
|
|
23
|
+
|
|
24
|
+
let raw = "";
|
|
25
|
+
process.stdin.setEncoding("utf-8");
|
|
26
|
+
process.stdin.on("data", (chunk) => { raw += chunk; });
|
|
27
|
+
process.stdin.on("end", () => {
|
|
28
|
+
try {
|
|
29
|
+
const input = JSON.parse(raw);
|
|
30
|
+
const engramId = process.env.RELIC_ENGRAM_ID;
|
|
31
|
+
if (!engramId) process.exit(0);
|
|
32
|
+
|
|
33
|
+
const archivePath = join(homedir(), ".relic", "engrams", engramId, "archive.md");
|
|
34
|
+
mkdirSync(dirname(archivePath), { recursive: true });
|
|
35
|
+
|
|
36
|
+
// Codex Stop hook は last_assistant_message を直接提供する
|
|
37
|
+
const lastResponse = (input.last_assistant_message || "").trim();
|
|
38
|
+
|
|
39
|
+
// transcript から最後のユーザー入力を取得
|
|
40
|
+
// Codex transcript format:
|
|
41
|
+
// { type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "..." }] } }
|
|
42
|
+
// <environment_context> で始まるエントリはシステム注入なのでスキップする
|
|
43
|
+
let lastPrompt = "";
|
|
44
|
+
const transcriptPath = input.transcript_path;
|
|
45
|
+
if (transcriptPath && existsSync(transcriptPath)) {
|
|
46
|
+
const lines = readFileSync(transcriptPath, "utf-8")
|
|
47
|
+
.split("\\n")
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
|
|
52
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
53
|
+
const entry = lines[i];
|
|
54
|
+
if (entry.type !== "response_item") continue;
|
|
55
|
+
const p = entry.payload;
|
|
56
|
+
if (!p || p.role !== "user" || p.type !== "message") continue;
|
|
57
|
+
const content = p.content;
|
|
58
|
+
if (Array.isArray(content)) {
|
|
59
|
+
const texts = content
|
|
60
|
+
.filter((c) => c.type === "input_text" && c.text && !c.text.trimStart().startsWith("<environment_context>"))
|
|
61
|
+
.map((c) => c.text.trim());
|
|
62
|
+
if (texts.length > 0) {
|
|
63
|
+
lastPrompt = texts.join("\\n").trim();
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!lastPrompt && !lastResponse) process.exit(0);
|
|
71
|
+
|
|
72
|
+
const date = new Date().toISOString().split("T")[0];
|
|
73
|
+
const summary = lastPrompt.slice(0, 80).replace(/\\n/g, " ");
|
|
74
|
+
const entry = \`\\n---\\n\${date} | \${summary}\\n\${lastResponse}\\n\`;
|
|
75
|
+
appendFileSync(archivePath, entry, "utf-8");
|
|
76
|
+
} catch {
|
|
77
|
+
// silently ignore
|
|
78
|
+
}
|
|
79
|
+
process.exit(0);
|
|
80
|
+
});
|
|
81
|
+
`;
|
|
82
|
+
/**
|
|
83
|
+
* フックスクリプトを最新の内容で書き出す。
|
|
84
|
+
* 毎回呼ばれ、ソース変更がデプロイされることを保証する。
|
|
85
|
+
*/
|
|
86
|
+
export function writeCodexHookScript() {
|
|
87
|
+
mkdirSync(HOOKS_DIR, { recursive: true });
|
|
88
|
+
writeFileSync(CODEX_HOOK_SCRIPT_PATH, HOOK_SCRIPT, { encoding: "utf-8", mode: 0o755 });
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Codex CLI の Stop フックを settings.json に登録する。
|
|
92
|
+
* 既にセットアップ済みの場合はスキップ。
|
|
93
|
+
*/
|
|
94
|
+
export function setupCodexHook() {
|
|
95
|
+
// ~/.codex/hooks.json に Stop フックを登録
|
|
96
|
+
const codexDir = join(homedir(), ".codex");
|
|
97
|
+
mkdirSync(codexDir, { recursive: true });
|
|
98
|
+
let hooksConfig = {};
|
|
99
|
+
if (existsSync(CODEX_HOOKS_PATH)) {
|
|
100
|
+
try {
|
|
101
|
+
hooksConfig = JSON.parse(readFileSync(CODEX_HOOKS_PATH, "utf-8"));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
hooksConfig = {};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const hooks = (hooksConfig.hooks ?? {});
|
|
108
|
+
const stopHooks = (hooks.Stop ?? []);
|
|
109
|
+
// 既に登録済みならスキップ
|
|
110
|
+
const alreadyRegistered = stopHooks.some((group) => group.hooks?.some((h) => h.command === RELIC_HOOK_COMMAND));
|
|
111
|
+
if (alreadyRegistered)
|
|
112
|
+
return;
|
|
113
|
+
hooks.Stop = [
|
|
114
|
+
...stopHooks,
|
|
115
|
+
{
|
|
116
|
+
hooks: [
|
|
117
|
+
{
|
|
118
|
+
type: "command",
|
|
119
|
+
command: RELIC_HOOK_COMMAND,
|
|
120
|
+
timeout: 5,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
hooksConfig.hooks = hooks;
|
|
126
|
+
writeFileSync(CODEX_HOOKS_PATH, JSON.stringify(hooksConfig, null, 2), "utf-8");
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Stop フックがセットアップ済みか確認する。
|
|
130
|
+
*/
|
|
131
|
+
export function isCodexHookSetup() {
|
|
132
|
+
if (!existsSync(CODEX_HOOK_SCRIPT_PATH))
|
|
133
|
+
return false;
|
|
134
|
+
if (!existsSync(CODEX_HOOKS_PATH))
|
|
135
|
+
return false;
|
|
136
|
+
try {
|
|
137
|
+
const hooksConfig = JSON.parse(readFileSync(CODEX_HOOKS_PATH, "utf-8"));
|
|
138
|
+
const stopHooks = hooksConfig.hooks?.Stop ?? [];
|
|
139
|
+
return stopHooks.some((group) => group.hooks?.some((h) => h.command === RELIC_HOOK_COMMAND));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import type { ShellLauncher, InjectionMode } from "../../core/ports/shell-launcher.js";
|
|
1
|
+
import type { ShellLauncher, InjectionMode, ShellLaunchOptions } from "../../core/ports/shell-launcher.js";
|
|
2
2
|
/**
|
|
3
3
|
* Codex CLI アダプター
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* `-c developer_instructions=<prompt>` でEngramをdeveloperロールとして注入する。
|
|
5
|
+
* user-messageよりシステムプロンプトに近い強度で注入できる。
|
|
6
|
+
*
|
|
7
|
+
* 初回起動時に Stop フックを ~/.codex/hooks.json に登録し、
|
|
8
|
+
* 各ターン終了後に会話ログを Engram archive に自動記録する。
|
|
6
9
|
*/
|
|
7
10
|
export declare class CodexShell implements ShellLauncher {
|
|
8
11
|
private readonly command;
|
|
@@ -10,5 +13,5 @@ export declare class CodexShell implements ShellLauncher {
|
|
|
10
13
|
readonly injectionMode: InjectionMode;
|
|
11
14
|
constructor(command?: string);
|
|
12
15
|
isAvailable(): Promise<boolean>;
|
|
13
|
-
launch(prompt: string,
|
|
16
|
+
launch(prompt: string, options?: ShellLaunchOptions): Promise<void>;
|
|
14
17
|
}
|
|
@@ -2,16 +2,20 @@ import { exec } from "node:child_process";
|
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { spawnShell } from "./spawn-shell.js";
|
|
4
4
|
import { wrapWithOverride } from "./override-preamble.js";
|
|
5
|
+
import { setupCodexHook, isCodexHookSetup, writeCodexHookScript } from "./codex-hook.js";
|
|
5
6
|
const execAsync = promisify(exec);
|
|
6
7
|
/**
|
|
7
8
|
* Codex CLI アダプター
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* `-c developer_instructions=<prompt>` でEngramをdeveloperロールとして注入する。
|
|
10
|
+
* user-messageよりシステムプロンプトに近い強度で注入できる。
|
|
11
|
+
*
|
|
12
|
+
* 初回起動時に Stop フックを ~/.codex/hooks.json に登録し、
|
|
13
|
+
* 各ターン終了後に会話ログを Engram archive に自動記録する。
|
|
10
14
|
*/
|
|
11
15
|
export class CodexShell {
|
|
12
16
|
command;
|
|
13
17
|
name = "Codex CLI";
|
|
14
|
-
injectionMode = "
|
|
18
|
+
injectionMode = "developer-message";
|
|
15
19
|
constructor(command = "codex") {
|
|
16
20
|
this.command = command;
|
|
17
21
|
}
|
|
@@ -24,11 +28,24 @@ export class CodexShell {
|
|
|
24
28
|
return false;
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
|
-
async launch(prompt,
|
|
31
|
+
async launch(prompt, options) {
|
|
32
|
+
// フックスクリプトを毎回最新に更新
|
|
33
|
+
writeCodexHookScript();
|
|
34
|
+
// hooks.json への登録は初回のみ
|
|
35
|
+
if (!isCodexHookSetup()) {
|
|
36
|
+
console.log("Setting up Codex CLI Stop hook (first run only)...");
|
|
37
|
+
setupCodexHook();
|
|
38
|
+
console.log("Hook registered to ~/.codex/hooks.json");
|
|
39
|
+
console.log();
|
|
40
|
+
}
|
|
28
41
|
const args = [
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
"-c", `developer_instructions=${JSON.stringify(wrapWithOverride(prompt))}`,
|
|
43
|
+
"-c", "features.codex_hooks=true",
|
|
44
|
+
...(options?.extraArgs ?? []),
|
|
31
45
|
];
|
|
32
|
-
|
|
46
|
+
const env = {};
|
|
47
|
+
if (options?.engramId)
|
|
48
|
+
env.RELIC_ENGRAM_ID = options.engramId;
|
|
49
|
+
await spawnShell(this.command, args, options?.cwd, Object.keys(env).length > 0 ? env : undefined);
|
|
33
50
|
}
|
|
34
51
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ShellLauncher, InjectionMode } from "../../core/ports/shell-launcher.js";
|
|
1
|
+
import type { ShellLauncher, InjectionMode, ShellLaunchOptions } from "../../core/ports/shell-launcher.js";
|
|
2
2
|
/**
|
|
3
3
|
* GitHub Copilot CLI アダプター (copilot コマンド)
|
|
4
4
|
* --interactive フラグでEngramを初回プロンプトとして注入し、
|
|
@@ -10,5 +10,5 @@ export declare class CopilotShell implements ShellLauncher {
|
|
|
10
10
|
readonly injectionMode: InjectionMode;
|
|
11
11
|
constructor(command?: string);
|
|
12
12
|
isAvailable(): Promise<boolean>;
|
|
13
|
-
launch(prompt: string,
|
|
13
|
+
launch(prompt: string, options?: ShellLaunchOptions): Promise<void>;
|
|
14
14
|
}
|
|
@@ -25,16 +25,16 @@ export class CopilotShell {
|
|
|
25
25
|
return false;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
-
async launch(prompt,
|
|
28
|
+
async launch(prompt, options) {
|
|
29
29
|
const tmp = writeTempPrompt(wrapWithOverride(prompt));
|
|
30
30
|
try {
|
|
31
31
|
const fileContent = readFileSync(tmp.path, "utf-8");
|
|
32
32
|
const args = [
|
|
33
33
|
"--interactive",
|
|
34
34
|
fileContent,
|
|
35
|
-
...extraArgs,
|
|
35
|
+
...(options?.extraArgs ?? []),
|
|
36
36
|
];
|
|
37
|
-
await spawnShell(this.command, args, cwd);
|
|
37
|
+
await spawnShell(this.command, args, options?.cwd);
|
|
38
38
|
}
|
|
39
39
|
finally {
|
|
40
40
|
tmp.cleanup();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const GEMINI_HOOK_SCRIPT_PATH: string;
|
|
2
|
+
/**
|
|
3
|
+
* フックスクリプトを最新の内容で書き出す。
|
|
4
|
+
* 毎回呼ばれ、ソース変更がデプロイされることを保証する。
|
|
5
|
+
*/
|
|
6
|
+
export declare function writeGeminiHookScript(): void;
|
|
7
|
+
/**
|
|
8
|
+
* Gemini CLI の AfterAgent フックを settings.json に登録する。
|
|
9
|
+
* 既にセットアップ済みの場合はスキップ。
|
|
10
|
+
*/
|
|
11
|
+
export declare function setupGeminiHook(): void;
|
|
12
|
+
/**
|
|
13
|
+
* AfterAgent フックがセットアップ済みか確認する。
|
|
14
|
+
*/
|
|
15
|
+
export declare function isGeminiHookSetup(): boolean;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
const RELIC_DIR = join(homedir(), ".relic");
|
|
5
|
+
const HOOKS_DIR = join(RELIC_DIR, "hooks");
|
|
6
|
+
export const GEMINI_HOOK_SCRIPT_PATH = join(HOOKS_DIR, "gemini-after-agent.js");
|
|
7
|
+
const GEMINI_SETTINGS_PATH = join(homedir(), ".gemini", "settings.json");
|
|
8
|
+
const RELIC_HOOK_NAME = "relic-archive-log";
|
|
9
|
+
/**
|
|
10
|
+
* AfterAgent hook スクリプトの内容。
|
|
11
|
+
* Gemini CLI の各ターン終了後に発火し、会話ログを Engram archive に追記する。
|
|
12
|
+
* RELIC_ENGRAM_ID 環境変数で対象 Engram ID を受け取る。
|
|
13
|
+
*/
|
|
14
|
+
const HOOK_SCRIPT = `#!/usr/bin/env node
|
|
15
|
+
// Relic AfterAgent hook for Gemini CLI
|
|
16
|
+
// Automatically logs each conversation turn to the Engram archive.
|
|
17
|
+
// Receives AfterAgentInput JSON on stdin.
|
|
18
|
+
const { appendFileSync, existsSync, mkdirSync } = require("node:fs");
|
|
19
|
+
const { join, dirname } = require("node:path");
|
|
20
|
+
const { homedir } = require("node:os");
|
|
21
|
+
|
|
22
|
+
let raw = "";
|
|
23
|
+
process.stdin.setEncoding("utf-8");
|
|
24
|
+
process.stdin.on("data", (chunk) => { raw += chunk; });
|
|
25
|
+
process.stdin.on("end", () => {
|
|
26
|
+
try {
|
|
27
|
+
const input = JSON.parse(raw);
|
|
28
|
+
const engramId = process.env.RELIC_ENGRAM_ID;
|
|
29
|
+
if (!engramId) process.exit(0);
|
|
30
|
+
|
|
31
|
+
const prompt = (input.prompt || "").trim();
|
|
32
|
+
const response = (input.prompt_response || "").trim();
|
|
33
|
+
if (!prompt && !response) process.exit(0);
|
|
34
|
+
|
|
35
|
+
const archivePath = join(homedir(), ".relic", "engrams", engramId, "archive.md");
|
|
36
|
+
mkdirSync(dirname(archivePath), { recursive: true });
|
|
37
|
+
|
|
38
|
+
const date = new Date().toISOString().split("T")[0];
|
|
39
|
+
const summary = prompt.slice(0, 80).replace(/\\n/g, " ");
|
|
40
|
+
const entry = \`\\n---\\n\${date} | \${summary}\\n\${response}\\n\`;
|
|
41
|
+
appendFileSync(archivePath, entry, "utf-8");
|
|
42
|
+
} catch {
|
|
43
|
+
// silently ignore
|
|
44
|
+
}
|
|
45
|
+
process.exit(0);
|
|
46
|
+
});
|
|
47
|
+
`;
|
|
48
|
+
/**
|
|
49
|
+
* フックスクリプトを最新の内容で書き出す。
|
|
50
|
+
* 毎回呼ばれ、ソース変更がデプロイされることを保証する。
|
|
51
|
+
*/
|
|
52
|
+
export function writeGeminiHookScript() {
|
|
53
|
+
mkdirSync(HOOKS_DIR, { recursive: true });
|
|
54
|
+
writeFileSync(GEMINI_HOOK_SCRIPT_PATH, HOOK_SCRIPT, { encoding: "utf-8", mode: 0o755 });
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Gemini CLI の AfterAgent フックを settings.json に登録する。
|
|
58
|
+
* 既にセットアップ済みの場合はスキップ。
|
|
59
|
+
*/
|
|
60
|
+
export function setupGeminiHook() {
|
|
61
|
+
// ~/.gemini/settings.json にフックを登録
|
|
62
|
+
const geminiDir = join(homedir(), ".gemini");
|
|
63
|
+
mkdirSync(geminiDir, { recursive: true });
|
|
64
|
+
let settings = {};
|
|
65
|
+
if (existsSync(GEMINI_SETTINGS_PATH)) {
|
|
66
|
+
try {
|
|
67
|
+
settings = JSON.parse(readFileSync(GEMINI_SETTINGS_PATH, "utf-8"));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
settings = {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const hooks = (settings.hooks ?? {});
|
|
74
|
+
const afterAgentHooks = (hooks.AfterAgent ?? []);
|
|
75
|
+
// 既に登録済みならスキップ
|
|
76
|
+
const alreadyRegistered = afterAgentHooks.some((group) => group.hooks?.some((h) => h.name === RELIC_HOOK_NAME));
|
|
77
|
+
if (alreadyRegistered)
|
|
78
|
+
return;
|
|
79
|
+
hooks.AfterAgent = [
|
|
80
|
+
...afterAgentHooks,
|
|
81
|
+
{
|
|
82
|
+
hooks: [
|
|
83
|
+
{
|
|
84
|
+
type: "command",
|
|
85
|
+
command: `node ${GEMINI_HOOK_SCRIPT_PATH}`,
|
|
86
|
+
name: RELIC_HOOK_NAME,
|
|
87
|
+
timeout: 5000,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
settings.hooks = hooks;
|
|
93
|
+
writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8");
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* AfterAgent フックがセットアップ済みか確認する。
|
|
97
|
+
*/
|
|
98
|
+
export function isGeminiHookSetup() {
|
|
99
|
+
if (!existsSync(GEMINI_HOOK_SCRIPT_PATH))
|
|
100
|
+
return false;
|
|
101
|
+
if (!existsSync(GEMINI_SETTINGS_PATH))
|
|
102
|
+
return false;
|
|
103
|
+
try {
|
|
104
|
+
const settings = JSON.parse(readFileSync(GEMINI_SETTINGS_PATH, "utf-8"));
|
|
105
|
+
const afterAgentHooks = settings.hooks?.AfterAgent ?? [];
|
|
106
|
+
return afterAgentHooks.some((group) => group.hooks?.some((h) => h.name === RELIC_HOOK_NAME));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type { ShellLauncher, InjectionMode } from "../../core/ports/shell-launcher.js";
|
|
1
|
+
import type { ShellLauncher, InjectionMode, ShellLaunchOptions } from "../../core/ports/shell-launcher.js";
|
|
2
2
|
/**
|
|
3
3
|
* Gemini CLI アダプター
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* GEMINI_SYSTEM_MD 環境変数でシステムプロンプトを完全上書きし、
|
|
5
|
+
* Engramを組み込みプロンプトと合成して注入する。
|
|
6
|
+
*
|
|
7
|
+
* 初回起動時のみ GEMINI_WRITE_SYSTEM_MD でデフォルトプロンプトをキャプチャ・キャッシュする。
|
|
6
8
|
*/
|
|
7
9
|
export declare class GeminiShell implements ShellLauncher {
|
|
8
10
|
private readonly command;
|
|
@@ -10,5 +12,5 @@ export declare class GeminiShell implements ShellLauncher {
|
|
|
10
12
|
readonly injectionMode: InjectionMode;
|
|
11
13
|
constructor(command?: string);
|
|
12
14
|
isAvailable(): Promise<boolean>;
|
|
13
|
-
launch(prompt: string,
|
|
15
|
+
launch(prompt: string, options?: ShellLaunchOptions): Promise<void>;
|
|
14
16
|
}
|
|
@@ -1,18 +1,89 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { exec } from "node:child_process";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
7
|
import { spawnShell, writeTempPrompt } from "./spawn-shell.js";
|
|
5
|
-
import {
|
|
8
|
+
import { setupGeminiHook, isGeminiHookSetup, writeGeminiHookScript } from "./gemini-hook.js";
|
|
6
9
|
const execAsync = promisify(exec);
|
|
10
|
+
const RELIC_DIR = join(homedir(), ".relic");
|
|
11
|
+
const GEMINI_DEFAULT_CACHE = join(RELIC_DIR, "gemini-system-default.md");
|
|
12
|
+
const RELIC_ENGRAM_START = "<!-- RELIC ENGRAM START -->";
|
|
13
|
+
const RELIC_ENGRAM_END = "<!-- RELIC ENGRAM END -->";
|
|
14
|
+
/**
|
|
15
|
+
* GEMINI_WRITE_SYSTEM_MD=true で gemini を一時起動し、
|
|
16
|
+
* 組み込みシステムプロンプトをキャプチャして返す。
|
|
17
|
+
* キャプチャ結果は ~/.relic/gemini-system-default.md にキャッシュされる。
|
|
18
|
+
*/
|
|
19
|
+
async function captureDefaultSystemPrompt(command) {
|
|
20
|
+
const tempDir = mkdtempSync(join(tmpdir(), "relic-gemini-capture-"));
|
|
21
|
+
const geminiDir = join(tempDir, ".gemini");
|
|
22
|
+
mkdirSync(geminiDir, { recursive: true });
|
|
23
|
+
try {
|
|
24
|
+
await new Promise((resolve) => {
|
|
25
|
+
const child = spawn(command, [], {
|
|
26
|
+
cwd: tempDir,
|
|
27
|
+
env: { ...process.env, GEMINI_WRITE_SYSTEM_MD: "true" },
|
|
28
|
+
// stdin を /dev/null 相当にして TTY なしで起動
|
|
29
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
30
|
+
});
|
|
31
|
+
// ファイル書き出し猶予として5秒後に強制終了
|
|
32
|
+
const timeout = setTimeout(() => {
|
|
33
|
+
child.kill("SIGTERM");
|
|
34
|
+
}, 5000);
|
|
35
|
+
child.on("close", () => {
|
|
36
|
+
clearTimeout(timeout);
|
|
37
|
+
resolve();
|
|
38
|
+
});
|
|
39
|
+
child.on("error", () => {
|
|
40
|
+
clearTimeout(timeout);
|
|
41
|
+
resolve();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
const systemMdPath = join(geminiDir, "system.md");
|
|
45
|
+
if (!existsSync(systemMdPath)) {
|
|
46
|
+
throw new Error("Failed to capture Gemini default system prompt.\n" +
|
|
47
|
+
"Run manually: GEMINI_WRITE_SYSTEM_MD=true gemini\n" +
|
|
48
|
+
`Then copy .gemini/system.md to ${GEMINI_DEFAULT_CACHE}`);
|
|
49
|
+
}
|
|
50
|
+
const content = readFileSync(systemMdPath, "utf-8");
|
|
51
|
+
mkdirSync(RELIC_DIR, { recursive: true });
|
|
52
|
+
writeFileSync(GEMINI_DEFAULT_CACHE, content, "utf-8");
|
|
53
|
+
return content;
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* デフォルトプロンプトにEngramセクションを追記 or 置換する(冪等)。
|
|
61
|
+
* <!-- RELIC ENGRAM START/END --> デリミタで管理する。
|
|
62
|
+
*/
|
|
63
|
+
function composeSystemPrompt(defaultPrompt, engramPrompt) {
|
|
64
|
+
const engramSection = [
|
|
65
|
+
RELIC_ENGRAM_START,
|
|
66
|
+
engramPrompt,
|
|
67
|
+
RELIC_ENGRAM_END,
|
|
68
|
+
].join("\n");
|
|
69
|
+
if (defaultPrompt.includes(RELIC_ENGRAM_START)) {
|
|
70
|
+
// 既存のRELICセクションを置換
|
|
71
|
+
return defaultPrompt.replace(new RegExp(`${RELIC_ENGRAM_START}[\\s\\S]*?${RELIC_ENGRAM_END}`), engramSection);
|
|
72
|
+
}
|
|
73
|
+
// 末尾に追記
|
|
74
|
+
return `${defaultPrompt}\n\n${engramSection}`;
|
|
75
|
+
}
|
|
7
76
|
/**
|
|
8
77
|
* Gemini CLI アダプター
|
|
9
|
-
*
|
|
10
|
-
*
|
|
78
|
+
* GEMINI_SYSTEM_MD 環境変数でシステムプロンプトを完全上書きし、
|
|
79
|
+
* Engramを組み込みプロンプトと合成して注入する。
|
|
80
|
+
*
|
|
81
|
+
* 初回起動時のみ GEMINI_WRITE_SYSTEM_MD でデフォルトプロンプトをキャプチャ・キャッシュする。
|
|
11
82
|
*/
|
|
12
83
|
export class GeminiShell {
|
|
13
84
|
command;
|
|
14
85
|
name = "Gemini CLI";
|
|
15
|
-
injectionMode = "
|
|
86
|
+
injectionMode = "system-prompt";
|
|
16
87
|
constructor(command = "gemini") {
|
|
17
88
|
this.command = command;
|
|
18
89
|
}
|
|
@@ -25,16 +96,36 @@ export class GeminiShell {
|
|
|
25
96
|
return false;
|
|
26
97
|
}
|
|
27
98
|
}
|
|
28
|
-
async launch(prompt,
|
|
29
|
-
|
|
99
|
+
async launch(prompt, options) {
|
|
100
|
+
// 1. フックスクリプトを毎回最新に更新
|
|
101
|
+
writeGeminiHookScript();
|
|
102
|
+
// settings.json への登録は初回のみ
|
|
103
|
+
if (!isGeminiHookSetup()) {
|
|
104
|
+
console.log("Setting up Gemini AfterAgent hook (first run only)...");
|
|
105
|
+
setupGeminiHook();
|
|
106
|
+
console.log("Hook registered to ~/.gemini/settings.json");
|
|
107
|
+
console.log();
|
|
108
|
+
}
|
|
109
|
+
// 2. デフォルトシステムプロンプトをキャッシュから読む、なければキャプチャ
|
|
110
|
+
let defaultPrompt;
|
|
111
|
+
if (existsSync(GEMINI_DEFAULT_CACHE)) {
|
|
112
|
+
defaultPrompt = readFileSync(GEMINI_DEFAULT_CACHE, "utf-8");
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
console.log("Capturing Gemini default system prompt (first run only)...");
|
|
116
|
+
defaultPrompt = await captureDefaultSystemPrompt(this.command);
|
|
117
|
+
console.log(`Cached to: ${GEMINI_DEFAULT_CACHE}`);
|
|
118
|
+
console.log();
|
|
119
|
+
}
|
|
120
|
+
// 3. デフォルト + Engram を合成してtempファイルに書き出す
|
|
121
|
+
const combined = composeSystemPrompt(defaultPrompt, prompt);
|
|
122
|
+
const tmp = writeTempPrompt(combined);
|
|
30
123
|
try {
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
];
|
|
37
|
-
await spawnShell(this.command, args, cwd);
|
|
124
|
+
// 4. GEMINI_SYSTEM_MD + RELIC_ENGRAM_ID でシステムプロンプトを差し替えて起動
|
|
125
|
+
const env = { GEMINI_SYSTEM_MD: tmp.path };
|
|
126
|
+
if (options?.engramId)
|
|
127
|
+
env.RELIC_ENGRAM_ID = options.engramId;
|
|
128
|
+
await spawnShell(this.command, [...(options?.extraArgs ?? [])], options?.cwd, env);
|
|
38
129
|
}
|
|
39
130
|
finally {
|
|
40
131
|
tmp.cleanup();
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* shell: false で起動し、引数をそのまま渡す。
|
|
6
6
|
* これにより長いプロンプト文字列がシェルに壊されない。
|
|
7
7
|
*/
|
|
8
|
-
export declare function spawnShell(command: string, args: string[], cwd?: string): Promise<void>;
|
|
8
|
+
export declare function spawnShell(command: string, args: string[], cwd?: string, env?: Record<string, string>): Promise<void>;
|
|
9
9
|
/**
|
|
10
10
|
* プロンプトをtempファイルに書き出し、パスを返す。
|
|
11
11
|
* Shell終了後にcleanup()を呼んで削除する。
|
|
@@ -9,17 +9,24 @@ import { tmpdir } from "node:os";
|
|
|
9
9
|
* shell: false で起動し、引数をそのまま渡す。
|
|
10
10
|
* これにより長いプロンプト文字列がシェルに壊されない。
|
|
11
11
|
*/
|
|
12
|
-
export function spawnShell(command, args, cwd) {
|
|
12
|
+
export function spawnShell(command, args, cwd, env) {
|
|
13
13
|
return new Promise((resolve, reject) => {
|
|
14
14
|
const child = spawn(command, args, {
|
|
15
15
|
stdio: "inherit",
|
|
16
16
|
cwd,
|
|
17
|
+
env: env ? { ...process.env, ...env } : undefined,
|
|
17
18
|
});
|
|
18
19
|
child.on("error", (err) => {
|
|
19
20
|
reject(new Error(`Failed to launch ${command}: ${err.message}`));
|
|
20
21
|
});
|
|
21
|
-
child.on("close", (code) => {
|
|
22
|
-
|
|
22
|
+
child.on("close", (code, signal) => {
|
|
23
|
+
// code 0 = 正常終了
|
|
24
|
+
// code null = シグナルで終了
|
|
25
|
+
// signal SIGINT/SIGTERM = ユーザーによる中断(正常扱い)
|
|
26
|
+
if (code === 0 ||
|
|
27
|
+
code === null ||
|
|
28
|
+
signal === "SIGINT" ||
|
|
29
|
+
signal === "SIGTERM") {
|
|
23
30
|
resolve();
|
|
24
31
|
}
|
|
25
32
|
else {
|