@codewithjuber/forgekit 0.8.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/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.mcp.json +8 -0
- package/ARCHITECTURE.md +314 -0
- package/CHANGELOG.md +467 -0
- package/LICENSE +21 -0
- package/ONBOARDING.md +180 -0
- package/README.md +286 -0
- package/bin/claude-init.sh +90 -0
- package/bin/claude-taste.sh +33 -0
- package/bin/learn-consolidate.sh +51 -0
- package/brand.json +15 -0
- package/global/CLAUDE.md +31 -0
- package/global/crew/frontend-verifier.md +36 -0
- package/global/crew/independent-reviewer.md +29 -0
- package/global/crew/scout.md +24 -0
- package/global/crew/verifier.md +28 -0
- package/global/guards/_guardlib.sh +36 -0
- package/global/guards/cortex.sh +14 -0
- package/global/guards/cost-budget.sh +41 -0
- package/global/guards/doom-loop.sh +25 -0
- package/global/guards/format-on-edit.sh +32 -0
- package/global/guards/lean-guard.sh +20 -0
- package/global/guards/protect-paths.sh +45 -0
- package/global/guards/recall-load.sh +22 -0
- package/global/guards/secret-redact.sh +18 -0
- package/global/guards/session-learner.sh +72 -0
- package/global/recall/MEMORY.md +7 -0
- package/global/rules/self-correction.md +17 -0
- package/global/rules/stack-notes.md +24 -0
- package/global/rules/tech-currency.md +19 -0
- package/global/settings.template.json +183 -0
- package/global/statusline.sh +51 -0
- package/global/taste/brutalist.json +9 -0
- package/global/taste/brutalist.md +19 -0
- package/global/taste/corporate.json +9 -0
- package/global/taste/corporate.md +19 -0
- package/global/taste/editorial.json +9 -0
- package/global/taste/editorial.md +19 -0
- package/global/taste/minimalist.json +9 -0
- package/global/taste/minimalist.md +20 -0
- package/global/taste/playful.json +9 -0
- package/global/taste/playful.md +19 -0
- package/global/tools/atlas/SKILL.md +27 -0
- package/global/tools/code-modernization/SKILL.md +275 -0
- package/global/tools/code-modernization/references/cost-impact-preflight.md +54 -0
- package/global/tools/code-modernization/references/design-patterns-cheatsheet.md +24 -0
- package/global/tools/code-modernization/references/research-protocol.md +42 -0
- package/global/tools/code-modernization/scripts/preflight_scan.py +190 -0
- package/global/tools/cognitive-substrate/SKILL.md +56 -0
- package/global/tools/cognitive-substrate/references/capability-map.md +17 -0
- package/global/tools/cost-guard/SKILL.md +50 -0
- package/global/tools/design-md/SKILL.md +54 -0
- package/global/tools/dev-radar/SKILL.md +56 -0
- package/global/tools/explore-plan-code/SKILL.md +24 -0
- package/global/tools/lean/SKILL.md +41 -0
- package/global/tools/recall/SKILL.md +31 -0
- package/global/tools/reuse-first/SKILL.md +64 -0
- package/global/tools/self-improve/SKILL.md +44 -0
- package/global/tools/taste/SKILL.md +26 -0
- package/global/tools/tech-selector/SKILL.md +35 -0
- package/global/tools/ui-workflow/SKILL.md +44 -0
- package/hooks/hooks.json +107 -0
- package/install.sh +88 -0
- package/package.json +93 -0
- package/public/index.html +45 -0
- package/scripts/build-pages.mjs +180 -0
- package/scripts/bump.mjs +322 -0
- package/skills/cognitive-substrate/SKILL.md +56 -0
- package/skills/cognitive-substrate/references/capability-map.md +17 -0
- package/source/mcp.json +10 -0
- package/source/rules.json +106 -0
- package/source/substrate.json +41 -0
- package/src/adjudicate.js +84 -0
- package/src/anchor.js +210 -0
- package/src/atlas.js +487 -0
- package/src/brain.js +84 -0
- package/src/brand.js +25 -0
- package/src/cli.js +1509 -0
- package/src/context.js +273 -0
- package/src/cortex.js +251 -0
- package/src/cortex_distill.js +55 -0
- package/src/cortex_features.js +81 -0
- package/src/cortex_hook.js +197 -0
- package/src/cortex_hook_main.js +139 -0
- package/src/cortex_mcp.js +352 -0
- package/src/cost_report.js +271 -0
- package/src/dash.html +396 -0
- package/src/dash.js +220 -0
- package/src/diagnose.js +0 -0
- package/src/doctor.js +315 -0
- package/src/embed.js +244 -0
- package/src/emit/_shared.js +39 -0
- package/src/emit/aider.js +22 -0
- package/src/emit/claude.js +44 -0
- package/src/emit/codex.js +17 -0
- package/src/emit/continue.js +28 -0
- package/src/emit/copilot.js +12 -0
- package/src/emit/cursor.js +23 -0
- package/src/emit/gemini.js +40 -0
- package/src/emit/mcp.js +94 -0
- package/src/emit/windsurf.js +22 -0
- package/src/emit/zed.js +34 -0
- package/src/eval.js +47 -0
- package/src/extract.js +82 -0
- package/src/harden.js +44 -0
- package/src/imagine.js +301 -0
- package/src/init.js +178 -0
- package/src/lean.js +149 -0
- package/src/ledger.js +475 -0
- package/src/ledger_bridge.js +279 -0
- package/src/ledger_read.js +152 -0
- package/src/ledger_store.js +360 -0
- package/src/lessons.js +185 -0
- package/src/lessons_store.js +137 -0
- package/src/metrics.js +54 -0
- package/src/model_tiers.js +17 -0
- package/src/model_tiers.json +39 -0
- package/src/predictor.js +143 -0
- package/src/preflight.js +410 -0
- package/src/providers.js +320 -0
- package/src/recall.js +103 -0
- package/src/reuse.js +0 -0
- package/src/route.js +323 -0
- package/src/scope.js +122 -0
- package/src/skillgate.js +89 -0
- package/src/speclock.js +64 -0
- package/src/substrate.js +492 -0
- package/src/sync.js +132 -0
- package/src/taste.js +55 -0
- package/src/uicheck.js +96 -0
- package/src/uifingerprint.js +861 -0
- package/src/uivisual.js +334 -0
- package/src/util.js +71 -0
- package/src/verify.js +117 -0
- package/templates/project-layer/.claude/settings.json +22 -0
- package/templates/project-layer/.claude/skills/hostlelo-deploy/SKILL.md +38 -0
- package/templates/project-layer/AGENTS.md +28 -0
- package/templates/project-layer/CLAUDE.md +40 -0
package/src/providers.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// forge providers — multi-provider model configuration. Supports Anthropic (default),
|
|
2
|
+
// OpenRouter, and custom API endpoints. Config lives in .forge/providers.json; defaults
|
|
3
|
+
// are hardcoded so the system works with zero config. Never stores API keys — only env
|
|
4
|
+
// var names (the key is resolved at runtime from the environment).
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { MODELS } from "./model_tiers.js";
|
|
10
|
+
|
|
11
|
+
const PROVIDERS_FILE = "providers.json";
|
|
12
|
+
const PROVIDERS_DIR = ".forge";
|
|
13
|
+
|
|
14
|
+
const ANTHROPIC_DEFAULT_URL = "https://api.anthropic.com";
|
|
15
|
+
|
|
16
|
+
function providersPath(root) {
|
|
17
|
+
return join(root, PROVIDERS_DIR, PROVIDERS_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BUILTIN_PROVIDERS = {
|
|
21
|
+
anthropic: {
|
|
22
|
+
type: "anthropic",
|
|
23
|
+
label: "Anthropic (direct)",
|
|
24
|
+
baseUrl: "https://api.anthropic.com",
|
|
25
|
+
envKey: "ANTHROPIC_API_KEY",
|
|
26
|
+
models: Object.fromEntries(Object.entries(MODELS).map(([key, m]) => [key, m.id])),
|
|
27
|
+
},
|
|
28
|
+
openrouter: {
|
|
29
|
+
type: "openrouter",
|
|
30
|
+
label: "OpenRouter",
|
|
31
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
32
|
+
envKey: "OPENROUTER_API_KEY",
|
|
33
|
+
models: Object.fromEntries(
|
|
34
|
+
Object.entries(MODELS).map(([key, m]) => [key, `anthropic/${m.id}`]),
|
|
35
|
+
),
|
|
36
|
+
},
|
|
37
|
+
litellm: {
|
|
38
|
+
type: "litellm",
|
|
39
|
+
label: "LiteLLM Gateway",
|
|
40
|
+
baseUrl: "http://localhost:4000",
|
|
41
|
+
envKey: "ANTHROPIC_API_KEY",
|
|
42
|
+
models: {
|
|
43
|
+
haiku: "forge-simple",
|
|
44
|
+
sonnet: "forge-medium",
|
|
45
|
+
opus: "forge-complex",
|
|
46
|
+
fable: "forge-complex",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function defaults() {
|
|
52
|
+
return {
|
|
53
|
+
active: "anthropic",
|
|
54
|
+
providers: { ...BUILTIN_PROVIDERS },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Load providers config, falling back to built-in defaults. */
|
|
59
|
+
export function loadProviders(root = process.cwd()) {
|
|
60
|
+
const path = providersPath(root);
|
|
61
|
+
if (!existsSync(path)) return defaults();
|
|
62
|
+
try {
|
|
63
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
64
|
+
const merged = defaults();
|
|
65
|
+
if (data.active) merged.active = data.active;
|
|
66
|
+
if (data.providers) {
|
|
67
|
+
for (const [name, cfg] of Object.entries(data.providers)) {
|
|
68
|
+
merged.providers[name] = { ...merged.providers[name], ...cfg };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return merged;
|
|
72
|
+
} catch {
|
|
73
|
+
return defaults();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Write providers config. */
|
|
78
|
+
function saveProviders(root, config) {
|
|
79
|
+
const dir = join(root, PROVIDERS_DIR);
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
writeFileSync(providersPath(root), `${JSON.stringify(config, null, 2)}\n`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The active provider config. Explicit .forge/providers.json wins; otherwise auto-detects from env. */
|
|
85
|
+
export function activeProvider(root = process.cwd()) {
|
|
86
|
+
if (existsSync(providersPath(root))) {
|
|
87
|
+
const config = loadProviders(root);
|
|
88
|
+
const name = config.active;
|
|
89
|
+
const provider = config.providers[name];
|
|
90
|
+
if (provider) return { name, ...provider };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const detected = autoDetectProvider();
|
|
94
|
+
if (detected) {
|
|
95
|
+
const { source, ...rest } = detected;
|
|
96
|
+
return { ...rest, _autoDetected: true, _source: source };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { name: "anthropic", ...BUILTIN_PROVIDERS.anthropic };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Resolve a tier key (haiku/sonnet/opus/fable) to the active provider's model ID. */
|
|
103
|
+
export function resolveModel(root, tierKey) {
|
|
104
|
+
const provider = activeProvider(root);
|
|
105
|
+
return provider.models?.[tierKey] ?? MODELS[tierKey]?.id ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Switch the active provider. Returns the new active config. */
|
|
109
|
+
export function setProvider(root, name) {
|
|
110
|
+
const config = loadProviders(root);
|
|
111
|
+
if (!config.providers[name]) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: `unknown provider "${name}" — add it first with forge config provider add`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
config.active = name;
|
|
118
|
+
saveProviders(root, config);
|
|
119
|
+
return { ok: true, name, provider: config.providers[name] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Register a custom provider.
|
|
124
|
+
* @param {string} root
|
|
125
|
+
* @param {string} name
|
|
126
|
+
* @param {{type?: string, label?: string, baseUrl?: string, envKey?: string, models?: Record<string,string>}} cfg
|
|
127
|
+
*/
|
|
128
|
+
export function addProvider(root, name, cfg) {
|
|
129
|
+
if (!name || !cfg.baseUrl) {
|
|
130
|
+
return { ok: false, reason: "name and --base-url are required" };
|
|
131
|
+
}
|
|
132
|
+
const config = loadProviders(root);
|
|
133
|
+
config.providers[name] = {
|
|
134
|
+
type: cfg.type || "custom",
|
|
135
|
+
label: cfg.label || name,
|
|
136
|
+
baseUrl: cfg.baseUrl,
|
|
137
|
+
envKey: cfg.envKey || "",
|
|
138
|
+
models: cfg.models || Object.fromEntries(Object.entries(MODELS).map(([key, m]) => [key, m.id])),
|
|
139
|
+
};
|
|
140
|
+
saveProviders(root, config);
|
|
141
|
+
return { ok: true, name, provider: config.providers[name] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** List all configured providers. */
|
|
145
|
+
export function listProviders(root = process.cwd()) {
|
|
146
|
+
const config = loadProviders(root);
|
|
147
|
+
return Object.entries(config.providers).map(([name, p]) => ({
|
|
148
|
+
name,
|
|
149
|
+
active: name === config.active,
|
|
150
|
+
type: p.type,
|
|
151
|
+
label: p.label || name,
|
|
152
|
+
baseUrl: p.baseUrl,
|
|
153
|
+
envKey: p.envKey,
|
|
154
|
+
hasKey: p.envKey ? Boolean(process.env[p.envKey]) : false,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Auto-detect the best provider from environment variables.
|
|
160
|
+
* Pure read — never writes config. Returns null if nothing detected.
|
|
161
|
+
*/
|
|
162
|
+
export function autoDetectProvider() {
|
|
163
|
+
const litellmUrl = (process.env.LITELLM_BASE_URL || "").replace(/\/+$/, "");
|
|
164
|
+
if (litellmUrl) {
|
|
165
|
+
return {
|
|
166
|
+
name: "litellm",
|
|
167
|
+
type: "litellm",
|
|
168
|
+
label: "LiteLLM Gateway (hosted)",
|
|
169
|
+
baseUrl: litellmUrl,
|
|
170
|
+
envKey: process.env.LITELLM_API_KEY ? "LITELLM_API_KEY" : "ANTHROPIC_API_KEY",
|
|
171
|
+
models: Object.fromEntries(Object.entries(MODELS).map(([key, m]) => [key, m.id])),
|
|
172
|
+
source: "LITELLM_BASE_URL",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const baseUrl = (process.env.ANTHROPIC_BASE_URL || "").replace(/\/+$/, "");
|
|
177
|
+
if (baseUrl && baseUrl.toLowerCase() !== ANTHROPIC_DEFAULT_URL) {
|
|
178
|
+
return {
|
|
179
|
+
name: "anthropic-proxy",
|
|
180
|
+
type: "anthropic",
|
|
181
|
+
label: "Anthropic (via proxy)",
|
|
182
|
+
baseUrl,
|
|
183
|
+
envKey: "ANTHROPIC_API_KEY",
|
|
184
|
+
models: Object.fromEntries(Object.entries(MODELS).map(([key, m]) => [key, m.id])),
|
|
185
|
+
source: "ANTHROPIC_BASE_URL",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (process.env.OPENROUTER_API_KEY) {
|
|
190
|
+
return { name: "openrouter", ...BUILTIN_PROVIDERS.openrouter, source: "OPENROUTER_API_KEY" };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
194
|
+
return { name: "anthropic", ...BUILTIN_PROVIDERS.anthropic, source: "ANTHROPIC_API_KEY" };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** List all providers that could be auto-detected from the current environment. */
|
|
201
|
+
export function listDetectedProviders() {
|
|
202
|
+
const detected = [];
|
|
203
|
+
const litellmUrl = (process.env.LITELLM_BASE_URL || "").replace(/\/+$/, "");
|
|
204
|
+
if (litellmUrl) {
|
|
205
|
+
detected.push({
|
|
206
|
+
name: "litellm",
|
|
207
|
+
type: "litellm",
|
|
208
|
+
label: "LiteLLM Gateway (hosted)",
|
|
209
|
+
source: "LITELLM_BASE_URL",
|
|
210
|
+
available: true,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const baseUrl = (process.env.ANTHROPIC_BASE_URL || "").replace(/\/+$/, "");
|
|
214
|
+
if (baseUrl && baseUrl.toLowerCase() !== ANTHROPIC_DEFAULT_URL) {
|
|
215
|
+
detected.push({
|
|
216
|
+
name: "anthropic-proxy",
|
|
217
|
+
type: "anthropic",
|
|
218
|
+
label: "Anthropic (via proxy)",
|
|
219
|
+
source: "ANTHROPIC_BASE_URL",
|
|
220
|
+
available: true,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (process.env.OPENROUTER_API_KEY) {
|
|
224
|
+
detected.push({
|
|
225
|
+
name: "openrouter",
|
|
226
|
+
type: "openrouter",
|
|
227
|
+
label: "OpenRouter",
|
|
228
|
+
source: "OPENROUTER_API_KEY",
|
|
229
|
+
available: true,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
233
|
+
detected.push({
|
|
234
|
+
name: "anthropic",
|
|
235
|
+
type: "anthropic",
|
|
236
|
+
label: "Anthropic (direct)",
|
|
237
|
+
source: "ANTHROPIC_API_KEY",
|
|
238
|
+
available: true,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return detected;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Check provider health: env key set, gateway reachable (litellm), base URL valid. */
|
|
245
|
+
export function providerStatus(root = process.cwd()) {
|
|
246
|
+
const prov = activeProvider(root);
|
|
247
|
+
const checks = [];
|
|
248
|
+
|
|
249
|
+
if (prov._autoDetected) {
|
|
250
|
+
checks.push({
|
|
251
|
+
id: "auto-detect",
|
|
252
|
+
ok: true,
|
|
253
|
+
detail: `Auto-detected from ${prov._source}`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (prov.envKey) {
|
|
258
|
+
checks.push({
|
|
259
|
+
id: "api-key",
|
|
260
|
+
ok: Boolean(process.env[prov.envKey]),
|
|
261
|
+
detail: process.env[prov.envKey]
|
|
262
|
+
? `${prov.envKey} is set`
|
|
263
|
+
: `${prov.envKey} is NOT set — get it from the Anthropic Console (console.anthropic.com/settings/keys) or your provider's dashboard`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (prov.type === "litellm") {
|
|
267
|
+
let reachable = false;
|
|
268
|
+
try {
|
|
269
|
+
const out = execFileSync(
|
|
270
|
+
"curl",
|
|
271
|
+
["-sf", "-o", "/dev/null", "-w", "%{http_code}", `${prov.baseUrl}/health`],
|
|
272
|
+
{ encoding: "utf8", timeout: 5000 },
|
|
273
|
+
);
|
|
274
|
+
reachable = out.startsWith("2");
|
|
275
|
+
} catch {}
|
|
276
|
+
checks.push({
|
|
277
|
+
id: "gateway",
|
|
278
|
+
ok: reachable,
|
|
279
|
+
detail: reachable
|
|
280
|
+
? `LiteLLM gateway reachable at ${prov.baseUrl}`
|
|
281
|
+
: `LiteLLM gateway NOT reachable at ${prov.baseUrl}`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const envScan = [
|
|
286
|
+
{ key: "LITELLM_BASE_URL", set: Boolean(process.env.LITELLM_BASE_URL) },
|
|
287
|
+
{ key: "LITELLM_API_KEY", set: Boolean(process.env.LITELLM_API_KEY) },
|
|
288
|
+
{ key: "ANTHROPIC_BASE_URL", set: Boolean(process.env.ANTHROPIC_BASE_URL) },
|
|
289
|
+
{ key: "OPENROUTER_API_KEY", set: Boolean(process.env.OPENROUTER_API_KEY) },
|
|
290
|
+
{ key: "ANTHROPIC_API_KEY", set: Boolean(process.env.ANTHROPIC_API_KEY) },
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
provider: prov.name,
|
|
295
|
+
type: prov.type,
|
|
296
|
+
autoDetected: Boolean(prov._autoDetected),
|
|
297
|
+
source: prov._source || null,
|
|
298
|
+
checks,
|
|
299
|
+
envScan,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Apply a routed model recommendation to ~/.claude/settings.json.
|
|
305
|
+
* @param {string} tierKey - the recommended tier (haiku/sonnet/opus/fable)
|
|
306
|
+
* @param {{settingsPath?: string}} [opts]
|
|
307
|
+
*/
|
|
308
|
+
export function applyRoute(tierKey, { settingsPath } = {}) {
|
|
309
|
+
const target = settingsPath || join(homedir(), ".claude", "settings.json");
|
|
310
|
+
let settings = {};
|
|
311
|
+
try {
|
|
312
|
+
settings = JSON.parse(readFileSync(target, "utf8"));
|
|
313
|
+
} catch {}
|
|
314
|
+
const modelId = MODELS[tierKey]?.id;
|
|
315
|
+
if (!modelId) return { ok: false, reason: `unknown tier "${tierKey}"` };
|
|
316
|
+
const prev = settings.model;
|
|
317
|
+
settings.model = tierKey;
|
|
318
|
+
writeFileSync(target, `${JSON.stringify(settings, null, 2)}\n`);
|
|
319
|
+
return { ok: true, model: tierKey, modelId, prev, path: target };
|
|
320
|
+
}
|
package/src/recall.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// forge recall — file-based cross-session memory: one fact per file + a MEMORY.md
|
|
2
|
+
// index. Refuses to persist secrets. Consolidation is deterministic (exact-dupe
|
|
3
|
+
// prune) — honest: no model call, so it can't hallucinate a "merged" memory.
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
// Secret-refusal now lives in the PCM ledger core (ledger.js) so NO claim kind can
|
|
9
|
+
// persist a credential; re-exported here because recall is where callers historically
|
|
10
|
+
// imported it from (lessons_store, guards, tests). See ledger.js for the precision
|
|
11
|
+
// rationale (credential formats + key-assigned-to-value, never a bare English mention).
|
|
12
|
+
import { SECRET_RE } from "./ledger.js";
|
|
13
|
+
// The merged read helper (P2 read flip). Import cycle note: ledger_read → lessons_store
|
|
14
|
+
// → recall is function-level only (no module-eval use on either side), so ESM resolves
|
|
15
|
+
// it safely — same pattern as lessons_store's own SECRET_RE import from here.
|
|
16
|
+
import { mergeFactSlugs } from "./ledger_read.js";
|
|
17
|
+
|
|
18
|
+
export { SECRET_RE };
|
|
19
|
+
|
|
20
|
+
export function defaultStore() {
|
|
21
|
+
return join(process.env.FORGE_HOME || join(homedir(), ".forge"), "recall");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const factsDir = (store) => join(store, "facts");
|
|
25
|
+
|
|
26
|
+
import { slug as slugify } from "./util.js";
|
|
27
|
+
|
|
28
|
+
export function add(store, name, body) {
|
|
29
|
+
if (SECRET_RE.test(`${name}\n${body}`)) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
reason: "refused: looks like a secret/credential — store a pointer, not the value",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const dir = factsDir(store);
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
const slug = slugify(name) || "fact";
|
|
38
|
+
writeFileSync(join(dir, `${slug}.md`), `# ${name}\n\n${body.trim()}\n`);
|
|
39
|
+
reindex(store);
|
|
40
|
+
return { ok: true, slug };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parse one stored fact back into {name, text} — THE parser for the fact format
|
|
44
|
+
* (`# name\n\ntext`), CRLF-tolerant so a Windows checkout can't fork the format.
|
|
45
|
+
* Everything that reads fact files (bridge import, consolidation) must use this. */
|
|
46
|
+
export function readFact(store, slug) {
|
|
47
|
+
const path = join(factsDir(store), `${slug}.md`);
|
|
48
|
+
if (!existsSync(path)) return null;
|
|
49
|
+
const raw = readFileSync(path, "utf8").replace(/\r\n/g, "\n");
|
|
50
|
+
const m = raw.match(/^# (.*)\n\n([\s\S]*)$/);
|
|
51
|
+
return m ? { name: m[1].trim(), text: m[2].trim() } : { name: slug, text: raw.trim() };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** File-backed fact slugs ONLY — the store the write path (add/consolidate) manages.
|
|
55
|
+
* The ledger bridge reconciles against THIS list: a merged teammate fact has no file,
|
|
56
|
+
* and treating it as "deleted from the store" would tombstone it away. */
|
|
57
|
+
export function listStored(store) {
|
|
58
|
+
const dir = factsDir(store);
|
|
59
|
+
if (!existsSync(dir)) return [];
|
|
60
|
+
return readdirSync(dir)
|
|
61
|
+
.filter((f) => f.endsWith(".md"))
|
|
62
|
+
.map((f) => f.replace(/\.md$/, ""))
|
|
63
|
+
.sort();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The read view (P2 read flip): file facts ∪ live facts from the personal ledger this
|
|
67
|
+
* store shadows into (`<store>/ledger` — see `forge recall add`). A file wins on slug
|
|
68
|
+
* collision (the file is the canonical local value). */
|
|
69
|
+
export function list(store) {
|
|
70
|
+
return mergeFactSlugs(listStored(store), join(store, "ledger"));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function reindex(store) {
|
|
74
|
+
const items = list(store);
|
|
75
|
+
mkdirSync(store, { recursive: true });
|
|
76
|
+
writeFileSync(
|
|
77
|
+
join(store, "MEMORY.md"),
|
|
78
|
+
["# Durable memory index", "", ...items.map((s) => `- ${s}`), ""].join("\n"),
|
|
79
|
+
);
|
|
80
|
+
return items.length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function consolidate(store) {
|
|
84
|
+
const dir = factsDir(store);
|
|
85
|
+
if (!existsSync(dir)) return { removed: 0, kept: 0 };
|
|
86
|
+
const seen = new Map();
|
|
87
|
+
let removed = 0;
|
|
88
|
+
for (const file of readdirSync(dir)
|
|
89
|
+
.filter((f) => f.endsWith(".md"))
|
|
90
|
+
.sort()) {
|
|
91
|
+
// Dedupe on the fact BODY (after the "# title" line), so two facts with
|
|
92
|
+
// different names but identical content collapse to one.
|
|
93
|
+
const raw = readFileSync(join(dir, file), "utf8");
|
|
94
|
+
const sep = raw.indexOf("\n\n");
|
|
95
|
+
const key = (sep >= 0 ? raw.slice(sep + 2) : raw).replace(/\s+/g, " ").trim();
|
|
96
|
+
if (seen.has(key)) {
|
|
97
|
+
rmSync(join(dir, file));
|
|
98
|
+
removed += 1;
|
|
99
|
+
} else seen.set(key, file);
|
|
100
|
+
}
|
|
101
|
+
reindex(store);
|
|
102
|
+
return { removed, kept: seen.size };
|
|
103
|
+
}
|
package/src/reuse.js
ADDED
|
Binary file
|