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.
Files changed (172) hide show
  1. package/bin/total-recall-memory.mjs +0 -0
  2. package/bin/total-recall.mjs +9 -40
  3. package/docs/reference/cli-reference.md +65 -1
  4. package/frontend/dist/assets/{index-AGUes-VG.css → index-BG9NkVUy.css} +1 -1
  5. package/frontend/dist/assets/{index-Bt5iVPZj.js → index-BtuiZqwP.js} +20 -7
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/src/components/plugins/PluginPanel.tsx +31 -5
  8. package/frontend/src/components/plugins/card/CardEngine.tsx +15 -23
  9. package/frontend/src/components/plugins/card/CardScenes.tsx +52 -23
  10. package/frontend/src/components/plugins/card/card.css +2 -5
  11. package/frontend/src/components/plugins/card/contract.ts +13 -19
  12. package/frontend/src/components/plugins/card/format.ts +8 -1
  13. package/frontend/src/components/plugins/card/widgets.tsx +4 -35
  14. package/frontend/src/components/plugins/plugin-frame.ts +30 -3
  15. package/frontend/src/pages/PluginsPage.tsx +3 -2
  16. package/package.json +1 -1
  17. package/scaffold/.agent/skills/meta-harness/references/optimized/original.md +1 -1
  18. package/scaffold/.agent/skills/plugin/SKILL.md +110 -0
  19. package/scaffold/.agent/skills/plugin/evals/README.md +53 -0
  20. package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +42 -0
  21. package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +39 -0
  22. package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +32 -0
  23. package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +46 -0
  24. package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +55 -0
  25. package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +92 -0
  26. package/scaffold/.agent/skills/plugin/evals/run.mjs +118 -0
  27. package/scaffold/.agent/skills/plugin/prompts/01-function.md +29 -0
  28. package/scaffold/.agent/skills/plugin/prompts/02-surfaces.md +43 -0
  29. package/scaffold/.agent/skills/plugin/prompts/03-scaffold.md +31 -0
  30. package/scaffold/.agent/skills/plugin/prompts/04-cli.md +49 -0
  31. package/scaffold/.agent/skills/plugin/prompts/05-credentials.md +41 -0
  32. package/scaffold/.agent/skills/plugin/prompts/06-card.md +46 -0
  33. package/scaffold/.agent/skills/plugin/prompts/07-scaffold.md +52 -0
  34. package/scaffold/.agent/skills/plugin/prompts/08-verify.md +43 -0
  35. package/scaffold/.agent/skills/plugin/references/anti-patterns.md +123 -0
  36. package/scaffold/.agent/skills/plugin/references/card.md +90 -0
  37. package/scaffold/.agent/skills/plugin/references/scaffold.md +120 -0
  38. package/scaffold/.agent/skills/plugin/references/surfaces.md +206 -0
  39. package/scaffold/.agent/skills/plugin/scripts/record.mjs +360 -0
  40. package/scaffold/.agent/skills/plugin/subagents/card-designer.md +60 -0
  41. package/scaffold/.agent/skills/plugin/subagents/plugin-reviewer.md +55 -0
  42. package/scaffold/.agent/skills/plugin/subagents/scaffold-extractor.md +71 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +71 -1441
  44. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +7 -5
  45. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +3 -44
  46. package/scaffold/.agent/skills/total-recall/plugins/auth/scripts/agent-login-pty.py +185 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +20 -1
  48. package/scaffold/.agent/skills/total-recall/plugins/auth/src/host-secrets-store.mjs +15 -0
  49. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +6 -4
  50. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +1 -0
  51. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -24
  52. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  53. package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +0 -21
  54. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  55. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +0 -32
  56. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  57. package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +11 -0
  58. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +5 -2
  59. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
  60. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -28
  61. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/DESIGN.md +95 -13
  62. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +553 -340
  63. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +0 -36
  64. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +383 -400
  65. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4 -4
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +0 -1
  67. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +0 -28
  68. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
  69. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +0 -32
  70. package/scaffold/.agent/skills/total-recall/references/auth.md +3 -1
  71. package/scaffold/.agent/skills/total-recall/references/memory.md +4 -0
  72. package/scaffold/.agent/skills/total-recall/references/openwiki.md +2 -0
  73. package/scaffold/.agent/skills/total-recall/references/operations.md +12 -0
  74. package/src/cli/brain.mjs +72 -2
  75. package/src/cli/connect.mjs +10 -39
  76. package/src/cli/context-stats.mjs +104 -0
  77. package/src/cli/context.mjs +8 -0
  78. package/src/cli/daemon.mjs +10 -1
  79. package/src/cli/export.mjs +3 -2
  80. package/src/cli/ingest-okf.mjs +12 -15
  81. package/src/cli/init.mjs +0 -85
  82. package/src/cli/lint.mjs +3 -2
  83. package/src/cli/plugin/copies.mjs +68 -0
  84. package/src/cli/plugin/create.mjs +22 -24
  85. package/src/cli/plugin/index.mjs +49 -3
  86. package/src/cli/plugin/search.mjs +19 -2
  87. package/src/cli/rebuild.mjs +2 -0
  88. package/src/cli/remember.mjs +23 -0
  89. package/src/cli/status.mjs +10 -3
  90. package/src/cli/update.mjs +3 -2
  91. package/src/core/action-predictor.mjs +161 -0
  92. package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +20 -3
  93. package/src/core/app-deploy/source.mjs +16 -9
  94. package/src/core/automatic-brain-updates.mjs +6 -3
  95. package/src/core/brain-registry.mjs +19 -2
  96. package/src/core/brain-state.mjs +23 -0
  97. package/src/core/cli-commands.mjs +1 -0
  98. package/src/core/cli-maintenance.mjs +1 -1
  99. package/src/core/cli-semantic-index.mjs +1 -1
  100. package/src/core/command-surface.mjs +3 -3
  101. package/src/core/composed-command-resolution.mjs +21 -0
  102. package/src/core/config.mjs +84 -0
  103. package/src/core/confined-memory.mjs +93 -0
  104. package/src/core/context-telemetry.mjs +214 -0
  105. package/src/core/daemon-control.mjs +14 -43
  106. package/src/core/fast-recall.mjs +1 -1
  107. package/src/core/memory-brain.mjs +2 -0
  108. package/src/core/plugin-activation.mjs +38 -6
  109. package/src/core/plugin-bundle.mjs +54 -0
  110. package/src/core/plugin-catalog.mjs +1 -1
  111. package/src/core/plugin-context.mjs +1 -1
  112. package/src/core/plugin-contracts/card.mjs +69 -37
  113. package/src/core/plugin-loader.mjs +5 -6
  114. package/src/core/plugin-runner.mjs +13 -6
  115. package/src/core/plugin-runtime.mjs +5 -4
  116. package/src/core/plugin-setup.mjs +5 -3
  117. package/src/core/plugin-state-store.mjs +12 -1
  118. package/src/core/plugin-state.mjs +46 -5
  119. package/src/core/plugin-store.mjs +78 -3
  120. package/src/core/plugin-tasks.mjs +3 -1
  121. package/src/core/plugin-updates.mjs +117 -0
  122. package/src/core/plugin-vendor.mjs +10 -4
  123. package/src/core/project-brain.mjs +14 -41
  124. package/src/core/project-id.mjs +4 -1
  125. package/src/core/required-plugins.mjs +151 -13
  126. package/src/core/skills-registry.mjs +27 -3
  127. package/src/core/ssss-kernel-bridge.mjs +3 -1
  128. package/src/core/startup-health.mjs +66 -16
  129. package/src/core/surface.mjs +53 -46
  130. package/src/core/testing/plugin-fixture.mjs +69 -0
  131. package/src/core/vault-files.mjs +13 -0
  132. package/src/core/vault.mjs +4 -30
  133. package/src/server/index.mjs +13 -1
  134. package/src/server/invoke-decision-child.mjs +49 -0
  135. package/src/server/invoke-execution.mjs +87 -0
  136. package/src/server/invoke-policy.mjs +64 -0
  137. package/src/server/invoke-read-child.mjs +26 -0
  138. package/src/server/invoke-targets.mjs +49 -0
  139. package/src/server/invoke-write-child.mjs +19 -0
  140. package/src/server/routes/invoke.mjs +314 -0
  141. package/src/server/routes/plugins.mjs +1 -1
  142. package/scaffold/.agent/skills/plugins/SKILL.md +0 -57
  143. package/scaffold/.agent/skills/plugins/references/card-design.md +0 -55
  144. package/scaffold/.agent/skills/plugins/references/distribution.md +0 -23
  145. package/scaffold/.agent/skills/plugins/references/manifest-contract.md +0 -90
  146. package/scaffold/.agent/skills/plugins/references/optimized/index.md +0 -9
  147. package/scaffold/.agent/skills/plugins/references/optimized/manifest.json +0 -35
  148. package/scaffold/.agent/skills/plugins/references/optimized/original.md +0 -58
  149. package/scaffold/.agent/skills/plugins/references/optimized/section-01.md +0 -5
  150. package/scaffold/.agent/skills/plugins/references/optimized/section-02.md +0 -25
  151. package/scaffold/.agent/skills/plugins/references/optimized/section-03.md +0 -12
  152. package/scaffold/.agent/skills/plugins/references/optimized/section-04.md +0 -7
  153. package/scaffold/.agent/skills/plugins/references/optimized/section-05.md +0 -5
  154. package/scaffold/.agent/skills/total-recall/openwiki/README.md +0 -10
  155. package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +0 -5
  156. package/scaffold/.agent/skills/total-recall/openwiki/ide.md +0 -10
  157. package/scaffold/.agent/skills/total-recall/openwiki/memory.md +0 -11
  158. package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +0 -7
  159. package/scaffold/.agent/skills/total-recall/openwiki/skills.md +0 -7
  160. package/scaffold/openwiki/README.md +0 -10
  161. package/scaffold/openwiki/architecture.md +0 -5
  162. package/scaffold/openwiki/ide.md +0 -10
  163. package/scaffold/openwiki/memory.md +0 -11
  164. package/scaffold/openwiki/secrets.md +0 -7
  165. package/scaffold/openwiki/skills.md +0 -7
  166. package/src/core/okf-adapter.mjs +0 -559
  167. package/templates/openwiki/README.md +0 -10
  168. package/templates/openwiki/architecture.md +0 -5
  169. package/templates/openwiki/ide.md +0 -10
  170. package/templates/openwiki/memory.md +0 -11
  171. package/templates/openwiki/secrets.md +0 -7
  172. 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
- const installed = getPlugin(source, options.projectRoot || process.cwd());
248
- if (installed && fs.existsSync(installed.dir)) {
249
- sourceType = installed.scope === 'bundled' ? 'bundled' : 'local';
250
- candidateDir = installed.dir;
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 bundledDir = path.join(bundledPluginsDir(), source);
253
- if (fs.existsSync(bundledDir)) {
254
- sourceType = 'bundled';
255
- candidateDir = bundledDir;
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
 
@@ -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;
@@ -22,6 +22,7 @@ export const CLI_COMMANDS = {
22
22
  edit: 'edit.mjs',
23
23
  recall: 'recall.mjs',
24
24
  context: 'context.mjs',
25
+ 'context-stats': 'context-stats.mjs',
25
26
  rules: 'rules.mjs',
26
27
  search: 'recall.mjs',
27
28
  research: 'research.mjs',
@@ -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) || 'CLI compilation failed'});continue;}
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 and openwiki documentation.',
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(projectRoot, '.agent', 'commands') }];
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(projectRoot, '.agent/skills/total-recall/config/brain.json'), 'utf8')).project_id; } catch {}
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
+ }
@@ -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
+ }