@jhizzard/termdeck 0.2.0 → 0.2.2
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 +191 -169
- package/config/config.example.yaml +10 -10
- package/package.json +7 -6
- package/packages/cli/src/index.js +44 -1
- package/packages/cli/src/init-engram.js +344 -0
- package/packages/cli/src/init-rumen.js +425 -0
- package/packages/client/public/index.html +10 -10
- package/packages/server/src/config.js +8 -8
- package/packages/server/src/index.js +10 -10
- package/packages/server/src/{engram-bridge → mnestra-bridge}/index.js +18 -18
- package/packages/server/src/rag.js +6 -6
- package/packages/server/src/setup/dotenv-io.js +116 -0
- package/packages/server/src/setup/engram-migrations/001_engram_tables.sql +116 -0
- package/packages/server/src/setup/engram-migrations/002_engram_search_function.sql +141 -0
- package/packages/server/src/setup/engram-migrations/003_engram_event_webhook.sql +28 -0
- package/packages/server/src/setup/engram-migrations/004_engram_match_count_cap_and_explain.sql +176 -0
- package/packages/server/src/setup/engram-migrations/005_v0_1_to_v0_2_upgrade.sql +23 -0
- package/packages/server/src/setup/engram-migrations/006_memory_status_rpc.sql +58 -0
- package/packages/server/src/setup/index.js +14 -0
- package/packages/server/src/setup/migrations.js +80 -0
- package/packages/server/src/setup/pg-runner.js +113 -0
- package/packages/server/src/setup/prompts.js +177 -0
- package/packages/server/src/setup/rumen/functions/rumen-tick/index.ts +85 -0
- package/packages/server/src/setup/rumen/functions/rumen-tick/tsconfig.json +14 -0
- package/packages/server/src/setup/rumen/migrations/001_rumen_tables.sql +91 -0
- package/packages/server/src/setup/rumen/migrations/002_pg_cron_schedule.sql +40 -0
- package/packages/server/src/setup/supabase-url.js +114 -0
- package/packages/server/src/setup/yaml-io.js +99 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Rumen v0.1 Supabase Edge Function entry point.
|
|
2
|
+
//
|
|
3
|
+
// IMPORTANT: This file targets the Deno runtime, NOT Node. It will not
|
|
4
|
+
// compile under the root tsconfig.json — it is intentionally excluded.
|
|
5
|
+
// A sibling tsconfig.json in this directory keeps the types sane for
|
|
6
|
+
// editors, but the canonical build target is Deno's own type checker
|
|
7
|
+
// (`deno check`) and Supabase's `supabase functions deploy`.
|
|
8
|
+
//
|
|
9
|
+
// Dependencies are pulled via `npm:` specifiers, which Supabase Edge
|
|
10
|
+
// Functions support natively.
|
|
11
|
+
//
|
|
12
|
+
// Deployment:
|
|
13
|
+
// supabase functions deploy rumen-tick
|
|
14
|
+
// supabase secrets set DATABASE_URL="$DATABASE_URL"
|
|
15
|
+
//
|
|
16
|
+
// Triggered on a schedule by pg_cron — see migrations/002_pg_cron_schedule.sql.
|
|
17
|
+
//
|
|
18
|
+
// This function runs one Rumen job per invocation and returns a JSON summary.
|
|
19
|
+
|
|
20
|
+
// @ts-ignore Deno std import resolved at runtime.
|
|
21
|
+
import { serve } from 'https://deno.land/std@0.224.0/http/server.ts';
|
|
22
|
+
// @ts-ignore npm specifier resolved at runtime.
|
|
23
|
+
import { runRumenJob, createPoolFromUrl } from 'npm:@jhizzard/rumen@0.1.0';
|
|
24
|
+
|
|
25
|
+
// @ts-ignore Deno global available at runtime.
|
|
26
|
+
declare const Deno: { env: { get: (k: string) => string | undefined } };
|
|
27
|
+
|
|
28
|
+
serve(async (_req: Request) => {
|
|
29
|
+
const url = Deno.env.get('DATABASE_URL');
|
|
30
|
+
if (!url) {
|
|
31
|
+
console.error('[rumen] DATABASE_URL not set in Edge Function secrets');
|
|
32
|
+
return new Response(
|
|
33
|
+
JSON.stringify({
|
|
34
|
+
ok: false,
|
|
35
|
+
error: 'DATABASE_URL not set',
|
|
36
|
+
}),
|
|
37
|
+
{
|
|
38
|
+
status: 500,
|
|
39
|
+
headers: { 'Content-Type': 'application/json' },
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const pool = createPoolFromUrl(url);
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
console.log('[rumen] edge function tick starting');
|
|
48
|
+
const summary = await runRumenJob(pool, {
|
|
49
|
+
triggeredBy: 'schedule',
|
|
50
|
+
});
|
|
51
|
+
console.log(
|
|
52
|
+
'[rumen] edge function tick complete job_id=' +
|
|
53
|
+
summary.job_id +
|
|
54
|
+
' status=' +
|
|
55
|
+
summary.status,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return new Response(
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
ok: summary.status === 'done',
|
|
61
|
+
summary,
|
|
62
|
+
}),
|
|
63
|
+
{
|
|
64
|
+
status: summary.status === 'done' ? 200 : 500,
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
70
|
+
console.error('[rumen] edge function tick threw:', err);
|
|
71
|
+
return new Response(
|
|
72
|
+
JSON.stringify({ ok: false, error: message }),
|
|
73
|
+
{
|
|
74
|
+
status: 500,
|
|
75
|
+
headers: { 'Content-Type': 'application/json' },
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
} finally {
|
|
79
|
+
try {
|
|
80
|
+
await pool.end();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error('[rumen] pool.end() failed:', err);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"allowImportingTsExtensions": false,
|
|
11
|
+
"types": []
|
|
12
|
+
},
|
|
13
|
+
"include": ["index.ts"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
-- Rumen v0.1 schema
|
|
2
|
+
-- Non-destructive: creates three new tables under the rumen_ namespace.
|
|
3
|
+
-- Does NOT modify or reference Engram's existing memory_items / memory_sessions tables.
|
|
4
|
+
--
|
|
5
|
+
-- Apply with:
|
|
6
|
+
-- psql "$DIRECT_URL" -f migrations/001_rumen_tables.sql
|
|
7
|
+
|
|
8
|
+
BEGIN;
|
|
9
|
+
|
|
10
|
+
-- ---------------------------------------------------------------------------
|
|
11
|
+
-- rumen_jobs: one row per Rumen tick. Tracks what was processed.
|
|
12
|
+
-- ---------------------------------------------------------------------------
|
|
13
|
+
CREATE TABLE IF NOT EXISTS rumen_jobs (
|
|
14
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
15
|
+
triggered_by TEXT NOT NULL CHECK (triggered_by IN ('schedule', 'session_end', 'manual')),
|
|
16
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed')),
|
|
17
|
+
sessions_processed INTEGER NOT NULL DEFAULT 0,
|
|
18
|
+
insights_generated INTEGER NOT NULL DEFAULT 0,
|
|
19
|
+
questions_generated INTEGER NOT NULL DEFAULT 0,
|
|
20
|
+
error_message TEXT,
|
|
21
|
+
source_session_ids UUID[] NOT NULL DEFAULT '{}',
|
|
22
|
+
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
23
|
+
completed_at TIMESTAMPTZ
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_rumen_jobs_status
|
|
27
|
+
ON rumen_jobs (status);
|
|
28
|
+
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_rumen_jobs_started_at
|
|
30
|
+
ON rumen_jobs (started_at DESC);
|
|
31
|
+
|
|
32
|
+
-- GIN index so we can cheaply check "has this session already been processed?"
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_rumen_jobs_source_session_ids
|
|
34
|
+
ON rumen_jobs USING GIN (source_session_ids);
|
|
35
|
+
|
|
36
|
+
-- ---------------------------------------------------------------------------
|
|
37
|
+
-- rumen_insights: synthesized cross-project findings.
|
|
38
|
+
-- v0.1 writes placeholder insight_text; v0.2 will add LLM synthesis.
|
|
39
|
+
-- ---------------------------------------------------------------------------
|
|
40
|
+
CREATE TABLE IF NOT EXISTS rumen_insights (
|
|
41
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
42
|
+
job_id UUID NOT NULL REFERENCES rumen_jobs(id) ON DELETE CASCADE,
|
|
43
|
+
source_memory_ids UUID[] NOT NULL DEFAULT '{}',
|
|
44
|
+
projects TEXT[] NOT NULL DEFAULT '{}',
|
|
45
|
+
insight_text TEXT NOT NULL,
|
|
46
|
+
confidence NUMERIC(4, 3) NOT NULL DEFAULT 0.000
|
|
47
|
+
CHECK (confidence >= 0.0 AND confidence <= 1.0),
|
|
48
|
+
acted_upon BOOLEAN NOT NULL DEFAULT FALSE,
|
|
49
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_rumen_insights_job_id
|
|
53
|
+
ON rumen_insights (job_id);
|
|
54
|
+
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_rumen_insights_created_at
|
|
56
|
+
ON rumen_insights (created_at DESC);
|
|
57
|
+
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_rumen_insights_projects
|
|
59
|
+
ON rumen_insights USING GIN (projects);
|
|
60
|
+
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_rumen_insights_source_memory_ids
|
|
62
|
+
ON rumen_insights USING GIN (source_memory_ids);
|
|
63
|
+
|
|
64
|
+
-- ---------------------------------------------------------------------------
|
|
65
|
+
-- rumen_questions: follow-up questions Rumen wants to ask the developer.
|
|
66
|
+
-- Reserved for v0.3. The table is created in v0.1 so the schema is stable,
|
|
67
|
+
-- but v0.1 never writes to it.
|
|
68
|
+
-- ---------------------------------------------------------------------------
|
|
69
|
+
CREATE TABLE IF NOT EXISTS rumen_questions (
|
|
70
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
71
|
+
job_id UUID NOT NULL REFERENCES rumen_jobs(id) ON DELETE CASCADE,
|
|
72
|
+
session_id UUID,
|
|
73
|
+
question TEXT NOT NULL,
|
|
74
|
+
context TEXT,
|
|
75
|
+
asked_at TIMESTAMPTZ,
|
|
76
|
+
answered_at TIMESTAMPTZ,
|
|
77
|
+
answer TEXT,
|
|
78
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_rumen_questions_job_id
|
|
82
|
+
ON rumen_questions (job_id);
|
|
83
|
+
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_rumen_questions_session_id
|
|
85
|
+
ON rumen_questions (session_id);
|
|
86
|
+
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_rumen_questions_unanswered
|
|
88
|
+
ON rumen_questions (created_at DESC)
|
|
89
|
+
WHERE answered_at IS NULL;
|
|
90
|
+
|
|
91
|
+
COMMIT;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- Rumen v0.1 schedule
|
|
2
|
+
-- Schedules the rumen-tick Supabase Edge Function to run every 15 minutes
|
|
3
|
+
-- via pg_cron + pg_net.
|
|
4
|
+
--
|
|
5
|
+
-- Before applying:
|
|
6
|
+
-- 1. Enable the pg_cron extension in the Supabase dashboard:
|
|
7
|
+
-- Database -> Extensions -> pg_cron (toggle on)
|
|
8
|
+
-- 2. Enable the pg_net extension the same way (needed for net.http_post).
|
|
9
|
+
-- 3. Replace <project-ref> below with your actual Supabase project ref.
|
|
10
|
+
-- 4. Replace <service-role-jwt> below with a service-role key stored in
|
|
11
|
+
-- Supabase Vault, NOT pasted inline. The SELECT below shows the Vault
|
|
12
|
+
-- pattern; delete the inline-key variant before running.
|
|
13
|
+
--
|
|
14
|
+
-- Apply with:
|
|
15
|
+
-- psql "$DIRECT_URL" -f migrations/002_pg_cron_schedule.sql
|
|
16
|
+
|
|
17
|
+
-- Remove any prior schedule with the same name so re-running is idempotent.
|
|
18
|
+
SELECT cron.unschedule('rumen-tick')
|
|
19
|
+
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'rumen-tick');
|
|
20
|
+
|
|
21
|
+
-- Schedule rumen-tick every 15 minutes.
|
|
22
|
+
-- The body is empty JSON; the Edge Function reads DATABASE_URL from its
|
|
23
|
+
-- own function secrets, not from the request body.
|
|
24
|
+
SELECT cron.schedule(
|
|
25
|
+
'rumen-tick',
|
|
26
|
+
'*/15 * * * *',
|
|
27
|
+
$$
|
|
28
|
+
SELECT net.http_post(
|
|
29
|
+
url := 'https://<project-ref>.supabase.co/functions/v1/rumen-tick',
|
|
30
|
+
headers := jsonb_build_object(
|
|
31
|
+
'Content-Type', 'application/json',
|
|
32
|
+
'Authorization', 'Bearer ' || (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'rumen_service_role_key')
|
|
33
|
+
),
|
|
34
|
+
body := '{}'::jsonb
|
|
35
|
+
);
|
|
36
|
+
$$
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
-- Verify:
|
|
40
|
+
-- SELECT jobname, schedule, active FROM cron.job WHERE jobname = 'rumen-tick';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Parse + validate Supabase URLs and derive what we can from them without the
|
|
2
|
+
// database password. Useful for both init wizards:
|
|
3
|
+
//
|
|
4
|
+
// - init-engram needs the project ref to show in status output and also
|
|
5
|
+
// needs a full DATABASE_URL to apply migrations; since the DB password
|
|
6
|
+
// cannot be derived from the project URL alone, the wizard prompts for
|
|
7
|
+
// the direct connection string separately.
|
|
8
|
+
//
|
|
9
|
+
// - init-rumen needs the project ref to run `supabase link --project-ref`
|
|
10
|
+
// and to substitute into the pg_cron schedule SQL.
|
|
11
|
+
|
|
12
|
+
// A Supabase project URL looks like:
|
|
13
|
+
// https://<project-ref>.supabase.co
|
|
14
|
+
// The ref is 20 characters of lowercase alphanumerics, but we accept anything
|
|
15
|
+
// that matches `[a-z0-9-]+` to avoid being stricter than Supabase itself.
|
|
16
|
+
function parseProjectUrl(url) {
|
|
17
|
+
if (!url || typeof url !== 'string') {
|
|
18
|
+
return { ok: false, error: 'empty url' };
|
|
19
|
+
}
|
|
20
|
+
const trimmed = url.trim().replace(/\/+$/, '');
|
|
21
|
+
let u;
|
|
22
|
+
try {
|
|
23
|
+
u = new URL(trimmed);
|
|
24
|
+
} catch (_err) {
|
|
25
|
+
return { ok: false, error: 'not a valid URL' };
|
|
26
|
+
}
|
|
27
|
+
if (u.protocol !== 'https:') {
|
|
28
|
+
return { ok: false, error: 'must be https://' };
|
|
29
|
+
}
|
|
30
|
+
const m = u.hostname.match(/^([a-z0-9-]+)\.supabase\.(co|in)$/i);
|
|
31
|
+
if (!m) {
|
|
32
|
+
return { ok: false, error: 'hostname must end in .supabase.co' };
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
url: `https://${u.hostname}`,
|
|
37
|
+
projectRef: m[1].toLowerCase(),
|
|
38
|
+
hostname: u.hostname
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Validate that a string has the shape of a Supabase service role key.
|
|
43
|
+
// Supabase now ships two formats:
|
|
44
|
+
// (1) legacy JWT: `eyJ...` (header.payload.signature), usually ~200 chars
|
|
45
|
+
// (2) prefixed v2: `sb_secret_...` or `sb_publishable_...`
|
|
46
|
+
// We accept both shapes and reject anything else with an explicit hint.
|
|
47
|
+
function looksLikeServiceRole(key) {
|
|
48
|
+
if (!key || typeof key !== 'string') return 'empty';
|
|
49
|
+
const trimmed = key.trim();
|
|
50
|
+
if (trimmed.startsWith('sb_secret_')) return null;
|
|
51
|
+
if (trimmed.startsWith('sb_publishable_')) {
|
|
52
|
+
return 'that looks like a publishable (anon) key, not the service_role key';
|
|
53
|
+
}
|
|
54
|
+
if (trimmed.startsWith('eyJ')) {
|
|
55
|
+
// Rough JWT shape check — 3 dot-separated base64url chunks.
|
|
56
|
+
const parts = trimmed.split('.');
|
|
57
|
+
if (parts.length === 3) return null;
|
|
58
|
+
return 'JWT-looking string but not 3 segments';
|
|
59
|
+
}
|
|
60
|
+
return 'does not look like a Supabase service_role key (expected sb_secret_… or eyJ…)';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Shape-check an OpenAI API key. Both classic `sk-` and project `sk-proj-`
|
|
64
|
+
// formats are accepted.
|
|
65
|
+
function looksLikeOpenAiKey(key) {
|
|
66
|
+
if (!key || typeof key !== 'string') return 'empty';
|
|
67
|
+
const trimmed = key.trim();
|
|
68
|
+
if (trimmed.startsWith('sk-') && trimmed.length >= 20) return null;
|
|
69
|
+
return 'OpenAI keys start with sk-';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Shape-check an Anthropic API key.
|
|
73
|
+
function looksLikeAnthropicKey(key) {
|
|
74
|
+
if (!key || typeof key !== 'string') return 'empty';
|
|
75
|
+
const trimmed = key.trim();
|
|
76
|
+
if (trimmed.startsWith('sk-ant-') && trimmed.length >= 30) return null;
|
|
77
|
+
return 'Anthropic keys start with sk-ant-';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Shape-check a Postgres connection string. Accepts both Supabase pooler URLs
|
|
81
|
+
// (`postgres://postgres.<ref>:...@aws-0-<region>.pooler.supabase.com:<port>/postgres`)
|
|
82
|
+
// and direct connection URLs (`postgres://postgres:...@db.<ref>.supabase.co:5432/postgres`).
|
|
83
|
+
function looksLikePostgresUrl(url) {
|
|
84
|
+
if (!url || typeof url !== 'string') return 'empty';
|
|
85
|
+
let u;
|
|
86
|
+
try {
|
|
87
|
+
u = new URL(url);
|
|
88
|
+
} catch (_err) {
|
|
89
|
+
return 'not a valid URL';
|
|
90
|
+
}
|
|
91
|
+
if (u.protocol !== 'postgres:' && u.protocol !== 'postgresql:') {
|
|
92
|
+
return 'must start with postgres:// or postgresql://';
|
|
93
|
+
}
|
|
94
|
+
if (!u.username || !u.password) {
|
|
95
|
+
return 'missing username or password — paste the full Connection String from Supabase';
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Mask all but the last 4 chars of a secret for logging.
|
|
101
|
+
function maskSecret(value) {
|
|
102
|
+
if (!value || typeof value !== 'string') return '';
|
|
103
|
+
if (value.length <= 8) return '***';
|
|
104
|
+
return `${value.slice(0, 4)}…${value.slice(-4)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
parseProjectUrl,
|
|
109
|
+
looksLikeServiceRole,
|
|
110
|
+
looksLikeOpenAiKey,
|
|
111
|
+
looksLikeAnthropicKey,
|
|
112
|
+
looksLikePostgresUrl,
|
|
113
|
+
maskSecret
|
|
114
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Targeted writer for ~/.termdeck/config.yaml that the `init --engram` wizard
|
|
2
|
+
// uses to flip `rag.enabled: true` and point secret fields at `${VAR}` refs
|
|
3
|
+
// instead of inline values.
|
|
4
|
+
//
|
|
5
|
+
// Uses the `yaml` package (already a dep) for a full parse + stringify round
|
|
6
|
+
// trip. Comments WILL be lost on rewrite — yaml.stringify doesn't preserve
|
|
7
|
+
// them. We back up the original to a timestamped `.bak` file first so the
|
|
8
|
+
// user can recover any hand-written comments.
|
|
9
|
+
//
|
|
10
|
+
// If config.yaml doesn't exist yet, this helper lets the server's own
|
|
11
|
+
// loadConfig() create the default template on next startup — it does NOT
|
|
12
|
+
// write a fresh template itself.
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const CONFIG_DIR = path.join(os.homedir(), '.termdeck');
|
|
19
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.yaml');
|
|
20
|
+
|
|
21
|
+
function loadYaml() {
|
|
22
|
+
const yaml = require('yaml');
|
|
23
|
+
if (!fs.existsSync(CONFIG_PATH)) return { parsed: {}, existed: false };
|
|
24
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
25
|
+
try {
|
|
26
|
+
const parsed = yaml.parse(raw) || {};
|
|
27
|
+
return { parsed, existed: true };
|
|
28
|
+
} catch (err) {
|
|
29
|
+
throw new Error(`config.yaml exists but is not valid YAML — refusing to rewrite: ${err.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function backup() {
|
|
34
|
+
if (!fs.existsSync(CONFIG_PATH)) return null;
|
|
35
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
36
|
+
const bak = `${CONFIG_PATH}.${ts}.bak`;
|
|
37
|
+
fs.copyFileSync(CONFIG_PATH, bak);
|
|
38
|
+
return bak;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Update the `rag.*` section of config.yaml. Pass only fields you want to
|
|
42
|
+
// change — everything else (projects, themes, sessionLogs, etc.) is preserved.
|
|
43
|
+
// Secret fields should be `${VAR}` references, not raw keys.
|
|
44
|
+
function updateRagConfig(updates) {
|
|
45
|
+
const yaml = require('yaml');
|
|
46
|
+
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
47
|
+
|
|
48
|
+
const { parsed, existed } = loadYaml();
|
|
49
|
+
const rag = (parsed.rag && typeof parsed.rag === 'object') ? parsed.rag : {};
|
|
50
|
+
|
|
51
|
+
// Merge in a well-defined order so the output is readable.
|
|
52
|
+
const merged = {
|
|
53
|
+
enabled: updates.enabled != null ? updates.enabled : (rag.enabled != null ? rag.enabled : false),
|
|
54
|
+
supabaseUrl: updates.supabaseUrl || rag.supabaseUrl || '${SUPABASE_URL}',
|
|
55
|
+
supabaseKey: updates.supabaseKey || rag.supabaseKey || '${SUPABASE_SERVICE_ROLE_KEY}',
|
|
56
|
+
openaiApiKey: updates.openaiApiKey || rag.openaiApiKey || '${OPENAI_API_KEY}',
|
|
57
|
+
anthropicApiKey: updates.anthropicApiKey || rag.anthropicApiKey || '${ANTHROPIC_API_KEY}',
|
|
58
|
+
syncIntervalMs: rag.syncIntervalMs != null ? rag.syncIntervalMs : 10000,
|
|
59
|
+
engramMode: rag.engramMode || 'direct',
|
|
60
|
+
engramWebhookUrl: rag.engramWebhookUrl || 'http://localhost:37778/engram'
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Preserve any fields we didn't explicitly handle (e.g. tables, developerId).
|
|
64
|
+
for (const [k, v] of Object.entries(rag)) {
|
|
65
|
+
if (!(k in merged)) merged[k] = v;
|
|
66
|
+
}
|
|
67
|
+
parsed.rag = merged;
|
|
68
|
+
|
|
69
|
+
const bak = existed ? backup() : null;
|
|
70
|
+
fs.writeFileSync(CONFIG_PATH, yaml.stringify(parsed), 'utf-8');
|
|
71
|
+
return { path: CONFIG_PATH, backup: bak };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Scan loaded parsed config for literal inline secrets in the rag section.
|
|
75
|
+
// Returns an array of dot-paths that still look like raw values (not ${VAR}).
|
|
76
|
+
function findInlineSecrets() {
|
|
77
|
+
const { parsed, existed } = loadYaml();
|
|
78
|
+
if (!existed) return [];
|
|
79
|
+
const rag = parsed.rag || {};
|
|
80
|
+
const hits = [];
|
|
81
|
+
const fields = ['supabaseKey', 'openaiApiKey', 'anthropicApiKey', 'supabaseUrl'];
|
|
82
|
+
for (const f of fields) {
|
|
83
|
+
const v = rag[f];
|
|
84
|
+
if (typeof v !== 'string') continue;
|
|
85
|
+
if (!v) continue;
|
|
86
|
+
const isEnvRef = /^\$\{[A-Z0-9_]+(?::-[^}]*)?\}$/i.test(v.trim());
|
|
87
|
+
if (!isEnvRef) hits.push(`rag.${f}`);
|
|
88
|
+
}
|
|
89
|
+
return hits;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
CONFIG_PATH,
|
|
94
|
+
CONFIG_DIR,
|
|
95
|
+
loadYaml,
|
|
96
|
+
updateRagConfig,
|
|
97
|
+
findInlineSecrets,
|
|
98
|
+
_backup: backup
|
|
99
|
+
};
|