@hiveku-apps/sync 0.4.0 → 0.5.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/README.md +118 -7
- package/bin/hiveku-sync.mjs +65 -3
- package/package.json +1 -1
- package/src/claude-md.mjs +215 -0
- package/src/codex-prompt-tools.json +14 -2
- package/src/codex.mjs +227 -40
- package/src/init.mjs +17 -39
- package/src/knowledge-files.mjs +306 -0
- package/src/memory-owner.mjs +464 -0
- package/src/pull.mjs +182 -78
- package/src/util.mjs +0 -10
package/src/pull.mjs
CHANGED
|
@@ -1,87 +1,118 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `hiveku-sync pull` — fetches
|
|
3
|
-
*
|
|
2
|
+
* `hiveku-sync pull` — fetches the account's memory, rules, skills,
|
|
3
|
+
* shortcuts, specialists and profiles with ONE memory_list call and writes
|
|
4
|
+
* each entry as a local .md file, filed by its owner (knowledge-files.mjs):
|
|
5
|
+
*
|
|
6
|
+
* rules/sales/<name>.md, skills/seo/<name>.md, memory/helpdesk/<name>.md, ...
|
|
7
|
+
* <type folder>/shared/<name>.md for an entry no agent owns
|
|
8
|
+
* <type folder>/<owner>/not-followed/<name>.md for an entry filed under an
|
|
9
|
+
* owner that no agent reads (the Memory page hides it)
|
|
10
|
+
*
|
|
11
|
+
* The owner is the `owner` memory_list returns (the builder's one owner rule),
|
|
12
|
+
* or the same rule from memory-owner.mjs when an older builder does not return
|
|
13
|
+
* it. The chief of staff's own `_account:*` rows and Voice and pronunciation
|
|
14
|
+
* are written where the Memory page shows them, read-only.
|
|
15
|
+
*
|
|
16
|
+
* It also refreshes the folder layout and memory rules in CLAUDE.md, the part
|
|
17
|
+
* between hiveku-sync's markers (claude-md.mjs).
|
|
4
18
|
*
|
|
5
19
|
* The account memory (domains `account` and `account-suggestions`) is not a
|
|
6
20
|
* knowledge entry: it is classified explicitly, never written under memory/,
|
|
7
21
|
* and pulled once from account_memory_get into a read-only
|
|
8
22
|
* hiveku-data/account/ACCOUNT_MEMORY.md (see account-memory.mjs).
|
|
9
23
|
*
|
|
24
|
+
* A file an earlier pull wrote for an entry this pull files somewhere else
|
|
25
|
+
* (an older hiveku-sync wrote flat <type>/<name>.md files), or no longer
|
|
26
|
+
* mirrors, is moved to .hiveku/set-aside/knowledge/<time>/: never deleted.
|
|
27
|
+
* A file whose entry is gone from Hiveku stays where it is and is listed as
|
|
28
|
+
* removed. When the listing fails, nothing is moved or reported.
|
|
29
|
+
*
|
|
10
30
|
* Safety: refuses to overwrite when the .env API key no longer matches the
|
|
11
31
|
* manifest's recorded key preview (prevents pulling Account A's data into a
|
|
12
32
|
* folder previously synced for Account B — the 2026-05-11 cross-tenant bug
|
|
13
33
|
* class).
|
|
14
34
|
*/
|
|
15
35
|
|
|
16
|
-
import {
|
|
36
|
+
import { existsSync } from 'node:fs';
|
|
37
|
+
import { mkdir, readFile, rename } from 'node:fs/promises';
|
|
38
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
17
39
|
|
|
18
40
|
import { HivekuMcpClient } from './mcp.mjs';
|
|
19
|
-
import {
|
|
20
|
-
loadDotenv,
|
|
21
|
-
readEnv,
|
|
22
|
-
safeSlug,
|
|
23
|
-
writeFileAtomic,
|
|
24
|
-
readJson,
|
|
25
|
-
toFrontmatter,
|
|
26
|
-
extractDepartmentTag,
|
|
27
|
-
TYPE_TO_FOLDER,
|
|
28
|
-
SUPPORTED_TYPES,
|
|
29
|
-
} from './util.mjs';
|
|
41
|
+
import { loadDotenv, readEnv, writeFileAtomic, readJson, SUPPORTED_TYPES } from './util.mjs';
|
|
30
42
|
import {
|
|
31
43
|
ACCOUNT_MEMORY_FILE,
|
|
32
|
-
classifyMemoryEntry,
|
|
33
44
|
ACCOUNT_MEMORY_TOOL,
|
|
34
45
|
normalizeAccountMemory,
|
|
35
46
|
renderAccountMemoryFile,
|
|
36
47
|
resolveAccountId,
|
|
37
48
|
writeReadOnly,
|
|
38
49
|
} from './account-memory.mjs';
|
|
50
|
+
import { planKnowledgeFiles } from './knowledge-files.mjs';
|
|
51
|
+
import { refreshClaudeMd } from './claude-md.mjs';
|
|
39
52
|
|
|
40
53
|
const MANIFEST_PATH = '.hiveku/manifest.json';
|
|
54
|
+
export const KNOWLEDGE_SET_ASIDE_DIR = '.hiveku/set-aside/knowledge';
|
|
41
55
|
|
|
42
56
|
function keyPreview(apiKey) {
|
|
43
57
|
return apiKey.slice(0, 10);
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
/** `rel` under `root`, or null when it would land outside it (a manifest path is data, not trusted). */
|
|
61
|
+
function insidePath(root, rel) {
|
|
62
|
+
const base = resolve(root);
|
|
63
|
+
const abs = resolve(base, String(rel || '').replace(/^\/+/, ''));
|
|
64
|
+
return abs.startsWith(base + sep) ? abs : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Every account-level entry, of every type, in one call. */
|
|
68
|
+
async function fetchAll(client) {
|
|
69
|
+
const result = await client.callToolJson('memory_list', {});
|
|
48
70
|
if (!result?.data || !Array.isArray(result.data)) {
|
|
49
|
-
throw new Error(
|
|
71
|
+
throw new Error('memory_list returned unexpected shape');
|
|
50
72
|
}
|
|
51
73
|
return result.data;
|
|
52
74
|
}
|
|
53
75
|
|
|
54
|
-
function entryFilename(entry) {
|
|
55
|
-
const name = entry.name || entry.domain.replace(/^_[^:]+:/, '');
|
|
56
|
-
return `${safeSlug(name)}.md`;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function renderEntry(entry, type) {
|
|
60
|
-
const body = entry.content ?? '';
|
|
61
|
-
const department = extractDepartmentTag(body);
|
|
62
|
-
const frontmatter = toFrontmatter({
|
|
63
|
-
id: entry.id,
|
|
64
|
-
name: entry.name,
|
|
65
|
-
type,
|
|
66
|
-
domain: entry.domain,
|
|
67
|
-
department,
|
|
68
|
-
project_id: entry.project_id,
|
|
69
|
-
version: entry.version,
|
|
70
|
-
updated_at: entry.updated_at,
|
|
71
|
-
});
|
|
72
|
-
return `${frontmatter}${body}`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
76
|
/**
|
|
76
77
|
* Write hiveku-data/account/ACCOUNT_MEMORY.md from account_memory_get, read-only.
|
|
77
78
|
* Returns { file, version, suggestions, accountId }, or throws (the caller
|
|
78
|
-
* records the error and keeps the previous copy).
|
|
79
|
+
* records the error and keeps the previous copy). Pass `accountId` when it is
|
|
80
|
+
* already known (null included) to skip looking it up again.
|
|
79
81
|
*/
|
|
80
|
-
export async function pullAccountMemory({ client, baseDir, fetchedAt = new Date().toISOString() }) {
|
|
82
|
+
export async function pullAccountMemory({ client, baseDir, fetchedAt = new Date().toISOString(), accountId }) {
|
|
81
83
|
const memory = normalizeAccountMemory(await client.callToolJson(ACCOUNT_MEMORY_TOOL, {}));
|
|
82
|
-
const
|
|
83
|
-
await writeReadOnly(join(baseDir, ACCOUNT_MEMORY_FILE), renderAccountMemoryFile({ memory, accountId, fetchedAt }));
|
|
84
|
-
return { file: ACCOUNT_MEMORY_FILE, version: memory.version, suggestions: memory.suggestions.length, accountId };
|
|
84
|
+
const id = accountId === undefined ? await resolveAccountId(client) : accountId;
|
|
85
|
+
await writeReadOnly(join(baseDir, ACCOUNT_MEMORY_FILE), renderAccountMemoryFile({ memory, accountId: id, fetchedAt }));
|
|
86
|
+
return { file: ACCOUNT_MEMORY_FILE, version: memory.version, suggestions: memory.suggestions.length, accountId: id };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The `domain` an earlier pull wrote into a file's front matter, or null. */
|
|
90
|
+
async function domainInFile(path) {
|
|
91
|
+
try {
|
|
92
|
+
const text = await readFile(path, 'utf8');
|
|
93
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---\n/);
|
|
94
|
+
const line = fm?.[1].match(/^domain: "((?:[^"\\]|\\.)*)"$/m);
|
|
95
|
+
return line ? line[1].replace(/\\"/g, '"') : null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Move files an earlier pull wrote out of the tree, into one timestamped folder. Returns the moved paths. */
|
|
102
|
+
async function setAside(baseDir, relPaths) {
|
|
103
|
+
if (relPaths.length === 0) return { moved: [], dir: null };
|
|
104
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
105
|
+
const dir = join(baseDir, KNOWLEDGE_SET_ASIDE_DIR, stamp);
|
|
106
|
+
const moved = [];
|
|
107
|
+
for (const rel of relPaths) {
|
|
108
|
+
const from = insidePath(baseDir, rel);
|
|
109
|
+
const to = insidePath(dir, rel);
|
|
110
|
+
if (!from || !to || !existsSync(from)) continue;
|
|
111
|
+
await mkdir(dirname(to), { recursive: true });
|
|
112
|
+
await rename(from, to);
|
|
113
|
+
moved.push(rel);
|
|
114
|
+
}
|
|
115
|
+
return { moved, dir: moved.length ? dir : null };
|
|
85
116
|
}
|
|
86
117
|
|
|
87
118
|
export async function runPull({ baseDir = process.cwd(), profile, client: injectedClient } = {}) {
|
|
@@ -117,35 +148,70 @@ export async function runPull({ baseDir = process.cwd(), profile, client: inject
|
|
|
117
148
|
const client = injectedClient || new HivekuMcpClient({ baseUrl, apiKey, profile: activeProfile });
|
|
118
149
|
if (!injectedClient) await client.initialize();
|
|
119
150
|
|
|
120
|
-
const summary = {
|
|
151
|
+
const summary = {
|
|
152
|
+
total: 0,
|
|
153
|
+
by_type: {},
|
|
154
|
+
// Entries an agent reads (or every agent shares), by owner folder.
|
|
155
|
+
by_owner: {},
|
|
156
|
+
// Entries filed under <type>/<owner>/not-followed/: no agent reads them.
|
|
157
|
+
not_followed: 0,
|
|
158
|
+
errors: [],
|
|
159
|
+
account_memory: null,
|
|
160
|
+
account_rows_skipped: 0,
|
|
161
|
+
not_mirrored: { system: 0, hidden: 0 },
|
|
162
|
+
set_aside: [],
|
|
163
|
+
set_aside_dir: null,
|
|
164
|
+
claude_md: null,
|
|
165
|
+
};
|
|
121
166
|
const writtenFiles = new Set();
|
|
167
|
+
const fileDomains = {};
|
|
168
|
+
const readOnlyFiles = new Set();
|
|
169
|
+
const previousFiles = Array.isArray(manifest?.files) ? manifest.files : [];
|
|
170
|
+
const previousDomains = manifest?.file_domains && typeof manifest.file_domains === 'object' ? manifest.file_domains : {};
|
|
171
|
+
|
|
172
|
+
// The account id gives the read-only copies their Memory page link.
|
|
173
|
+
const accountId = await resolveAccountId(client);
|
|
174
|
+
|
|
175
|
+
let plan = null;
|
|
176
|
+
try {
|
|
177
|
+
plan = planKnowledgeFiles(await fetchAll(client), { accountId });
|
|
178
|
+
} catch (err) {
|
|
179
|
+
summary.errors.push({ type: 'memory_list', message: err.message });
|
|
180
|
+
}
|
|
122
181
|
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
182
|
+
if (plan) {
|
|
183
|
+
for (const type of SUPPORTED_TYPES) summary.by_type[type] = 0;
|
|
184
|
+
summary.account_rows_skipped = plan.skipped.account;
|
|
185
|
+
summary.not_mirrored = { system: plan.skipped.system, hidden: plan.skipped.hidden };
|
|
186
|
+
for (const file of plan.files) {
|
|
187
|
+
const abs = join(baseDir, file.rel);
|
|
188
|
+
if (file.readOnly) {
|
|
189
|
+
await writeReadOnly(abs, file.body);
|
|
190
|
+
readOnlyFiles.add(file.rel);
|
|
191
|
+
} else {
|
|
192
|
+
await writeFileAtomic(abs, file.body);
|
|
193
|
+
}
|
|
194
|
+
writtenFiles.add(file.rel);
|
|
195
|
+
fileDomains[file.rel] = file.domain;
|
|
196
|
+
summary.by_type[file.type] = (summary.by_type[file.type] ?? 0) + 1;
|
|
197
|
+
if (file.notFollowed) {
|
|
198
|
+
summary.not_followed += 1;
|
|
199
|
+
} else {
|
|
200
|
+
const owner = file.owner ?? 'shared';
|
|
201
|
+
summary.by_owner[owner] = (summary.by_owner[owner] ?? 0) + 1;
|
|
202
|
+
}
|
|
203
|
+
summary.total += 1;
|
|
131
204
|
}
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
else summary.account_rows_skipped += 1;
|
|
205
|
+
} else {
|
|
206
|
+
// Nothing was listed, so nothing can be called moved or removed: keep
|
|
207
|
+
// what the last pull recorded, as it was.
|
|
208
|
+
for (const rel of previousFiles) {
|
|
209
|
+
if (rel === ACCOUNT_MEMORY_FILE) continue;
|
|
210
|
+
writtenFiles.add(rel);
|
|
211
|
+
if (typeof previousDomains[rel] === 'string') fileDomains[rel] = previousDomains[rel];
|
|
140
212
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
for (const entry of knowledge) {
|
|
145
|
-
const filename = entryFilename(entry);
|
|
146
|
-
const fullPath = join(baseDir, folder, filename);
|
|
147
|
-
await writeFileAtomic(fullPath, renderEntry(entry, type));
|
|
148
|
-
writtenFiles.add(`${folder}/${filename}`);
|
|
213
|
+
for (const rel of Array.isArray(manifest?.read_only) ? manifest.read_only : []) {
|
|
214
|
+
if (rel !== ACCOUNT_MEMORY_FILE && writtenFiles.has(rel)) readOnlyFiles.add(rel);
|
|
149
215
|
}
|
|
150
216
|
}
|
|
151
217
|
|
|
@@ -153,18 +219,37 @@ export async function runPull({ baseDir = process.cwd(), profile, client: inject
|
|
|
153
219
|
// A failure keeps the previous copy (and its manifest line), so it is not
|
|
154
220
|
// reported as removed upstream.
|
|
155
221
|
try {
|
|
156
|
-
summary.account_memory = await pullAccountMemory({ client, baseDir });
|
|
222
|
+
summary.account_memory = await pullAccountMemory({ client, baseDir, accountId });
|
|
157
223
|
writtenFiles.add(ACCOUNT_MEMORY_FILE);
|
|
158
224
|
} catch (err) {
|
|
159
225
|
summary.errors.push({ type: 'account memory', message: err.message });
|
|
160
|
-
if (
|
|
226
|
+
if (previousFiles.includes(ACCOUNT_MEMORY_FILE)) writtenFiles.add(ACCOUNT_MEMORY_FILE);
|
|
161
227
|
}
|
|
162
228
|
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
229
|
+
// Files the last pull wrote that this one did not. When Hiveku still lists
|
|
230
|
+
// the entry, its copy is now somewhere else (or no longer mirrored): the
|
|
231
|
+
// old copy is set aside. Otherwise the entry is gone upstream: the file is
|
|
232
|
+
// left on disk and reported, never deleted.
|
|
233
|
+
const notWritten = previousFiles.filter((rel) => !writtenFiles.has(rel));
|
|
234
|
+
const outOfPlace = [];
|
|
235
|
+
const removedRemotely = [];
|
|
236
|
+
for (const rel of notWritten) {
|
|
237
|
+
const path = insidePath(baseDir, rel);
|
|
238
|
+
// Already gone from disk (you deleted it): nothing to move, nothing to prune.
|
|
239
|
+
if (!path || !existsSync(path)) continue;
|
|
240
|
+
const domain = typeof previousDomains[rel] === 'string' ? previousDomains[rel] : await domainInFile(path);
|
|
241
|
+
if (plan && domain && plan.listed.has(domain)) outOfPlace.push(rel);
|
|
242
|
+
else removedRemotely.push(rel);
|
|
243
|
+
}
|
|
244
|
+
const tidy = await setAside(baseDir, outOfPlace);
|
|
245
|
+
summary.set_aside = tidy.moved;
|
|
246
|
+
summary.set_aside_dir = tidy.dir;
|
|
167
247
|
|
|
248
|
+
const sortedDomains = Object.fromEntries(Object.entries(fileDomains).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
249
|
+
// Which entry each file holds, when this pull (or the last) knew it. A failed
|
|
250
|
+
// listing over a folder an older release pulled knows none: leave it out,
|
|
251
|
+
// so readers (the Codex rules digest) keep reading the files on disk.
|
|
252
|
+
const knowsDomains = Boolean(plan) || (manifest?.file_domains && typeof manifest.file_domains === 'object');
|
|
168
253
|
await writeFileAtomic(
|
|
169
254
|
manifestPath,
|
|
170
255
|
JSON.stringify(
|
|
@@ -173,10 +258,13 @@ export async function runPull({ baseDir = process.cwd(), profile, client: inject
|
|
|
173
258
|
base_url: baseUrl,
|
|
174
259
|
profile: activeProfile,
|
|
175
260
|
last_sync_at: new Date().toISOString(),
|
|
261
|
+
// Filed by owner: <type folder>/<owner>/<name>.md (shared/ for no owner).
|
|
262
|
+
layout: 'by-owner',
|
|
176
263
|
counts: summary.by_type,
|
|
177
264
|
files: [...writtenFiles].sort(),
|
|
178
|
-
|
|
179
|
-
|
|
265
|
+
...(knowsDomains ? { file_domains: sortedDomains } : {}),
|
|
266
|
+
// Pulled for reading only: changed on the Memory page, never uploaded.
|
|
267
|
+
read_only: [ACCOUNT_MEMORY_FILE, ...[...readOnlyFiles].sort()],
|
|
180
268
|
removed_remotely_since_last_sync: removedRemotely,
|
|
181
269
|
},
|
|
182
270
|
null,
|
|
@@ -184,5 +272,21 @@ export async function runPull({ baseDir = process.cwd(), profile, client: inject
|
|
|
184
272
|
),
|
|
185
273
|
);
|
|
186
274
|
|
|
187
|
-
|
|
275
|
+
// The folder layout and memory rules in CLAUDE.md, which only `init` wrote
|
|
276
|
+
// before: refreshed, so a folder an older release made stops teaching the
|
|
277
|
+
// old ones (claude-md.mjs). A failure here is reported, not a failed pull.
|
|
278
|
+
try {
|
|
279
|
+
summary.claude_md = await refreshClaudeMd(baseDir);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
summary.claude_md = { status: 'failed', message: err.message };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let codexLane = false;
|
|
285
|
+
try {
|
|
286
|
+
codexLane = (await readFile(join(baseDir, 'AGENTS.md'), 'utf8')).includes('<!-- hiveku:begin -->');
|
|
287
|
+
} catch {
|
|
288
|
+
/* no Codex lane */
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { ...summary, removed_remotely: removedRemotely, profile: activeProfile, baseUrl, codex_lane: codexLane };
|
|
188
292
|
}
|
package/src/util.mjs
CHANGED
|
@@ -87,16 +87,6 @@ export const TYPE_TO_FOLDER = {
|
|
|
87
87
|
|
|
88
88
|
export const SUPPORTED_TYPES = Object.keys(TYPE_TO_FOLDER);
|
|
89
89
|
|
|
90
|
-
/** Extract a department tag from content. Looks for HTML or YAML markers. */
|
|
91
|
-
export function extractDepartmentTag(content) {
|
|
92
|
-
if (!content) return null;
|
|
93
|
-
const htmlMatch = content.match(/<!--\s*department:\s*([a-z0-9_-]+)\s*-->/i);
|
|
94
|
-
if (htmlMatch) return htmlMatch[1];
|
|
95
|
-
const yamlMatch = content.match(/^department:\s*["']?([a-z0-9_-]+)["']?\s*$/im);
|
|
96
|
-
if (yamlMatch) return yamlMatch[1];
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
90
|
/** Resolve account base directory (default: ./). */
|
|
101
91
|
export function defaultBaseDir(cwd = process.cwd()) {
|
|
102
92
|
return cwd;
|