@hiveku-apps/sync 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pull.mjs CHANGED
@@ -1,87 +1,118 @@
1
1
  /**
2
- * `hiveku-sync pull` — fetches memory/rules/skills via the MCP memory_list tool
3
- * and writes them as local .md files under the current folder.
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 { join } from 'node:path';
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
- async function fetchType(client, type) {
47
- const result = await client.callToolJson('memory_list', { type });
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(`memory_list(${type}) returned unexpected shape`);
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 accountId = await resolveAccountId(client);
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 = { total: 0, by_type: {}, errors: [], account_memory: null, account_rows_skipped: 0 };
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
- for (const type of SUPPORTED_TYPES) {
124
- const folder = TYPE_TO_FOLDER[type];
125
- let entries;
126
- try {
127
- entries = await fetchType(client, type);
128
- } catch (err) {
129
- summary.errors.push({ type, message: err.message });
130
- continue;
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
- // The two account-memory domains are not knowledge entries: an older
133
- // server still lists them, and they would land as memory/account.md and
134
- // memory/account-suggestions.md (raw JSON Lines) looking editable. They
135
- // are written once, read-only, from account_memory_get below.
136
- const knowledge = [];
137
- for (const entry of entries) {
138
- if (classifyMemoryEntry(entry, folder).kind === 'knowledge') knowledge.push(entry);
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
- summary.by_type[type] = knowledge.length;
142
- summary.total += knowledge.length;
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 ((manifest?.files ?? []).includes(ACCOUNT_MEMORY_FILE)) writtenFiles.add(ACCOUNT_MEMORY_FILE);
226
+ if (previousFiles.includes(ACCOUNT_MEMORY_FILE)) writtenFiles.add(ACCOUNT_MEMORY_FILE);
161
227
  }
162
228
 
163
- // v1: don't delete stale files; just record them in the manifest so the
164
- // user can prune manually if they want. Avoids losing local edits.
165
- const previouslySeen = new Set(manifest?.files ?? []);
166
- const removedRemotely = [...previouslySeen].filter((f) => !writtenFiles.has(f));
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
- // Pulled for reading only: edited on the dashboard, never uploaded.
179
- read_only: [ACCOUNT_MEMORY_FILE],
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
- return { ...summary, removed_remotely: removedRemotely, profile: activeProfile, baseUrl };
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;