@joenandez/academy 0.4.0 → 0.5.12
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 +5 -0
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +390 -0
- package/README.md +67 -122
- package/hooks/backstory/.claude-plugin/plugin.json +11 -0
- package/hooks/backstory/SOURCE +2 -0
- package/hooks/backstory/bin/backstory +9 -0
- package/hooks/backstory/hooks/hooks.json +45 -0
- package/hooks/backstory/scripts/knowledge.mjs +87 -0
- package/hooks/backstory/scripts/memory.mjs +685 -0
- package/hooks/backstory/scripts/tool-tick.mjs +1 -0
- package/hooks/backstory/skills/capture/SKILL.md +71 -0
- package/hooks/backstory/skills/capture/references/knowledge-capture-input.json +16 -0
- package/hooks/backstory/skills/capture/references/tagging-policy.md +29 -0
- package/hooks/backstory/skills/memory/SKILL.md +70 -0
- package/hooks/backstory/skills/setup/SKILL.md +85 -0
- package/hooks/context_metrics.mjs +67 -0
- package/hooks/hook_runtime.mjs +43 -2
- package/hooks/memory_bridge.mjs +286 -52
- package/hooks/memory_store.mjs +78 -26
- package/hooks/register_session.mjs +76 -14
- package/hooks/roque/.claude-plugin/plugin.json +6 -0
- package/hooks/roque/LICENSE +21 -0
- package/hooks/roque/SOURCE +1 -0
- package/hooks/roque/hooks.json +38 -0
- package/hooks/roque/package.json +3 -0
- package/hooks/roque/roque.js +1690 -0
- package/hooks/sync_memory.mjs +11 -7
- package/package.json +7 -3
- package/scripts/cli/agent-gate.mjs +70 -0
- package/scripts/cli/agent-id.mjs +37 -0
- package/scripts/cli/archive.mjs +39 -38
- package/scripts/cli/archived.mjs +4 -2
- package/scripts/cli/args-kinds.mjs +33 -0
- package/scripts/cli/args-notes.mjs +42 -0
- package/scripts/cli/args-owner.mjs +37 -0
- package/scripts/cli/args-run.mjs +38 -0
- package/scripts/cli/args.mjs +47 -104
- package/scripts/cli/beads.mjs +57 -0
- package/scripts/cli/codex.mjs +66 -12
- package/scripts/cli/core.mjs +117 -41
- package/scripts/cli/create.mjs +224 -76
- package/scripts/cli/curator-feed-codex.mjs +81 -0
- package/scripts/cli/curator-feed-observations.mjs +97 -0
- package/scripts/cli/curator-feed-scope.mjs +124 -0
- package/scripts/cli/curator-feed-sources.mjs +172 -0
- package/scripts/cli/curator-feed.mjs +318 -0
- package/scripts/cli/default-name.mjs +73 -0
- package/scripts/cli/doctor.mjs +67 -2
- package/scripts/cli/eventlog.mjs +6 -2
- package/scripts/cli/exit-code.mjs +9 -0
- package/scripts/cli/first-run.mjs +115 -0
- package/scripts/cli/heartbeat.mjs +135 -0
- package/scripts/cli/helm-skip.mjs +20 -0
- package/scripts/cli/hire-headless.mjs +37 -22
- package/scripts/cli/hire.mjs +8 -2
- package/scripts/cli/inspect.mjs +36 -18
- package/scripts/cli/lifecycle.mjs +95 -49
- package/scripts/cli/main.mjs +32 -2
- package/scripts/cli/memory-env.mjs +124 -0
- package/scripts/cli/migrate.mjs +13 -7
- package/scripts/cli/next-steps.mjs +13 -0
- package/scripts/cli/nightly-launch.mjs +118 -0
- package/scripts/cli/nightly.mjs +228 -0
- package/scripts/cli/notes.mjs +58 -8
- package/scripts/cli/owner-config.mjs +181 -0
- package/scripts/cli/owner-jobs.mjs +201 -0
- package/scripts/cli/prompt-append.mjs +135 -0
- package/scripts/cli/rename.mjs +90 -109
- package/scripts/cli/run-context.mjs +50 -0
- package/scripts/cli/run.mjs +236 -79
- package/scripts/cli/scaffold.mjs +119 -126
- package/scripts/cli/schedule-errors.mjs +70 -0
- package/scripts/cli/sessions.mjs +10 -3
- package/scripts/cli/skills-check.mjs +147 -0
- package/scripts/cli/skills.mjs +170 -0
- package/scripts/cli/start.mjs +201 -0
- package/scripts/cli/templates.mjs +119 -45
- package/scripts/cli/tldr.mjs +184 -0
- package/scripts/cli/toml-string.mjs +76 -0
- package/scripts/cli/user-work-view.mjs +48 -0
- package/scripts/cli/writable-dirs.mjs +23 -0
- package/scripts/cli/yaml.mjs +1 -1
- package/skills/hire/SKILL.md +112 -100
- package/skills/hire/references/owner.md +68 -0
- package/templates/agents/claude-code/knowledge-curator.md +5 -2
- package/templates/agents/codex/knowledge-curator.toml +2 -1
- package/templates/operating-model/base.md +78 -0
- package/templates/operating-model/owner.md +11 -0
- package/templates/operating-model/principal.md +9 -0
- package/templates/operating-model/specialist.md +9 -0
- package/templates/packs/owner/craft.md +32 -0
- package/templates/packs/owner/identity.md +13 -0
- package/templates/packs/owner/mandate.md +26 -0
- package/templates/packs/owner/owner/SKILL.md +118 -0
- package/templates/packs/owner/owner-feedback/SKILL.md +49 -0
- package/templates/packs/owner/owner-heartbeat/SKILL.md +35 -0
- package/templates/packs/owner/owner-nightly/SKILL.md +114 -0
- package/templates/packs/owner/owner-review/SKILL.md +70 -0
- package/templates/packs/owner/owner-work/SKILL.md +53 -0
- package/templates/packs/principal/craft.md +27 -0
- package/templates/packs/principal/identity.md +13 -0
- package/templates/packs/principal/mandate.md +23 -0
- package/templates/packs/principal/principal-brief/SKILL.md +44 -0
- package/templates/packs/principal/principal-intro/SKILL.md +95 -0
- package/templates/packs/principal/principal-nightly/SKILL.md +152 -0
- package/templates/skills/check-in/SKILL.md +34 -24
- package/templates/skills/knowledge-curation/SKILL.md +89 -48
- package/templates/skills/local-rules/SKILL.md +86 -0
- package/templates/skills/nightly-consolidation/SKILL.md +131 -100
- package/templates/skills/skill-method/SKILL.md +79 -0
- package/templates/skills/user-nightly/SKILL.md +143 -0
- package/templates/skills/work-graph/SKILL.md +75 -0
- package/conformance/README.md +0 -60
- package/conformance/discovery.test.mjs +0 -140
- package/conformance/envelope.test.mjs +0 -185
- package/conformance/error-codes.test.mjs +0 -125
- package/conformance/harness.mjs +0 -180
- package/conformance/identity.test.mjs +0 -125
- package/docs/integration-guide.md +0 -1042
- package/templates/skills/self-update/SKILL.md +0 -121
package/hooks/memory_bridge.mjs
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
accessSync,
|
|
3
|
+
appendFileSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
2
10
|
import { homedir } from 'node:os';
|
|
3
11
|
import { join } from 'node:path';
|
|
12
|
+
import { withFileLock } from './hook_runtime.mjs';
|
|
4
13
|
|
|
5
14
|
/**
|
|
6
15
|
* Academy names no specific client except in this module. The memory sync
|
|
7
16
|
* bridge stays client-specific by explicit decision, so every client-named
|
|
8
17
|
* identifier Academy needs — env keys, tool permission, skill guidance, and the
|
|
9
18
|
* observation copy itself — lives here. Client-neutral layers import these
|
|
10
|
-
* declarations and inject them
|
|
19
|
+
* declarations and inject them on every agent. The bridge is always on; it
|
|
20
|
+
* copies nothing unless the client's own keys and observations directory exist.
|
|
21
|
+
* The module also holds the Backstory source: observations of recorded
|
|
22
|
+
* sessions, found by session id under the Backstory home.
|
|
11
23
|
*/
|
|
12
24
|
export const MEMORY_BRIDGE_ENV = {
|
|
13
25
|
enabled: 'GROVE_MEMORY_ENABLED',
|
|
@@ -34,48 +46,145 @@ optional. Skip this section when \`subspace-memory\` is not installed.
|
|
|
34
46
|
Subspace memory may enrich or corroborate an eligible run. It can never
|
|
35
47
|
establish eligibility.`;
|
|
36
48
|
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
// The Subspace wrapper is the only reader of grove.db. Academy hands it a
|
|
50
|
+
// workspace path and lets it resolve GROVE_* for the child, exactly as tldr's
|
|
51
|
+
// subspace_memory_launch.mjs does for scheduled jobs.
|
|
52
|
+
const MEMORY_WRAPPER = join('bin', 'helm-job-memory-wrapper');
|
|
53
|
+
const CLAUDE_SETTINGS_FILE = 'claude-code-subspace-settings.json';
|
|
54
|
+
const PANE_ENV_KEYS = ['GROVE_PANE_ID', 'SUBSPACE_PANE_ID'];
|
|
55
|
+
|
|
56
|
+
/** True when the client already resolved this process's memory workspace. */
|
|
57
|
+
export function bridgeEnvPresent(env) {
|
|
58
|
+
return Boolean(env[MEMORY_BRIDGE_ENV.project] && env[MEMORY_BRIDGE_ENV.workspace]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function bridgeHome(env) {
|
|
62
|
+
const explicit = String(env[MEMORY_BRIDGE_ENV.home] ?? '').trim();
|
|
63
|
+
return explicit || join(env.HOME || homedir(), '.subspace');
|
|
39
64
|
}
|
|
40
65
|
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
66
|
+
function isExecutable(path) {
|
|
67
|
+
try {
|
|
68
|
+
accessSync(path, constants.X_OK);
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The installed wrapper, or null when this machine has none. */
|
|
76
|
+
export function bridgeLauncher(env) {
|
|
77
|
+
const home = bridgeHome(env);
|
|
78
|
+
const wrapper = join(home, MEMORY_WRAPPER);
|
|
79
|
+
return isExecutable(wrapper) ? { home, wrapper } : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The memory Stop hook ships async; a one-shot run exits before an async hook
|
|
83
|
+
// writes, so it is forced synchronous (same transform as tldr).
|
|
84
|
+
function isAsyncMemoryStopHook(hook) {
|
|
85
|
+
const command = typeof hook?.command === 'string' ? hook.command : '';
|
|
86
|
+
return (
|
|
87
|
+
hook?.async === true &&
|
|
88
|
+
command.includes('subspace-memory') &&
|
|
89
|
+
command.includes('stop-lifecycle')
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function effectiveClaudeSettings(path) {
|
|
94
|
+
try {
|
|
95
|
+
const settings = JSON.parse(readFileSync(path, 'utf8'));
|
|
96
|
+
const hooks = (settings?.hooks?.Stop || []).flatMap((group) => group?.hooks || []);
|
|
97
|
+
const asyncHooks = hooks.filter(isAsyncMemoryStopHook);
|
|
98
|
+
for (const hook of asyncHooks) hook.async = false;
|
|
99
|
+
return asyncHooks.length > 0 ? JSON.stringify(settings) : path;
|
|
100
|
+
} catch {
|
|
101
|
+
return path;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Claude arguments that load the client's settings, or none when absent. */
|
|
106
|
+
export function bridgeClaudeSettingsArgs(launcher) {
|
|
107
|
+
const path = join(launcher.home, CLAUDE_SETTINGS_FILE);
|
|
108
|
+
if (!existsSync(path)) return [];
|
|
109
|
+
return ['--settings', effectiveClaudeSettings(path)];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const USER_MEMORY_HOOK = /subspace-memory|subspace-hooks\/|subspace-claude-/;
|
|
113
|
+
|
|
114
|
+
function userClaudeSettingsPaths(env) {
|
|
115
|
+
const paths = [join(env.HOME || homedir(), '.claude', 'settings.json')];
|
|
116
|
+
if (env.CLAUDE_CONFIG_DIR) paths.push(join(env.CLAUDE_CONFIG_DIR, 'settings.json'));
|
|
117
|
+
return paths;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function runsMemoryHooks(path) {
|
|
121
|
+
try {
|
|
122
|
+
const groups = Object.values(JSON.parse(readFileSync(path, 'utf8'))?.hooks ?? {}).flat();
|
|
123
|
+
return groups
|
|
124
|
+
.flatMap((group) => group?.hooks || [])
|
|
125
|
+
.some((hook) => USER_MEMORY_HOOK.test(String(hook?.command ?? '')));
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* True when the user's own Claude settings already run the client's memory
|
|
133
|
+
* hooks. Claude Code merges settings and de-duplicates hooks only on identical
|
|
134
|
+
* command text, so loading the client's settings file too would fire every
|
|
135
|
+
* memory hook twice.
|
|
136
|
+
*/
|
|
137
|
+
export function bridgeHooksInUserSettings(env) {
|
|
138
|
+
return userClaudeSettingsPaths(env).some(runsMemoryHooks);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The wrapped argv and env that start `command` with a memory workspace. */
|
|
142
|
+
export function bridgeLaunch(launcher, { command, args, env, workspaceCwd }) {
|
|
143
|
+
const unsetPane = PANE_ENV_KEYS.flatMap((key) => ['-u', key]);
|
|
144
|
+
return {
|
|
145
|
+
command: launcher.wrapper,
|
|
146
|
+
args: ['/usr/bin/env', ...unsetPane, command, ...args],
|
|
147
|
+
env: {
|
|
148
|
+
...env,
|
|
149
|
+
[MEMORY_BRIDGE_ENV.home]: launcher.home,
|
|
150
|
+
PATH: `${join(launcher.home, 'bin')}:${env.PATH || ''}`,
|
|
151
|
+
[MEMORY_BRIDGE_ENV.enabled]: '1',
|
|
152
|
+
HELM_MEMORY_CONTEXT: JSON.stringify({ workspace_cwd: workspaceCwd }),
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
158
|
+
|
|
159
|
+
function sweepDates(days) {
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
return Array.from({ length: days }, (_, back) =>
|
|
162
|
+
new Date(now - back * DAY_MS).toISOString().slice(0, 10),
|
|
163
|
+
);
|
|
46
164
|
}
|
|
47
165
|
|
|
48
166
|
function memoryKey(entry) {
|
|
49
167
|
return `${entry.sessionId || ''}|${entry.timestamp || ''}|${entry.turnNumber || ''}`;
|
|
50
168
|
}
|
|
51
169
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (!existsSync(path)) return
|
|
170
|
+
// Each parsed row keeps its source line, so a copy is byte-for-byte.
|
|
171
|
+
function readJsonlLines(path) {
|
|
172
|
+
if (!existsSync(path)) return [];
|
|
173
|
+
const rows = [];
|
|
55
174
|
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
56
175
|
const trimmed = line.trim();
|
|
57
176
|
if (!trimmed) continue;
|
|
58
177
|
try {
|
|
59
|
-
|
|
178
|
+
rows.push({ entry: JSON.parse(trimmed), line: trimmed });
|
|
60
179
|
} catch {
|
|
61
|
-
// Ignore malformed
|
|
180
|
+
// Ignore malformed lines.
|
|
62
181
|
}
|
|
63
182
|
}
|
|
64
|
-
return
|
|
183
|
+
return rows;
|
|
65
184
|
}
|
|
66
185
|
|
|
67
|
-
function
|
|
68
|
-
|
|
69
|
-
const workspace = env[MEMORY_BRIDGE_ENV.workspace];
|
|
70
|
-
if (!project || !workspace) return null;
|
|
71
|
-
const roots = [env[MEMORY_BRIDGE_ENV.home], join(env.HOME || homedir(), '.subspace')].filter(
|
|
72
|
-
Boolean,
|
|
73
|
-
);
|
|
74
|
-
for (const root of roots) {
|
|
75
|
-
const dir = join(root, project, workspace, 'memory', 'observations');
|
|
76
|
-
if (existsSync(dir)) return dir;
|
|
77
|
-
}
|
|
78
|
-
return null;
|
|
186
|
+
function readJsonl(path) {
|
|
187
|
+
return readJsonlLines(path).map(({ entry }) => entry);
|
|
79
188
|
}
|
|
80
189
|
|
|
81
190
|
function memorySyncEnabled(env) {
|
|
@@ -83,38 +192,163 @@ function memorySyncEnabled(env) {
|
|
|
83
192
|
return enabled === '1' || enabled === 'true';
|
|
84
193
|
}
|
|
85
194
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
195
|
+
/**
|
|
196
|
+
* The memory location a session record keeps, so a later sweep reads that
|
|
197
|
+
* session's own observation folder. A nightly session records none: its own
|
|
198
|
+
* turns must never become the next nightly's input.
|
|
199
|
+
*/
|
|
200
|
+
export function bridgeSessionFields(env) {
|
|
201
|
+
if (!memorySyncEnabled(env) || !bridgeEnvPresent(env)) return {};
|
|
202
|
+
if (env.ACADEMY_NIGHTLY_RUN === '1') return {};
|
|
203
|
+
return {
|
|
204
|
+
groveProject: env[MEMORY_BRIDGE_ENV.project],
|
|
205
|
+
groveWorkspace: env[MEMORY_BRIDGE_ENV.workspace],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function recordedSessionsByWorkspace(agentDir) {
|
|
210
|
+
const groups = new Map();
|
|
211
|
+
for (const row of readJsonl(join(agentDir, 'memory', 'sessions.jsonl'))) {
|
|
212
|
+
if (!row.sessionId || !row.groveProject || !row.groveWorkspace) continue;
|
|
213
|
+
const key = JSON.stringify([row.groveProject, row.groveWorkspace]);
|
|
214
|
+
if (!groups.has(key)) {
|
|
215
|
+
groups.set(key, {
|
|
216
|
+
project: row.groveProject,
|
|
217
|
+
workspace: row.groveWorkspace,
|
|
218
|
+
sessionIds: new Set(),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
groups.get(key).sessionIds.add(row.sessionId);
|
|
222
|
+
}
|
|
223
|
+
return [...groups.values()];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// SUBSPACE_HOME first, then ~/.subspace, per date file: a home can hold an
|
|
227
|
+
// older copy of a workspace folder while today's file lives in the other.
|
|
228
|
+
function observationFile(env, group, date) {
|
|
229
|
+
const roots = [env[MEMORY_BRIDGE_ENV.home], join(env.HOME || homedir(), '.subspace')];
|
|
230
|
+
return roots
|
|
231
|
+
.filter(Boolean)
|
|
232
|
+
.map((root) => join(root, group.project, group.workspace, 'memory', 'observations'))
|
|
233
|
+
.map((dir) => join(dir, `${date}.jsonl`))
|
|
234
|
+
.find((path) => existsSync(path));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// shortcut: scans every Backstory context for the date, upgrade if Backstory is installed widely.
|
|
238
|
+
function backstoryObservationFiles(env, date) {
|
|
239
|
+
const projects = join(
|
|
240
|
+
env.BACKSTORY_HOME || join(env.HOME || homedir(), '.backstory'),
|
|
241
|
+
'projects',
|
|
242
|
+
);
|
|
243
|
+
const dirs = (path) => {
|
|
92
244
|
try {
|
|
93
|
-
|
|
94
|
-
const key = memoryKey(entry);
|
|
95
|
-
if (entry.sessionId !== sessionId || existingKeys.has(key)) continue;
|
|
96
|
-
appendFileSync(agentFile, `${trimmed}\n`);
|
|
97
|
-
existingKeys.add(key);
|
|
98
|
-
synced++;
|
|
245
|
+
return readdirSync(path);
|
|
99
246
|
} catch {
|
|
100
|
-
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
return dirs(projects)
|
|
251
|
+
.flatMap((project) =>
|
|
252
|
+
dirs(join(projects, project, 'contexts')).map((context) =>
|
|
253
|
+
join(projects, project, 'contexts', context, 'memory', 'observations', `${date}.jsonl`),
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
.filter((path) => existsSync(path));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Nightly sessions are excluded: their own turns must not feed the next nightly.
|
|
260
|
+
function backstorySessionIds(agentDir) {
|
|
261
|
+
return new Set(
|
|
262
|
+
readJsonl(join(agentDir, 'memory', 'sessions.jsonl'))
|
|
263
|
+
.filter((row) => row.sessionId && !row.nightly)
|
|
264
|
+
.map((row) => row.sessionId),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function sweepBackstory(archiveDir, sessionIds, days, env) {
|
|
269
|
+
let copied = 0;
|
|
270
|
+
for (const date of sweepDates(days)) {
|
|
271
|
+
for (const source of backstoryObservationFiles(env, date)) {
|
|
272
|
+
copied += copySessionObservations(source, join(archiveDir, `${date}.jsonl`), sessionIds);
|
|
101
273
|
}
|
|
102
274
|
}
|
|
103
|
-
return
|
|
275
|
+
return copied;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function copySessionObservations(sourceFile, agentFile, sessionIds) {
|
|
279
|
+
const existingKeys = new Set(readJsonl(agentFile).map(memoryKey));
|
|
280
|
+
let copied = 0;
|
|
281
|
+
for (const { entry, line } of readJsonlLines(sourceFile)) {
|
|
282
|
+
const key = memoryKey(entry);
|
|
283
|
+
if (!sessionIds.has(entry.sessionId) || existingKeys.has(key)) continue;
|
|
284
|
+
appendFileSync(agentFile, `${line}\n`);
|
|
285
|
+
existingKeys.add(key);
|
|
286
|
+
copied++;
|
|
287
|
+
}
|
|
288
|
+
return copied;
|
|
104
289
|
}
|
|
105
290
|
|
|
106
291
|
/**
|
|
107
|
-
* Copy the client
|
|
108
|
-
*
|
|
292
|
+
* Copy observations the client wrote for any session recorded for this agent,
|
|
293
|
+
* from each session's own workspace folder, into the agent's own archive. The
|
|
294
|
+
* client writes observations after Stop, so every sweep catches what earlier
|
|
295
|
+
* ones could not yet see; `memoryKey` keeps a repeated sweep from duplicating.
|
|
296
|
+
* The client-neutral callers learn only a count.
|
|
109
297
|
*/
|
|
110
|
-
export function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
298
|
+
export function sweepBridgeObservations(agentDir, { days = 2, env = process.env } = {}) {
|
|
299
|
+
try {
|
|
300
|
+
return sweepRecordedSessions(agentDir, days, env);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
// A failed sweep must not cost the caller its own work; the next sweep
|
|
303
|
+
// covers the same window.
|
|
304
|
+
process.stderr.write(
|
|
305
|
+
`${JSON.stringify({ event: 'memory.sweep_failed', params: { agentDir, days }, error: error.stack })}\n`,
|
|
306
|
+
);
|
|
307
|
+
return 0;
|
|
118
308
|
}
|
|
119
|
-
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function sweepRecordedSessions(agentDir, days, env) {
|
|
312
|
+
const groups = recordedSessionsByWorkspace(agentDir);
|
|
313
|
+
const archiveDir = join(agentDir, 'memory', 'observations');
|
|
314
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
315
|
+
return withFileLock(join(agentDir, 'memory', 'sweep.lock'), () => {
|
|
316
|
+
let copied = 0;
|
|
317
|
+
for (const group of groups) {
|
|
318
|
+
for (const date of sweepDates(days)) {
|
|
319
|
+
const source = observationFile(env, group, date);
|
|
320
|
+
if (!source) continue;
|
|
321
|
+
const target = join(archiveDir, `${date}.jsonl`);
|
|
322
|
+
copied += copySessionObservations(source, target, group.sessionIds);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return copied + sweepBackstory(archiveDir, backstorySessionIds(agentDir), days, env);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// --- Curator feed: the client facts scripts/cli/curator-feed*.mjs reads ---
|
|
330
|
+
|
|
331
|
+
/** The Codex originator the client stamps on sessions it starts for the user. */
|
|
332
|
+
export const MEMORY_BRIDGE_CODEX_ORIGINATOR = 'subspace';
|
|
333
|
+
|
|
334
|
+
/** Every client home the feed reads, in order: SUBSPACE_HOME, ~/.subspace-alpha, ~/.subspace. */
|
|
335
|
+
export function bridgeHomes(env) {
|
|
336
|
+
const home = env.HOME || homedir();
|
|
337
|
+
const homes = [
|
|
338
|
+
env[MEMORY_BRIDGE_ENV.home],
|
|
339
|
+
join(home, '.subspace-alpha'),
|
|
340
|
+
join(home, '.subspace'),
|
|
341
|
+
];
|
|
342
|
+
return [...new Set(homes.filter(Boolean))];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The client project and workspace a cwd sits in, or null outside a client home. */
|
|
346
|
+
export function bridgeWorkspaceOf(cwd) {
|
|
347
|
+
const m = /\.subspace(?:-alpha)?\/([^/]+)\/([^/]+)/.exec(cwd ?? '');
|
|
348
|
+
return m ? { project: m[1], workspace: m[2] } : null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** A prompt without the context blocks the client injects into it. */
|
|
352
|
+
export function stripBridgeContext(text) {
|
|
353
|
+
return text.replace(/<(subspace|memory|rag|context)[-_a-z]*>.*?<\/\1[-_a-z]*>/gs, '');
|
|
120
354
|
}
|
package/hooks/memory_store.mjs
CHANGED
|
@@ -8,39 +8,72 @@ import {
|
|
|
8
8
|
} from 'node:fs';
|
|
9
9
|
import { join, resolve } from 'node:path';
|
|
10
10
|
import { jsonlHasValue, resolvedAgentDir, withFileLock } from './hook_runtime.mjs';
|
|
11
|
+
import { bridgeSessionFields } from './memory_bridge.mjs';
|
|
11
12
|
|
|
12
13
|
// Academy's own per-agent memory state. Client-neutral: nothing here knows
|
|
13
|
-
// which client, if any, feeds the observation archive
|
|
14
|
+
// which client, if any, feeds the observation archive; the bridge supplies
|
|
15
|
+
// any client location a session record keeps.
|
|
14
16
|
|
|
15
17
|
export function pendingMarkerPath(agentDir) {
|
|
16
18
|
return join(resolve(agentDir), 'memory', 'pending-consolidation.json');
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
// The marker's writers and the nightly's remover share one lock, so a bump
|
|
22
|
+
// never lands between the remover's revision check and its unlink.
|
|
23
|
+
export function withPendingMarkerLock(memoryDir, fn) {
|
|
24
|
+
return withFileLock(join(memoryDir, 'pending-consolidation.lock'), fn);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// `write` runs inside the lock, so a writer's input commits with its bump: the
|
|
28
|
+
// nightly's remover sees both or neither. A `write` that returns false skips
|
|
29
|
+
// the bump. False means the lock timed out and `write` never ran: the caller
|
|
30
|
+
// wrote nothing, so a hook's idempotent sweep runs again next time and
|
|
31
|
+
// `notes add` reports the failure.
|
|
32
|
+
export function markPendingConsolidation(memoryDir, write = () => true) {
|
|
20
33
|
const markerPath = pendingMarkerPath(resolve(memoryDir, '..'));
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
try {
|
|
35
|
+
withPendingMarkerLock(memoryDir, () => {
|
|
36
|
+
if (write() === false) return;
|
|
37
|
+
let revision = 0;
|
|
38
|
+
if (existsSync(markerPath)) {
|
|
39
|
+
try {
|
|
40
|
+
revision = Number(JSON.parse(readFileSync(markerPath, 'utf8')).revision) || 0;
|
|
41
|
+
} catch {
|
|
42
|
+
// Replace malformed private state with the next valid revision.
|
|
43
|
+
}
|
|
28
44
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
const tempPath = join(memoryDir, `.pending-consolidation.${process.pid}.${Date.now()}.tmp`);
|
|
46
|
+
writeFileSync(
|
|
47
|
+
tempPath,
|
|
48
|
+
JSON.stringify(
|
|
49
|
+
{
|
|
50
|
+
revision: revision + 1,
|
|
51
|
+
updatedAt: new Date().toISOString(),
|
|
52
|
+
},
|
|
53
|
+
null,
|
|
54
|
+
2,
|
|
55
|
+
) + '\n',
|
|
56
|
+
);
|
|
57
|
+
renameSync(tempPath, markerPath);
|
|
58
|
+
});
|
|
59
|
+
return true;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!skippedOnLockTimeout(error, { memoryDir, write: 'pending_marker' })) throw error;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function skippedOnLockTimeout(error, params) {
|
|
67
|
+
if (error?.code !== 'lock_timeout') return false;
|
|
68
|
+
console.error(
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
event: 'memory.lock_timeout',
|
|
71
|
+
params,
|
|
72
|
+
result: { written: false },
|
|
73
|
+
status: 'skipped',
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
return true;
|
|
44
77
|
}
|
|
45
78
|
|
|
46
79
|
export function recordSession(memoryDir, payload, env) {
|
|
@@ -48,7 +81,7 @@ export function recordSession(memoryDir, payload, env) {
|
|
|
48
81
|
if (!sessionId) return;
|
|
49
82
|
const sessionsPath = join(memoryDir, 'sessions.jsonl');
|
|
50
83
|
mkdirSync(memoryDir, { recursive: true });
|
|
51
|
-
|
|
84
|
+
const appendRow = () => {
|
|
52
85
|
if (jsonlHasValue(sessionsPath, 'sessionId', sessionId)) return;
|
|
53
86
|
appendFileSync(
|
|
54
87
|
sessionsPath,
|
|
@@ -60,7 +93,26 @@ export function recordSession(memoryDir, payload, env) {
|
|
|
60
93
|
cwd: payload.cwd || null,
|
|
61
94
|
projectDir: env.ACADEMY_PROJECT_DIR || null,
|
|
62
95
|
source: 'stop',
|
|
96
|
+
...bridgeSessionFields(env),
|
|
97
|
+
// The Backstory source matches by session id, so it needs this marker.
|
|
98
|
+
...(env.ACADEMY_NIGHTLY_RUN === '1' ? { nightly: true } : {}),
|
|
63
99
|
}) + '\n',
|
|
64
100
|
);
|
|
65
|
-
}
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
withFileLock(join(memoryDir, 'sessions.lock'), appendRow);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error?.code !== 'lock_timeout') throw error;
|
|
106
|
+
// A one-turn session has no later Stop to record it, so write without the
|
|
107
|
+
// lock. The sweep groups ids in a Set, so a duplicate row is harmless.
|
|
108
|
+
console.error(
|
|
109
|
+
JSON.stringify({
|
|
110
|
+
event: 'memory.lock_timeout',
|
|
111
|
+
params: { memoryDir, write: 'session_record' },
|
|
112
|
+
result: { written: true, locked: false },
|
|
113
|
+
status: 'warn',
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
appendRow();
|
|
117
|
+
}
|
|
66
118
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import {
|
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
runtimeEnvFor,
|
|
11
11
|
withFileLock,
|
|
12
12
|
} from './hook_runtime.mjs';
|
|
13
|
+
import { measureSessionContext } from './context_metrics.mjs';
|
|
14
|
+
import { bridgeSessionFields, sweepBridgeObservations } from './memory_bridge.mjs';
|
|
15
|
+
import { markPendingConsolidation } from './memory_store.mjs';
|
|
13
16
|
|
|
14
17
|
function appendSession(sessionsPath, session) {
|
|
15
18
|
if (jsonlHasValue(sessionsPath, 'sessionId', session.sessionId)) return true;
|
|
@@ -27,25 +30,84 @@ export function registerAcademySession(payload, env = process.env) {
|
|
|
27
30
|
// unattributable rather than corrupt, so it is written without the field and
|
|
28
31
|
// counted by `doctor`.
|
|
29
32
|
const agentDir = resolvedAgentDir(env.ACADEMY_AGENT_DIR);
|
|
33
|
+
const recorded = writeIndexRow(env, {
|
|
34
|
+
sessionId,
|
|
35
|
+
agentName,
|
|
36
|
+
...(agentDir ? { agentDir } : {}),
|
|
37
|
+
cwd,
|
|
38
|
+
startedAt: new Date().toISOString(),
|
|
39
|
+
...(payload.transcript_path ? { transcriptPath: payload.transcript_path } : {}),
|
|
40
|
+
...contextField(agentDir),
|
|
41
|
+
...bridgeSessionFields(env),
|
|
42
|
+
});
|
|
43
|
+
sweepEarlierSessions(agentDir, env);
|
|
44
|
+
return recorded;
|
|
45
|
+
}
|
|
30
46
|
|
|
47
|
+
// shortcut: reads the prompt file after launch, so two near-simultaneous launches of the same agent can read each other's render; upgrade by passing the measurement through the launch env.
|
|
48
|
+
function contextField(agentDir) {
|
|
49
|
+
if (!agentDir) return {};
|
|
31
50
|
try {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
startedAt: new Date().toISOString(),
|
|
51
|
+
const context = measureSessionContext(agentDir);
|
|
52
|
+
return context ? { context } : {};
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error(
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
event: 'session.context_measure_failed',
|
|
57
|
+
params: { agentDir },
|
|
58
|
+
result: { error: err.message },
|
|
59
|
+
status: 'failed',
|
|
42
60
|
}),
|
|
43
61
|
);
|
|
44
|
-
|
|
45
|
-
return false;
|
|
62
|
+
return {};
|
|
46
63
|
}
|
|
47
64
|
}
|
|
48
65
|
|
|
66
|
+
function logHookFailure(event, params, error, status = 'failed') {
|
|
67
|
+
console.error(
|
|
68
|
+
JSON.stringify({ event, params, result: { error: error.message }, status, stack: error.stack }),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// An unlocked append can duplicate a row only if the lock holder writes this
|
|
73
|
+
// same session id at once; appendSession still checks for it first.
|
|
74
|
+
function writeIndexRow(env, row) {
|
|
75
|
+
const academyDir = join(env.HOME || homedir(), '.academy');
|
|
76
|
+
const sessionsPath = join(academyDir, 'sessions.jsonl');
|
|
77
|
+
try {
|
|
78
|
+
mkdirSync(academyDir, { recursive: true });
|
|
79
|
+
return withFileLock(join(academyDir, 'sessions.lock'), () => appendSession(sessionsPath, row));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error?.code !== 'lock_timeout') {
|
|
82
|
+
logHookFailure('session.index_write_failed', { academyDir }, error);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
logHookFailure('session.lock_timeout', { academyDir, write: 'session_index' }, error, 'warn');
|
|
86
|
+
try {
|
|
87
|
+
return appendSession(sessionsPath, row);
|
|
88
|
+
} catch (writeError) {
|
|
89
|
+
logHookFailure('session.index_write_failed', { academyDir }, writeError);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A new session picks up the last turn of the session before it, which the
|
|
96
|
+
// client wrote after that session's final Stop had already swept.
|
|
97
|
+
// The copies commit with the marker bump; no memory/ means nothing to sweep.
|
|
98
|
+
function sweepEarlierSessions(agentDir, env) {
|
|
99
|
+
if (!agentDir || !existsSync(join(agentDir, 'memory'))) return;
|
|
100
|
+
markPendingConsolidation(
|
|
101
|
+
join(agentDir, 'memory'),
|
|
102
|
+
() => sweepBridgeObservations(agentDir, { env }) > 0,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
49
106
|
if (isMainModule(import.meta.url)) {
|
|
50
|
-
|
|
107
|
+
try {
|
|
108
|
+
registerAcademySession(readPayload());
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// A hook must never fail the session it observes.
|
|
111
|
+
logHookFailure('session.register_failed', {}, error);
|
|
112
|
+
}
|
|
51
113
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joe Fernandez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
54ca4d587b911adee6f71ce6239d448359a10843
|