total-recall-brain 3.40.7 → 3.41.1
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/bin/total-recall-memory.mjs +0 -0
- package/bin/total-recall.mjs +9 -40
- package/docs/reference/cli-reference.md +65 -1
- package/frontend/dist/assets/{index-AGUes-VG.css → index-BG9NkVUy.css} +1 -1
- package/frontend/dist/assets/{index-Bt5iVPZj.js → index-BtuiZqwP.js} +20 -7
- package/frontend/dist/index.html +2 -2
- package/frontend/src/components/plugins/PluginPanel.tsx +31 -5
- package/frontend/src/components/plugins/card/CardEngine.tsx +15 -23
- package/frontend/src/components/plugins/card/CardScenes.tsx +52 -23
- package/frontend/src/components/plugins/card/card.css +2 -5
- package/frontend/src/components/plugins/card/contract.ts +13 -19
- package/frontend/src/components/plugins/card/format.ts +8 -1
- package/frontend/src/components/plugins/card/widgets.tsx +4 -35
- package/frontend/src/components/plugins/plugin-frame.ts +30 -3
- package/frontend/src/pages/PluginsPage.tsx +3 -2
- package/package.json +1 -1
- package/scaffold/.agent/skills/meta-harness/references/optimized/original.md +1 -1
- package/scaffold/.agent/skills/plugin/SKILL.md +110 -0
- package/scaffold/.agent/skills/plugin/evals/README.md +53 -0
- package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +42 -0
- package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +39 -0
- package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +32 -0
- package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +46 -0
- package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +55 -0
- package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +92 -0
- package/scaffold/.agent/skills/plugin/evals/run.mjs +118 -0
- package/scaffold/.agent/skills/plugin/prompts/01-function.md +29 -0
- package/scaffold/.agent/skills/plugin/prompts/02-surfaces.md +43 -0
- package/scaffold/.agent/skills/plugin/prompts/03-scaffold.md +31 -0
- package/scaffold/.agent/skills/plugin/prompts/04-cli.md +49 -0
- package/scaffold/.agent/skills/plugin/prompts/05-credentials.md +41 -0
- package/scaffold/.agent/skills/plugin/prompts/06-card.md +46 -0
- package/scaffold/.agent/skills/plugin/prompts/07-scaffold.md +52 -0
- package/scaffold/.agent/skills/plugin/prompts/08-verify.md +43 -0
- package/scaffold/.agent/skills/plugin/references/anti-patterns.md +123 -0
- package/scaffold/.agent/skills/plugin/references/card.md +90 -0
- package/scaffold/.agent/skills/plugin/references/scaffold.md +120 -0
- package/scaffold/.agent/skills/plugin/references/surfaces.md +206 -0
- package/scaffold/.agent/skills/plugin/scripts/record.mjs +360 -0
- package/scaffold/.agent/skills/plugin/subagents/card-designer.md +60 -0
- package/scaffold/.agent/skills/plugin/subagents/plugin-reviewer.md +55 -0
- package/scaffold/.agent/skills/plugin/subagents/scaffold-extractor.md +71 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +71 -1441
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +7 -5
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +3 -44
- package/scaffold/.agent/skills/total-recall/plugins/auth/scripts/agent-login-pty.py +185 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +20 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/host-secrets-store.mjs +15 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +6 -4
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +1 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -24
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +0 -21
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +0 -32
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +11 -0
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +5 -2
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -28
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/DESIGN.md +95 -13
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +553 -340
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +0 -36
- package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +383 -400
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +0 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +0 -28
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +0 -32
- package/scaffold/.agent/skills/total-recall/references/auth.md +3 -1
- package/scaffold/.agent/skills/total-recall/references/memory.md +4 -0
- package/scaffold/.agent/skills/total-recall/references/openwiki.md +2 -0
- package/scaffold/.agent/skills/total-recall/references/operations.md +12 -0
- package/src/cli/brain.mjs +72 -2
- package/src/cli/connect.mjs +10 -39
- package/src/cli/context-stats.mjs +104 -0
- package/src/cli/context.mjs +8 -0
- package/src/cli/daemon.mjs +10 -1
- package/src/cli/export.mjs +3 -2
- package/src/cli/ingest-okf.mjs +12 -15
- package/src/cli/init.mjs +0 -85
- package/src/cli/lint.mjs +3 -2
- package/src/cli/plugin/copies.mjs +68 -0
- package/src/cli/plugin/create.mjs +22 -24
- package/src/cli/plugin/index.mjs +49 -3
- package/src/cli/plugin/search.mjs +19 -2
- package/src/cli/rebuild.mjs +2 -0
- package/src/cli/remember.mjs +23 -0
- package/src/cli/status.mjs +10 -3
- package/src/cli/update.mjs +3 -2
- package/src/core/action-predictor.mjs +161 -0
- package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +20 -3
- package/src/core/app-deploy/source.mjs +16 -9
- package/src/core/automatic-brain-updates.mjs +6 -3
- package/src/core/brain-registry.mjs +19 -2
- package/src/core/brain-state.mjs +23 -0
- package/src/core/cli-commands.mjs +1 -0
- package/src/core/cli-maintenance.mjs +1 -1
- package/src/core/cli-semantic-index.mjs +1 -1
- package/src/core/command-surface.mjs +3 -3
- package/src/core/composed-command-resolution.mjs +21 -0
- package/src/core/config.mjs +84 -0
- package/src/core/confined-memory.mjs +93 -0
- package/src/core/context-telemetry.mjs +214 -0
- package/src/core/daemon-control.mjs +14 -43
- package/src/core/fast-recall.mjs +1 -1
- package/src/core/memory-brain.mjs +2 -0
- package/src/core/plugin-activation.mjs +38 -6
- package/src/core/plugin-bundle.mjs +54 -0
- package/src/core/plugin-catalog.mjs +1 -1
- package/src/core/plugin-context.mjs +1 -1
- package/src/core/plugin-contracts/card.mjs +69 -37
- package/src/core/plugin-loader.mjs +5 -6
- package/src/core/plugin-runner.mjs +13 -6
- package/src/core/plugin-runtime.mjs +5 -4
- package/src/core/plugin-setup.mjs +5 -3
- package/src/core/plugin-state-store.mjs +12 -1
- package/src/core/plugin-state.mjs +46 -5
- package/src/core/plugin-store.mjs +78 -3
- package/src/core/plugin-tasks.mjs +3 -1
- package/src/core/plugin-updates.mjs +117 -0
- package/src/core/plugin-vendor.mjs +10 -4
- package/src/core/project-brain.mjs +14 -41
- package/src/core/project-id.mjs +4 -1
- package/src/core/required-plugins.mjs +151 -13
- package/src/core/skills-registry.mjs +27 -3
- package/src/core/ssss-kernel-bridge.mjs +3 -1
- package/src/core/startup-health.mjs +66 -16
- package/src/core/surface.mjs +53 -46
- package/src/core/testing/plugin-fixture.mjs +69 -0
- package/src/core/vault-files.mjs +13 -0
- package/src/core/vault.mjs +4 -30
- package/src/server/index.mjs +13 -1
- package/src/server/invoke-decision-child.mjs +49 -0
- package/src/server/invoke-execution.mjs +87 -0
- package/src/server/invoke-policy.mjs +64 -0
- package/src/server/invoke-read-child.mjs +26 -0
- package/src/server/invoke-targets.mjs +49 -0
- package/src/server/invoke-write-child.mjs +19 -0
- package/src/server/routes/invoke.mjs +314 -0
- package/src/server/routes/plugins.mjs +1 -1
- package/scaffold/.agent/skills/plugins/SKILL.md +0 -57
- package/scaffold/.agent/skills/plugins/references/card-design.md +0 -55
- package/scaffold/.agent/skills/plugins/references/distribution.md +0 -23
- package/scaffold/.agent/skills/plugins/references/manifest-contract.md +0 -90
- package/scaffold/.agent/skills/plugins/references/optimized/index.md +0 -9
- package/scaffold/.agent/skills/plugins/references/optimized/manifest.json +0 -35
- package/scaffold/.agent/skills/plugins/references/optimized/original.md +0 -58
- package/scaffold/.agent/skills/plugins/references/optimized/section-01.md +0 -5
- package/scaffold/.agent/skills/plugins/references/optimized/section-02.md +0 -25
- package/scaffold/.agent/skills/plugins/references/optimized/section-03.md +0 -12
- package/scaffold/.agent/skills/plugins/references/optimized/section-04.md +0 -7
- package/scaffold/.agent/skills/plugins/references/optimized/section-05.md +0 -5
- package/scaffold/.agent/skills/total-recall/openwiki/README.md +0 -10
- package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +0 -5
- package/scaffold/.agent/skills/total-recall/openwiki/ide.md +0 -10
- package/scaffold/.agent/skills/total-recall/openwiki/memory.md +0 -11
- package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +0 -7
- package/scaffold/.agent/skills/total-recall/openwiki/skills.md +0 -7
- package/scaffold/openwiki/README.md +0 -10
- package/scaffold/openwiki/architecture.md +0 -5
- package/scaffold/openwiki/ide.md +0 -10
- package/scaffold/openwiki/memory.md +0 -11
- package/scaffold/openwiki/secrets.md +0 -7
- package/scaffold/openwiki/skills.md +0 -7
- package/src/core/okf-adapter.mjs +0 -559
- package/templates/openwiki/README.md +0 -10
- package/templates/openwiki/architecture.md +0 -5
- package/templates/openwiki/ide.md +0 -10
- package/templates/openwiki/memory.md +0 -11
- package/templates/openwiki/secrets.md +0 -7
- package/templates/openwiki/skills.md +0 -7
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* action-predictor — ask Jev which action to take next, instead of loading the
|
|
3
|
+
* whole CLI surface into a generative model's context.
|
|
4
|
+
*
|
|
5
|
+
* The shape of the problem: Total Recall exposes 352 commands, 7 composed
|
|
6
|
+
* commands and 34 plugins. A generative model asked "what should I do next?"
|
|
7
|
+
* needs that catalog in its prompt, which is exactly the context cost the
|
|
8
|
+
* capsule work exists to reduce. A decision model does not generate prose — it
|
|
9
|
+
* reads state plus a map of named questions and returns TYPED answers with
|
|
10
|
+
* calibrated probabilities. So the catalog becomes the option set of a `choice`
|
|
11
|
+
* question, and the answer is one verb plus a confidence.
|
|
12
|
+
*
|
|
13
|
+
* Cost shape: a probe measured ~$0.000012-0.000022 per decision with the pinned
|
|
14
|
+
* Jev build, against ~10,000 tokens of capsule on every call.
|
|
15
|
+
*
|
|
16
|
+
* Two rules this module holds to:
|
|
17
|
+
* 1. Narrowing happens in code. A decision model given 352 options answers
|
|
18
|
+
* worse than one given the 20 that survived retrieval, and the retrieval
|
|
19
|
+
* layer is deterministic and free. Jev chooses from a shortlist, it does
|
|
20
|
+
* not search the catalog.
|
|
21
|
+
* 2. A low-confidence answer is not an action. The confidence is returned so
|
|
22
|
+
* the caller can gate on it and fall back, never silently act.
|
|
23
|
+
*/
|
|
24
|
+
import { queryCliSemanticIndex } from './cli-semantic-index.mjs';
|
|
25
|
+
|
|
26
|
+
/** How many candidates retrieval hands to the decision model. */
|
|
27
|
+
export const DEFAULT_SHORTLIST = 20;
|
|
28
|
+
/** Below this, the caller should treat the prediction as advice only. */
|
|
29
|
+
export const DEFAULT_MIN_CONFIDENCE = 0.5;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the typed question set for "what next?".
|
|
33
|
+
*
|
|
34
|
+
* `choice.criteria` maps option name to description, which is where the model's
|
|
35
|
+
* routing signal lives — a bare list of verb names gives it almost nothing.
|
|
36
|
+
*/
|
|
37
|
+
export function buildActionQuestions(candidates, { intent, allowNone = true } = {}) {
|
|
38
|
+
const criteria = {};
|
|
39
|
+
for (const c of candidates) {
|
|
40
|
+
// The usage form is what a caller would actually run, so it is what the
|
|
41
|
+
// model should see; the description disambiguates near-identical verbs.
|
|
42
|
+
const label = c.usage || c.verb || c.id;
|
|
43
|
+
const description = [c.name, c.description].filter(Boolean).join(' — ');
|
|
44
|
+
criteria[label] = description ? `${description}` : `${label}`;
|
|
45
|
+
}
|
|
46
|
+
if (allowNone) criteria.none = 'No single command fits; the next step is not a CLI verb.';
|
|
47
|
+
|
|
48
|
+
const questions = {
|
|
49
|
+
next_action: {
|
|
50
|
+
type: 'choice',
|
|
51
|
+
instructions: intent
|
|
52
|
+
? `Given this state and the operator's intent, which single command should run next? Intent: ${intent}`
|
|
53
|
+
: 'Given this state, which single command should run next?',
|
|
54
|
+
criteria,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
if (allowNone) {
|
|
58
|
+
questions.actionable = {
|
|
59
|
+
type: 'noul',
|
|
60
|
+
instructions: 'Is there enough information in the state to choose a specific command confidently?',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return questions;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Retrieve the candidate verbs for an intent.
|
|
68
|
+
*
|
|
69
|
+
* Deterministic and local. The decision model never sees the full catalog.
|
|
70
|
+
*/
|
|
71
|
+
export async function shortlistActions(intent, { derivedDir = null, projectRoot = process.cwd(), limit = DEFAULT_SHORTLIST } = {}) {
|
|
72
|
+
const hits = await queryCliSemanticIndex(intent || '', { derivedDir, projectRoot, limit, minScore: 0 });
|
|
73
|
+
return hits.map(h => ({
|
|
74
|
+
id: h.id,
|
|
75
|
+
verb: h.verb,
|
|
76
|
+
name: h.name,
|
|
77
|
+
usage: h.usage,
|
|
78
|
+
description: h.description,
|
|
79
|
+
namespace: h.namespace,
|
|
80
|
+
category: h.category,
|
|
81
|
+
score: h.score,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Predict the next action.
|
|
87
|
+
*
|
|
88
|
+
* `requestDecision` is injected so this is testable without a live provider and
|
|
89
|
+
* so a caller can supply its own transport. Returns a plain result: the caller
|
|
90
|
+
* decides what to do with it, and an abstention is a first-class outcome.
|
|
91
|
+
*/
|
|
92
|
+
export async function predictNextAction(intent, {
|
|
93
|
+
state = {},
|
|
94
|
+
derivedDir = null,
|
|
95
|
+
projectRoot = process.cwd(),
|
|
96
|
+
limit = DEFAULT_SHORTLIST,
|
|
97
|
+
minConfidence = DEFAULT_MIN_CONFIDENCE,
|
|
98
|
+
requestDecision,
|
|
99
|
+
model,
|
|
100
|
+
endpoint,
|
|
101
|
+
timeoutMs,
|
|
102
|
+
apiKey,
|
|
103
|
+
allowNone = true,
|
|
104
|
+
} = {}) {
|
|
105
|
+
const candidates = await shortlistActions(intent, { derivedDir, projectRoot, limit });
|
|
106
|
+
if (!candidates.length) {
|
|
107
|
+
return { ok: false, reason: 'no-candidates', intent, candidates: [] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const byLabel = new Map(candidates.map(c => [c.usage || c.verb || c.id, c]));
|
|
111
|
+
const questions = buildActionQuestions(candidates, { intent, allowNone });
|
|
112
|
+
|
|
113
|
+
const decision = await requestDecision({
|
|
114
|
+
apiKey, model, endpoint, timeoutMs,
|
|
115
|
+
state,
|
|
116
|
+
questions,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (!decision) {
|
|
120
|
+
return { ok: false, reason: 'no-decision', intent, candidates };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const chosenLabel = decision.choice('next_action', '');
|
|
124
|
+
const confidence = decision.confidence('next_action');
|
|
125
|
+
const actionable = allowNone ? decision.noul('actionable', false) : true;
|
|
126
|
+
const chosen = byLabel.get(chosenLabel) || null;
|
|
127
|
+
|
|
128
|
+
// An answer naming nothing, or an option that was never offered, is an
|
|
129
|
+
// abstention — not a command to run.
|
|
130
|
+
if (!chosen) {
|
|
131
|
+
return {
|
|
132
|
+
ok: true, abstained: true, reason: 'no-command-fits', intent, candidates,
|
|
133
|
+
confidence, model: decision.modelVersion, cost: decision.cost, latencyMs: decision.latencyMs,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (confidence < minConfidence) {
|
|
137
|
+
return {
|
|
138
|
+
ok: true, abstained: true, reason: 'low-confidence', intent, candidates,
|
|
139
|
+
candidate: chosen, confidence, minConfidence,
|
|
140
|
+
model: decision.modelVersion, cost: decision.cost, latencyMs: decision.latencyMs,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (!actionable) {
|
|
144
|
+
return {
|
|
145
|
+
ok: true, abstained: true, reason: 'model-reports-insufficient-state', intent, candidates,
|
|
146
|
+
candidate: chosen, confidence,
|
|
147
|
+
model: decision.modelVersion, cost: decision.cost, latencyMs: decision.latencyMs,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
ok: true, abstained: false, intent,
|
|
153
|
+
action: chosen,
|
|
154
|
+
confidence,
|
|
155
|
+
alternatives: candidates.filter(c => c !== chosen).slice(0, 5),
|
|
156
|
+
model: decision.modelVersion,
|
|
157
|
+
endpoint: decision.endpointUsed,
|
|
158
|
+
cost: decision.cost,
|
|
159
|
+
latencyMs: decision.latencyMs,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -8,6 +8,23 @@ const SPEC = __SPEC__;
|
|
|
8
8
|
const EXIT = { OK: 0, HTTP: 1, USAGE: 2, UNREACHABLE: 3 };
|
|
9
9
|
const flagOf = (name) => `--${name.replace(/_/g, '-')}`;
|
|
10
10
|
|
|
11
|
+
// Model harnesses scrub credential-shaped environment names (KEY/PASSWORD/SECRET/TOKEN) from the
|
|
12
|
+
// shell they hand an agent, so a per-run bearer token exported by the parent is invisible here and
|
|
13
|
+
// every call fails as unauthenticated. Read the same token from a 0600 file in the working
|
|
14
|
+
// directory when the environment has none, so the agent gets its own scoped credential back
|
|
15
|
+
// without the token ever reaching a log or a prompt.
|
|
16
|
+
async function fileToken() {
|
|
17
|
+
try {
|
|
18
|
+
const [{ readFileSync }, path] = await Promise.all([import('node:fs'), import('node:path')]);
|
|
19
|
+
// Deliberately not named after the token variable: a credential-shaped file name is a smell and
|
|
20
|
+
// invites the same scrub that hid the environment variable.
|
|
21
|
+
const value = readFileSync(path.default.join(process.cwd(), `.${SPEC.name}-agent-grant`), 'utf8').trim();
|
|
22
|
+
return value || undefined;
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
11
28
|
class UsageError extends Error {}
|
|
12
29
|
|
|
13
30
|
function usage(command) {
|
|
@@ -109,7 +126,7 @@ function parse(argv) {
|
|
|
109
126
|
return { global, command, values };
|
|
110
127
|
}
|
|
111
128
|
|
|
112
|
-
function buildRequest(command, values, baseUrl) {
|
|
129
|
+
async function buildRequest(command, values, baseUrl) {
|
|
113
130
|
let base;
|
|
114
131
|
try {
|
|
115
132
|
base = new URL(baseUrl);
|
|
@@ -132,7 +149,7 @@ function buildRequest(command, values, baseUrl) {
|
|
|
132
149
|
init.headers['content-type'] = 'application/json';
|
|
133
150
|
init.body = JSON.stringify(Object.fromEntries(rest));
|
|
134
151
|
}
|
|
135
|
-
const token = SPEC.auth ? process.env[SPEC.auth.token_env] : undefined;
|
|
152
|
+
const token = SPEC.auth ? (process.env[SPEC.auth.token_env] || await fileToken()) : undefined;
|
|
136
153
|
if (token) init.headers.authorization = `Bearer ${token}`;
|
|
137
154
|
return { url, init };
|
|
138
155
|
}
|
|
@@ -173,7 +190,7 @@ async function main() {
|
|
|
173
190
|
const baseUrl = global.baseUrl || process.env[SPEC.api.base_url_env] || SPEC.api.default_base_url;
|
|
174
191
|
let request;
|
|
175
192
|
try {
|
|
176
|
-
request = buildRequest(command, values, baseUrl);
|
|
193
|
+
request = await buildRequest(command, values, baseUrl);
|
|
177
194
|
} catch (err) {
|
|
178
195
|
if (!(err instanceof UsageError)) throw err;
|
|
179
196
|
console.error(`${SPEC.name}: ${err.message}`);
|
|
@@ -243,16 +243,23 @@ export async function resolveCapabilitySource(source, options = {}) {
|
|
|
243
243
|
sourceType = 'local';
|
|
244
244
|
candidateDir = path.resolve(source);
|
|
245
245
|
} else {
|
|
246
|
-
// Check if it's an installed or bundled plugin by ID
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
246
|
+
// Check if it's an installed or bundled plugin by ID.
|
|
247
|
+
//
|
|
248
|
+
// Precedence matters here. `getPlugin` searches the caller's project and
|
|
249
|
+
// the global agent dir, so on a machine where the same id is also
|
|
250
|
+
// installed it returns that copy — and the request was recorded as
|
|
251
|
+
// 'local' instead of 'bundled'. Bundled is the intended source for a bare
|
|
252
|
+
// id, so it is resolved first and only then falls back to what is
|
|
253
|
+
// installed.
|
|
254
|
+
const bundledDir = path.join(bundledPluginsDir(), source);
|
|
255
|
+
if (fs.existsSync(bundledDir)) {
|
|
256
|
+
sourceType = 'bundled';
|
|
257
|
+
candidateDir = bundledDir;
|
|
251
258
|
} else {
|
|
252
|
-
const
|
|
253
|
-
if (fs.existsSync(
|
|
254
|
-
sourceType = 'bundled';
|
|
255
|
-
candidateDir =
|
|
259
|
+
const installed = getPlugin(source, options.projectRoot || process.cwd());
|
|
260
|
+
if (installed && fs.existsSync(installed.dir)) {
|
|
261
|
+
sourceType = installed.scope === 'bundled' ? 'bundled' : 'local';
|
|
262
|
+
candidateDir = installed.dir;
|
|
256
263
|
}
|
|
257
264
|
}
|
|
258
265
|
}
|
|
@@ -10,6 +10,7 @@ import { readProcessCommand, shouldHonorPidLock } from './pid-lock.mjs';
|
|
|
10
10
|
import { updateGlobalBrainPackages } from './global-brain-updates.mjs';
|
|
11
11
|
import { updateMeshBrainPackages } from './mesh-brain-updates.mjs';
|
|
12
12
|
import { updateSourceBrainPackages } from './source-brain-updates.mjs';
|
|
13
|
+
import { updateInstalledPlugins } from './plugin-updates.mjs';
|
|
13
14
|
|
|
14
15
|
export function automaticUpdateStatus(brainDir) {
|
|
15
16
|
let last;
|
|
@@ -34,7 +35,7 @@ export function startAutomaticBrainUpdates({brainDir,intervalMs=60*60*1000,run=r
|
|
|
34
35
|
return {tick,stop(){stopped=true;clearInterval(timer);}};
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false,skipThrottle=false,intervalMs=60*60*1000,update=updateAllBrains,compile=maintainCliSurfaces,globalUpdate=updateGlobalBrainPackages,meshUpdate=updateMeshBrainPackages,sourceUpdate=updateSourceBrainPackages,now=Date.now}={}) {
|
|
38
|
+
export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false,skipThrottle=false,intervalMs=60*60*1000,update=updateAllBrains,compile=maintainCliSurfaces,globalUpdate=updateGlobalBrainPackages,meshUpdate=updateMeshBrainPackages,sourceUpdate=updateSourceBrainPackages,pluginUpdate=updateInstalledPlugins,now=Date.now}={}) {
|
|
38
39
|
if(!force && !isPackageAutoUpdateEnabled())return {skipped:true,reason:'disabled',results:[]};
|
|
39
40
|
const file=path.join(brainDir,'memory-derived/automatic-brain-updates.json');
|
|
40
41
|
let saved;try{saved=JSON.parse(fs.readFileSync(file,'utf8'));}catch{}
|
|
@@ -56,10 +57,12 @@ export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false
|
|
|
56
57
|
const summary=await update({brainDir,dryRun:false,ensureDefaults:true});
|
|
57
58
|
const global=summary.versions?await globalUpdate({versions:summary.versions}):{failed:1,results:[],error:'Cannot maintain global packages without verified published versions'};
|
|
58
59
|
const sources=summary.versions?await sourceUpdate({brainDir,versions:summary.versions}):{failed:1,results:[]};
|
|
60
|
+
// The shared plugin folder follows the source each plugin was installed from (git, mesh peer, directory).
|
|
61
|
+
const plugins=await pluginUpdate({roots:[path.resolve(brainDir,'../../..')]});
|
|
59
62
|
const mesh=fleet && summary.versions?await meshUpdate({versions:summary.versions,version:summary.versions['total-recall-brain']}):{failed:0,results:[],scope:'local-only'};
|
|
60
|
-
const changed=summary.updated>0 || global.results.some(row=>row.status==='updated') || sources.results.some(row=>row.status==='updated') || JSON.stringify(saved?.versions)!==JSON.stringify(summary.versions);
|
|
63
|
+
const changed=summary.updated>0 || plugins.updated>0 || global.results.some(row=>row.status==='updated') || sources.results.some(row=>row.status==='updated') || JSON.stringify(saved?.versions)!==JSON.stringify(summary.versions);
|
|
61
64
|
const surfaces=await compile({brainDir,force:changed});
|
|
62
|
-
report={...summary,global,sources,mesh,surfaces,failed:(summary.failed || 0)+global.failed+sources.failed+mesh.failed+(surfaces.ok?0:surfaces.results.filter(row=>row.status==='failed').length),checkedAt:now()};
|
|
65
|
+
report={...summary,global,sources,plugins,mesh,surfaces,failed:(summary.failed || 0)+global.failed+sources.failed+plugins.failed+mesh.failed+(surfaces.ok?0:surfaces.results.filter(row=>row.status==='failed').length),checkedAt:now()};
|
|
63
66
|
}catch(error){report={failed:1,error:error.message,checkedAt:now(),results:[]};}
|
|
64
67
|
const temporary=`${file}.${process.pid}.tmp`;fs.writeFileSync(temporary,JSON.stringify(report,null,2));fs.renameSync(temporary,file);
|
|
65
68
|
return report;
|
|
@@ -40,9 +40,26 @@ function readJson(file, fallback) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Authorization consumers must distinguish missing metadata from corruption. */
|
|
44
|
+
function readStrictJson(file, fallback) {
|
|
45
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
46
|
+
catch (err) { if (err.code === 'ENOENT') return fallback; throw new Error('Brain registry metadata is unavailable'); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readRegisteredProjects(globalBrainDir = globalBrainDirFor()) {
|
|
50
|
+
const rows = readStrictJson(registryPath(globalBrainDir), []);
|
|
51
|
+
if (!Array.isArray(rows) || rows.some(row => !row || typeof row !== 'object' || Array.isArray(row))) {
|
|
52
|
+
throw new Error('Invalid project registry');
|
|
53
|
+
}
|
|
54
|
+
return rows;
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
/** `{ [resolvedBrainDir]: false }` for every brain switched off. */
|
|
44
|
-
export function readBrainToggles(globalBrainDir = globalBrainDirFor()) {
|
|
45
|
-
const data = readJson(togglesPath(globalBrainDir), {});
|
|
58
|
+
export function readBrainToggles(globalBrainDir = globalBrainDirFor(), { strict = false } = {}) {
|
|
59
|
+
const data = strict ? readStrictJson(togglesPath(globalBrainDir), {}) : readJson(togglesPath(globalBrainDir), {});
|
|
60
|
+
if (strict && (!data || typeof data !== 'object' || Array.isArray(data) || Object.values(data).some(v => typeof v !== 'boolean'))) {
|
|
61
|
+
throw new Error('Invalid brain toggles');
|
|
62
|
+
}
|
|
46
63
|
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
47
64
|
}
|
|
48
65
|
|
package/src/core/brain-state.mjs
CHANGED
|
@@ -43,9 +43,32 @@ export const BRAIN_STATE = load();
|
|
|
43
43
|
* @param {string} relPath e.g. "references/api-reference.md"
|
|
44
44
|
* @returns {boolean}
|
|
45
45
|
*/
|
|
46
|
+
/**
|
|
47
|
+
* Bundled plugin SOURCE inside the shipped scaffold is a template, not state.
|
|
48
|
+
*
|
|
49
|
+
* `plugins/` is state because an installed plugin directory is one brain's own
|
|
50
|
+
* collection. But the scaffold ships bundled plugin source at
|
|
51
|
+
* `scaffold/.agent/skills/total-recall/plugins/<id>/`, and `.gitignore` states
|
|
52
|
+
* plainly that it must stay in git. Matching the `plugins` segment anywhere in
|
|
53
|
+
* the path therefore flagged the template itself and failed the release gate.
|
|
54
|
+
* The exemption is anchored to the scaffold's own brain path, so an installed
|
|
55
|
+
* or brain-local `plugins/` directory is still state.
|
|
56
|
+
*/
|
|
57
|
+
function isScaffoldBundledPluginSource(parts) {
|
|
58
|
+
const at = parts.lastIndexOf('scaffold');
|
|
59
|
+
if (at === -1) return false;
|
|
60
|
+
const rest = parts.slice(at + 1);
|
|
61
|
+
return rest[0] === '.agent'
|
|
62
|
+
&& rest[1] === 'skills'
|
|
63
|
+
&& rest[2] === 'total-recall'
|
|
64
|
+
&& rest[3] === 'plugins'
|
|
65
|
+
&& rest.length > 4;
|
|
66
|
+
}
|
|
67
|
+
|
|
46
68
|
export function isBrainState(relPath) {
|
|
47
69
|
if (!relPath) return false;
|
|
48
70
|
const parts = String(relPath).split(/[\\/]+/).filter(Boolean);
|
|
71
|
+
if (isScaffoldBundledPluginSource(parts)) return false;
|
|
49
72
|
if (parts.some((seg) => BRAIN_STATE.dirs.has(seg))) return true;
|
|
50
73
|
const base = parts[parts.length - 1];
|
|
51
74
|
if (!base) return false;
|
|
@@ -29,7 +29,7 @@ export async function maintainCliSurfaces({brainDir,brains,force=false,run=runBo
|
|
|
29
29
|
const current=fingerprint(root);
|
|
30
30
|
if(!force && previous?.fingerprint===current){results.push({root,status:'current'});continue;}
|
|
31
31
|
const result=await run(process.execPath,[path.join(PACKAGE_ROOT,'bin/total-recall.mjs'),'plugin','refresh',...(brain.kind==='global'?['--global']:[])],{cwd:root,timeoutMs:600000,maxBytes:262144});
|
|
32
|
-
if(result.code!==0){results.push({root,status:'failed',error:result.reason || result.output?.slice(-1000) ||
|
|
32
|
+
if(result.code!==0){results.push({root,status:'failed',error:result.reason || (result.stderr || result.output)?.trim().slice(-1000) || `CLI compilation failed (exit ${result.code})`});continue;}
|
|
33
33
|
fs.mkdirSync(path.dirname(file),{recursive:true});
|
|
34
34
|
const temp=`${file}.${process.pid}.tmp`;fs.writeFileSync(temp,JSON.stringify({fingerprint:current,compiledAt:new Date().toISOString()}));fs.renameSync(temp,file);
|
|
35
35
|
results.push({root,status:'refreshed'});
|
|
@@ -30,7 +30,7 @@ const CORE_METADATA = {
|
|
|
30
30
|
effect: 'read'
|
|
31
31
|
},
|
|
32
32
|
init: {
|
|
33
|
-
description: 'Bootstrap global or project brain
|
|
33
|
+
description: 'Bootstrap a portable global or project memory brain.',
|
|
34
34
|
usage: 'total-recall init [--project]',
|
|
35
35
|
effect: 'write'
|
|
36
36
|
},
|
|
@@ -16,10 +16,10 @@ const NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
|
|
16
16
|
const MAX_DESCRIPTION = 180;
|
|
17
17
|
|
|
18
18
|
/** Static scope metadata shared by catalogs and instruction projections. */
|
|
19
|
-
export function commandScopeDirectories(projectRoot, agentDir) {
|
|
20
|
-
const dirs = [{ scope: 'project', dir: path.join(
|
|
19
|
+
export function commandScopeDirectories(projectRoot, agentDir, { projectAgentDir = path.join(projectRoot, '.agent') } = {}) {
|
|
20
|
+
const dirs = projectAgentDir ? [{ scope: 'project', dir: path.join(projectAgentDir, 'commands') }] : [];
|
|
21
21
|
let projectId;
|
|
22
|
-
try { projectId = JSON.parse(fs.readFileSync(path.join(
|
|
22
|
+
try { if (projectAgentDir) projectId = JSON.parse(fs.readFileSync(path.join(projectAgentDir, 'skills/total-recall/config/brain.json'), 'utf8')).project_id; } catch {}
|
|
23
23
|
const groups = path.join(agentDir, 'skills/total-recall/groups');
|
|
24
24
|
if (typeof projectId === 'string' && projectId) {
|
|
25
25
|
let files = [];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { globalAgentDir, resolveBrainLayer } from './config.mjs';
|
|
4
|
+
import { commandScopeDirectories } from './command-surface.mjs';
|
|
5
|
+
|
|
6
|
+
/** Execution and invoke discovery use the same selected brain as the CLI. */
|
|
7
|
+
export function composedCommandDirectories(target = process.cwd()) {
|
|
8
|
+
let projectAgentDir = null;
|
|
9
|
+
try { projectAgentDir = resolveBrainLayer('project', undefined, target).agentDir; } catch {}
|
|
10
|
+
return commandScopeDirectories(target, globalAgentDir, { projectAgentDir });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Resolve statically; importing a candidate is an execution boundary. */
|
|
14
|
+
export function resolveComposedCommand(name, dirs = composedCommandDirectories()) {
|
|
15
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(name)) return null;
|
|
16
|
+
for (const { dir } of dirs) {
|
|
17
|
+
const file = path.join(dir, `${name}.mjs`);
|
|
18
|
+
try { if (fs.statSync(file).isFile()) return file; } catch {}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
package/src/core/config.mjs
CHANGED
|
@@ -257,8 +257,27 @@ export function detectProjectBrain(startDir = process.cwd()) {
|
|
|
257
257
|
|
|
258
258
|
let dir = startDir;
|
|
259
259
|
const homeDir = os.homedir();
|
|
260
|
+
// Resolved live rather than from the module constant above: the agent dir can
|
|
261
|
+
// be overridden per process, and the guard must follow the folder actually in
|
|
262
|
+
// use.
|
|
263
|
+
const agentDirLive = process.env.AGENT_DIR || process.env._TR_TEST_AGENT_DIR || path.join(homeDir, '.agent');
|
|
264
|
+
const pluginsRoot = path.join(agentDirLive, 'skills', 'total-recall', 'plugins');
|
|
265
|
+
// A shared plugin is either a directory inside the shared folder or a symlink
|
|
266
|
+
// to its owner checkout. Working inside one — directly or through the link —
|
|
267
|
+
// must never be treated as working in a project, or the plugin provisions a
|
|
268
|
+
// complete nested brain into itself, one level deeper on every run. The owner
|
|
269
|
+
// checkout's TOP LEVEL is the one exception: that is the plugin's repository,
|
|
270
|
+
// registered and edited in its own right.
|
|
271
|
+
const targets = pluginTargets(pluginsRoot);
|
|
272
|
+
const reachedThroughPlugin = isInsideSharedFolder(startDir, pluginsRoot, targets);
|
|
260
273
|
while (dir !== path.dirname(dir)) {
|
|
261
274
|
if (dir === homeDir) break; // don't detect global as project
|
|
275
|
+
if (dir === pluginsRoot || dir.startsWith(pluginsRoot + path.sep)) break;
|
|
276
|
+
// A plugin's own checkout stops the walk unless it is a registered project.
|
|
277
|
+
// The shared folder links every plugin to its owner repository, and those
|
|
278
|
+
// repositories are registered and worked in directly, so reaching one is
|
|
279
|
+
// only "a project" when the registry says it is.
|
|
280
|
+
if (reachedThroughPlugin && targets.has(fs.existsSync(dir) ? safeRealpath(dir) : dir) && !isRegisteredProject(dir)) break;
|
|
262
281
|
// Both `.agent` (canonical) and `.agents` (the upstream harness convention)
|
|
263
282
|
// are supported. This function used to check only `.agent` while its former
|
|
264
283
|
// sibling in src/cli/agent-dir.mjs checked both, so the two could disagree
|
|
@@ -283,6 +302,71 @@ export function detectProjectBrain(startDir = process.cwd()) {
|
|
|
283
302
|
return null;
|
|
284
303
|
}
|
|
285
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Whether a starting path belongs to a shared plugin rather than to a repository
|
|
307
|
+
* in its own right.
|
|
308
|
+
*
|
|
309
|
+
* True when the caller's path lies beneath the shared folder literally, or when
|
|
310
|
+
* it resolves into one of the folder's link targets — including the target root,
|
|
311
|
+
* because a plugin that resolved its own directory asks from exactly there. The
|
|
312
|
+
* registry is what separates that plugin's own repository from a plugin install;
|
|
313
|
+
* this function only reports where the caller came from.
|
|
314
|
+
*/
|
|
315
|
+
function isInsideSharedFolder(startDir, pluginsRoot, targets) {
|
|
316
|
+
const candidates = [startDir, safeRealpath(startDir)].filter(Boolean);
|
|
317
|
+
for (const candidate of candidates) {
|
|
318
|
+
if (candidate === pluginsRoot || candidate.startsWith(pluginsRoot + path.sep)) return true;
|
|
319
|
+
for (const target of targets) {
|
|
320
|
+
if (candidate === target || candidate.startsWith(target + path.sep)) return true;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Whether this directory is a project the operator registered, read from the
|
|
328
|
+
* brain registry. A registered repository is a real project even when it is also
|
|
329
|
+
* installed as a shared plugin; anything else that is only reachable as a plugin
|
|
330
|
+
* is a plugin. Unreadable registry metadata is treated as "not registered",
|
|
331
|
+
* which keeps provisioning safe.
|
|
332
|
+
*/
|
|
333
|
+
function isRegisteredProject(dir) {
|
|
334
|
+
try {
|
|
335
|
+
const registry = path.join(
|
|
336
|
+
process.env.AGENT_DIR || process.env._TR_TEST_AGENT_DIR || path.join(os.homedir(), '.agent'),
|
|
337
|
+
'skills', 'total-recall', 'config', 'project-registry.json'
|
|
338
|
+
);
|
|
339
|
+
const rows = JSON.parse(fs.readFileSync(registry, 'utf8'));
|
|
340
|
+
if (!Array.isArray(rows)) return false;
|
|
341
|
+
const wanted = fs.existsSync(dir) ? safeRealpath(dir) : path.resolve(dir);
|
|
342
|
+
return rows.some((row) => {
|
|
343
|
+
const candidate = row?.path || row?.root;
|
|
344
|
+
if (typeof candidate !== 'string' || !candidate) return false;
|
|
345
|
+
return (fs.existsSync(candidate) ? safeRealpath(candidate) : path.resolve(candidate)) === wanted;
|
|
346
|
+
});
|
|
347
|
+
} catch { return false; }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function safeRealpath(target) {
|
|
351
|
+
try { return fs.realpathSync(target); } catch { return null; }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The real directories behind the shared plugin folder. Each entry is normally a
|
|
356
|
+
* symlink to the plugin's owner checkout, which is where a nested brain would
|
|
357
|
+
* actually be written once the link is resolved.
|
|
358
|
+
*/
|
|
359
|
+
function pluginTargets(pluginsRoot) {
|
|
360
|
+
const targets = new Set();
|
|
361
|
+
let entries = [];
|
|
362
|
+
try { entries = fs.readdirSync(pluginsRoot, { withFileTypes: true }); } catch { return targets; }
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
if (!entry.isSymbolicLink()) continue;
|
|
365
|
+
try { targets.add(fs.realpathSync(path.join(pluginsRoot, entry.name))); } catch { /* broken link */ }
|
|
366
|
+
}
|
|
367
|
+
return targets;
|
|
368
|
+
}
|
|
369
|
+
|
|
286
370
|
/**
|
|
287
371
|
* Get both brain layers for the current context.
|
|
288
372
|
*
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Trusted memory-owner adapter. No CLI, configuration, auth or plugin imports. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import matter from './frontmatter.mjs';
|
|
6
|
+
import { processOperationAsync } from './operation-validator.mjs';
|
|
7
|
+
import { buildLocalSearchIndex } from './fast-recall.mjs';
|
|
8
|
+
|
|
9
|
+
export function validateConfinedFact(params) {
|
|
10
|
+
if (!params || typeof params !== 'object' || Array.isArray(params)) throw new Error('Invalid fact');
|
|
11
|
+
const allowed = new Set(['category', 'title', 'content', 'tags', 'target', 'timeoutMs']);
|
|
12
|
+
if (Object.keys(params).some(k => !allowed.has(k))) throw new Error('Unknown fact field');
|
|
13
|
+
if (params.category !== undefined && params.category !== 'fact') throw new Error('Only facts are supported');
|
|
14
|
+
if (params.target !== undefined && params.target !== 'server') throw new Error('Invalid target');
|
|
15
|
+
if (typeof params.content !== 'string' || !params.content.trim() || Buffer.byteLength(params.content) > 12000) throw new Error('Invalid content');
|
|
16
|
+
if (params.title !== undefined && (typeof params.title !== 'string' || !params.title.trim() || params.title.length > 200)) throw new Error('Invalid title');
|
|
17
|
+
if (params.tags !== undefined && (!Array.isArray(params.tags) || params.tags.length > 16 ||
|
|
18
|
+
params.tags.some(t => typeof t !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(t) || ['universal', 'absolute'].includes(t)))) throw new Error('Invalid tags');
|
|
19
|
+
if (params.timeoutMs !== undefined && (typeof params.timeoutMs !== 'number' || !Number.isFinite(params.timeoutMs) || params.timeoutMs <= 0)) throw new Error('Invalid timeout');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Resolve an operator-selected brain once; reject links below that boundary. */
|
|
23
|
+
function checkTree(dir) {
|
|
24
|
+
if (!fs.existsSync(dir)) {
|
|
25
|
+
// existsSync follows links, so dangling links must still be refused.
|
|
26
|
+
try { if (fs.lstatSync(dir).isSymbolicLink()) throw new Error('Unsafe path'); }
|
|
27
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const stat = fs.lstatSync(dir);
|
|
31
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() && !stat.isFile()) throw new Error('Unsafe path');
|
|
32
|
+
if (stat.isDirectory()) for (const name of fs.readdirSync(dir)) checkTree(path.join(dir, name));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readMemoryNodes(vault) {
|
|
36
|
+
const nodes = [];
|
|
37
|
+
function walk(dir) {
|
|
38
|
+
if (!fs.existsSync(dir)) return;
|
|
39
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
40
|
+
if (entry.name.startsWith('.') || entry.name === 'proposals') continue;
|
|
41
|
+
const file = path.join(dir, entry.name);
|
|
42
|
+
if (entry.isSymbolicLink()) throw new Error('Unsafe vault path');
|
|
43
|
+
if (entry.isDirectory()) walk(file);
|
|
44
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
45
|
+
const { data, content } = matter(fs.readFileSync(file, 'utf8'));
|
|
46
|
+
if (data.type === 'memory') nodes.push({ ...data, body: content, _filePath: file });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
walk(vault);
|
|
51
|
+
return nodes;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Create-only SSSS fact; rebuilding the local projection never runs generators. */
|
|
55
|
+
export async function createConfinedFact(brain, params, caller = 'local-operator') {
|
|
56
|
+
validateConfinedFact(params);
|
|
57
|
+
if (typeof caller !== 'string' || !caller || caller.length > 200) throw new Error('Invalid caller');
|
|
58
|
+
const base = fs.realpathSync(brain);
|
|
59
|
+
const vault = path.join(base, 'memory-vault'), derived = path.join(base, 'memory-derived');
|
|
60
|
+
checkTree(vault); checkTree(derived);
|
|
61
|
+
const slug = `fact-${crypto.randomUUID()}`, relative = `facts/${slug}.md`;
|
|
62
|
+
const title = params.title || params.content.trim().split('\n')[0].slice(0, 160);
|
|
63
|
+
const now = new Date().toISOString();
|
|
64
|
+
const data = { type: 'memory', slug, category: 'facts', title, description: title, timestamp: now,
|
|
65
|
+
schema_version: 2, status: 'active', confidence: 1, importance: 3, modality: 'descriptive',
|
|
66
|
+
created: now, updated: now, last_accessed: now, subject: 'agent', predicate: 'remembers_fact', object: slug,
|
|
67
|
+
sentiment_polarity: 'descriptive', sentiment_target: 'memory', tags: [...new Set(['invoke', ...(params.tags || [])])],
|
|
68
|
+
source: { type: 'confined-memory', session_id: 'confined-memory', agent: caller, evidence_count: 1 },
|
|
69
|
+
x_temporal_context: now };
|
|
70
|
+
// No overwrite grant and no caller-selected path, metadata, role or source.
|
|
71
|
+
const principal = { id: caller, kind: 'agent', workspaceIds: ['confined-memory'],
|
|
72
|
+
capabilities: ['memory:create', 'ssss:memory:create'],
|
|
73
|
+
authentication: { provider: 'total-recall-confined-memory', assurance: 'verified' } };
|
|
74
|
+
const result = { category: 'fact', path: `memory-vault/${relative}`, slug, committed: false, indexed: false };
|
|
75
|
+
try {
|
|
76
|
+
const written = await processOperationAsync({ type: 'operation', idempotency_key: crypto.randomUUID(),
|
|
77
|
+
path: relative, workspace_id: 'confined-memory', content: matter.stringify(params.content, data),
|
|
78
|
+
actor: { role: 'confined-memory' } }, vault, { agentRole: 'confined-memory', principal });
|
|
79
|
+
if (!written.success) throw new Error('Canonical write failed');
|
|
80
|
+
result.committed = true;
|
|
81
|
+
result.operation_id = written.operation_id;
|
|
82
|
+
buildLocalSearchIndex(readMemoryNodes(vault), { vaultDir: vault, derivedDir: derived });
|
|
83
|
+
result.indexed = true;
|
|
84
|
+
return result;
|
|
85
|
+
} catch (cause) {
|
|
86
|
+
// Kernel rolls back failed canonical event commits. A later host-audit or
|
|
87
|
+
// index error may leave the fact committed; never advertise safe blind retry.
|
|
88
|
+
result.committed = fs.existsSync(path.join(vault, relative));
|
|
89
|
+
const err = new Error(result.committed ? 'Fact committed; local indexing incomplete' : 'Fact creation failed', { cause });
|
|
90
|
+
err.result = result;
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|