@hiveku-apps/sync 0.3.3 → 0.3.5

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 CHANGED
@@ -8,6 +8,8 @@ Local sync CLI for a Hiveku account. Pairs with the Hiveku MCP server at
8
8
  2. **`hiveku-sync pull`** — fetch the account's memory/rules/skills via the
9
9
  `memory_list` MCP tool and write each entry as a Markdown file with
10
10
  YAML frontmatter (name, type, domain, department, version, updated_at).
11
+ It also writes the account memory (see "Account memory" below) to
12
+ `hiveku-data/account/ACCOUNT_MEMORY.md`, read-only.
11
13
 
12
14
  The goal: open one folder per Hiveku tenant in VS Code with Claude Code,
13
15
  and the local `.md` files become the source-of-truth Claude reads. Tools
@@ -182,3 +184,25 @@ For **projects**, `hiveku-sync push` / `deploy` write straight back to Hiveku
182
184
  (see "Coder projects" above). For **memory/rules/skills**, `pull` is read-only —
183
185
  persist changes with the `memory_create` / `memory_update` / `memory_delete` MCP
184
186
  tools through Claude, then `hiveku-sync pull` to refresh the local copies.
187
+
188
+ ## Account memory
189
+
190
+ The account memory is the business facts every Hiveku department agent reads.
191
+ Owners and admins edit it on the Hiveku dashboard, under Account memory
192
+ (`https://app.hiveku.com/<account id>/dashboard/memory`); no tool sets it.
193
+
194
+ `pull` reads it with `account_memory_get` and writes one file,
195
+ `hiveku-data/account/ACCOUNT_MEMORY.md`, the same file, byte for byte, that the
196
+ Hiveku plugin and the VS Code extension write. The file is read-only on disk and
197
+ starts with a note saying where it is edited, with the link (the unscoped
198
+ `https://app.hiveku.com/dashboard/memory` when the account id cannot be read),
199
+ and that it is internal to the team. The owners' text comes first, then the suggestions
200
+ agents and team members made that an owner has not reviewed yet, each with who
201
+ suggested it and when. The two account-memory rows (`account` and
202
+ `account-suggestions`) are never written under `memory/`, even when an older
203
+ server still lists them.
204
+
205
+ Nothing uploads this file: `push`, `commit` and `status` leave any
206
+ `hiveku-data/account/` folder out, and the next `pull` replaces it. To add a
207
+ fact, suggest it with the `account_memory_append` tool; an owner keeps or
208
+ removes it on the dashboard.
@@ -189,6 +189,13 @@ async function main() {
189
189
  for (const [type, count] of Object.entries(summary.by_type)) {
190
190
  console.log(` ${type}: ${count}`);
191
191
  }
192
+ if (summary.account_memory) {
193
+ const am = summary.account_memory;
194
+ console.log(
195
+ ` account memory: ${am.file} (read-only; ${am.version > 0 ? `version ${am.version}` : 'not written yet'}, ` +
196
+ `${am.suggestions} suggestion${am.suggestions === 1 ? '' : 's'} waiting). Owners edit it on the dashboard.`,
197
+ );
198
+ }
192
199
  if (summary.removed_remotely.length > 0) {
193
200
  console.log('\nNote: these files were present in the last sync but are no longer in Olympus:');
194
201
  for (const f of summary.removed_remotely) console.log(` ${f}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hiveku-apps/sync",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Sync a Hiveku account's memory/rules/skills + coder projects into a local folder, and ship projects (push/deploy/pull-env) from the terminal. Pairs with the Hiveku MCP at core.hiveku.com/mcp so Claude Code can drive Hiveku tools per-folder.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The account memory, pulled read-only.
3
+ *
4
+ * The account memory is the business facts every Hiveku department agent reads.
5
+ * It lives in two rows of account_ai_memory, and neither is a department:
6
+ *
7
+ * domain `account` the owner's document (owners and admins edit
8
+ * it on the dashboard, nowhere else);
9
+ * domain `account-suggestions` one-line suggestions from agents, MCP clients
10
+ * and team members, waiting for an owner's
11
+ * Keep or Remove (JSON Lines).
12
+ *
13
+ * Neither row may land in the memory/ tree as if it were a department's
14
+ * document: `account-suggestions` would be raw JSON Lines, and a local edit to
15
+ * either would look like something `memory_update` could write back, which the
16
+ * server refuses. So `hiveku-sync pull` classifies both domains explicitly and
17
+ * writes one read-only file instead, in the same place the Hiveku plugin does:
18
+ *
19
+ * hiveku-data/account/ACCOUNT_MEMORY.md
20
+ *
21
+ * from `account_memory_get`. Nothing in hiveku-sync ever uploads that file:
22
+ * there is no tool that sets the account memory, and `push` leaves the folder
23
+ * out (see isAccountMemoryPath).
24
+ */
25
+
26
+ import { chmod } from 'node:fs/promises';
27
+ import { existsSync } from 'node:fs';
28
+
29
+ import { writeFileAtomic } from './util.mjs';
30
+
31
+ export const ACCOUNT_MEMORY_DOMAIN = 'account';
32
+ export const ACCOUNT_SUGGESTIONS_DOMAIN = 'account-suggestions';
33
+ export const ACCOUNT_MEMORY_DOMAINS = new Set([ACCOUNT_MEMORY_DOMAIN, ACCOUNT_SUGGESTIONS_DOMAIN]);
34
+
35
+ /** Folder and file, relative to the account folder (same layout as the plugin). */
36
+ export const ACCOUNT_DATA_DIR = 'hiveku-data/account';
37
+ export const ACCOUNT_MEMORY_FILE = `${ACCOUNT_DATA_DIR}/ACCOUNT_MEMORY.md`;
38
+
39
+ export const APP_URL = 'https://app.hiveku.com';
40
+
41
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
42
+
43
+ export function isAccountMemoryDomain(domain) {
44
+ return typeof domain === 'string' && ACCOUNT_MEMORY_DOMAINS.has(domain.trim());
45
+ }
46
+
47
+ /**
48
+ * Where a memory_list row belongs. The two account domains are named
49
+ * explicitly, before any folder or department tag is looked at: they carry no
50
+ * department tag, so a generic rule would file them under memory/ (or, in a
51
+ * by-department layout, invent an "account" department).
52
+ *
53
+ * { kind: 'account' } the owner document (written from account_memory_get)
54
+ * { kind: 'account-suggestions' } the suggestions row (listed under the document)
55
+ * { kind: 'knowledge', folder } everything else, in its type's folder
56
+ */
57
+ export function classifyMemoryEntry(entry, folder) {
58
+ const domain = typeof entry?.domain === 'string' ? entry.domain.trim() : '';
59
+ if (domain === ACCOUNT_MEMORY_DOMAIN) return { kind: 'account' };
60
+ if (domain === ACCOUNT_SUGGESTIONS_DOMAIN) return { kind: 'account-suggestions' };
61
+ return { kind: 'knowledge', folder };
62
+ }
63
+
64
+ /**
65
+ * True for a path (relative, forward or back slashes) inside the pulled
66
+ * account-memory folder, wherever that folder sits. `push` drops these paths,
67
+ * so the read-only copy can never be uploaded as site code.
68
+ */
69
+ export function isAccountMemoryPath(relPath) {
70
+ const p = String(relPath || '').replace(/\\/g, '/').replace(/^\.?\//, '');
71
+ return p === ACCOUNT_DATA_DIR || p.startsWith(`${ACCOUNT_DATA_DIR}/`) || p.includes(`/${ACCOUNT_DATA_DIR}/`);
72
+ }
73
+
74
+ export const ACCOUNT_MEMORY_TOOL = 'account_memory_get';
75
+
76
+ /** The builder's dashboard page (src/app/dashboard/memory, scoped mirror under /[accountId]). */
77
+ const DASHBOARD_PATH = 'dashboard/memory';
78
+
79
+ /*
80
+ * ONE FILE, THREE WRITERS. The Claude Code plugin (lib/account-memory.mjs),
81
+ * the VS Code extension (src/accountMemory.ts) and hiveku-sync all write
82
+ * hiveku-data/account/ACCOUNT_MEMORY.md, and the plugin reads its front matter
83
+ * back (`fetched_at`, for `pull --list` and `--stale`). So everything from here
84
+ * to writeReadOnly is a port of the plugin's normalizeAccountMemory,
85
+ * accountMemoryDashboardUrl and renderAccountMemoryFile, and
86
+ * test/account-memory.test.mjs compares the two byte for byte whenever a plugin
87
+ * checkout sits beside this repo. Change them together.
88
+ */
89
+
90
+ /**
91
+ * The page where owners and admins edit it. Account-scoped when the account id
92
+ * is a real UUID (the scoped route opens on the right account for a person in
93
+ * several); the unscoped page otherwise, never a half-built URL.
94
+ */
95
+ export function accountMemoryDashboardUrl(accountId, appUrl = APP_URL) {
96
+ const base = String(appUrl || APP_URL).replace(/\/+$/, '');
97
+ return typeof accountId === 'string' && UUID_RE.test(accountId)
98
+ ? `${base}/${accountId.toLowerCase()}/${DASHBOARD_PATH}`
99
+ : `${base}/${DASHBOARD_PATH}`;
100
+ }
101
+
102
+ /**
103
+ * The tool result, checked. account_memory_get returns the builder's
104
+ * `{ data: { content, version, updated_at, bytes, suggestions, suggestions_version,
105
+ * injected, truncated } }`. Anything else is an error, not an empty memory:
106
+ * writing "nothing written yet" over a real document because the shape moved
107
+ * would be a lie on disk.
108
+ */
109
+ export function normalizeAccountMemory(payload) {
110
+ const inner = payload && typeof payload === 'object' && !Array.isArray(payload) && 'data' in payload ? payload.data : payload;
111
+ if (!inner || typeof inner !== 'object' || Array.isArray(inner)) {
112
+ throw new Error(`${ACCOUNT_MEMORY_TOOL} returned an unexpected shape (no object)`);
113
+ }
114
+ if (typeof inner.content !== 'string' || !Number.isFinite(Number(inner.version))) {
115
+ throw new Error(`${ACCOUNT_MEMORY_TOOL} returned an unexpected shape (no content or version)`);
116
+ }
117
+ const suggestions = Array.isArray(inner.suggestions) ? inner.suggestions : [];
118
+ return {
119
+ content: inner.content,
120
+ version: Number(inner.version),
121
+ updated_at: typeof inner.updated_at === 'string' ? inner.updated_at : null,
122
+ suggestions: suggestions
123
+ .filter((s) => s && typeof s === 'object' && typeof s.text === 'string' && s.text.trim())
124
+ .map((s) => ({
125
+ id: typeof s.id === 'string' ? s.id : '',
126
+ at: typeof s.at === 'string' ? s.at : '',
127
+ source: typeof s.source === 'string' && s.source.trim() ? s.source : 'Unknown',
128
+ text: s.text,
129
+ })),
130
+ suggestions_version: Number.isFinite(Number(inner.suggestions_version)) ? Number(inner.suggestions_version) : 0,
131
+ truncated: inner.truncated === true,
132
+ };
133
+ }
134
+
135
+ /** "2026-09-23 14:02 UTC", or the raw value when it is not a date. */
136
+ function readableTime(value) {
137
+ if (!value) return 'unknown time';
138
+ const d = new Date(value);
139
+ if (Number.isNaN(d.getTime())) return String(value);
140
+ return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
141
+ }
142
+
143
+ /** One line, no line breaks: a suggestion is one line on the server too. */
144
+ function oneLine(value) {
145
+ return String(value).replace(/[\r\n\u2028\u2029]+/g, ' ').trim();
146
+ }
147
+
148
+ function yamlString(value) {
149
+ return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
150
+ }
151
+
152
+ /**
153
+ * The file text. Plain language: the person reading it may be the owner.
154
+ * Held suggestions (from the agents that talk to customers, and onboarding)
155
+ * are never in the tool's answer, so they are never here either.
156
+ */
157
+ export function renderAccountMemoryFile({ memory, accountId, fetchedAt, appUrl = APP_URL }) {
158
+ const editUrl = accountMemoryDashboardUrl(accountId, appUrl);
159
+ const fm = [
160
+ '---',
161
+ `read_only: true`,
162
+ `source_tool: ${ACCOUNT_MEMORY_TOOL}`,
163
+ `version: ${memory.version}`,
164
+ ...(memory.updated_at ? [`updated_at: ${yamlString(memory.updated_at)}`] : []),
165
+ `suggestions: ${memory.suggestions.length}`,
166
+ `suggestions_version: ${memory.suggestions_version}`,
167
+ `fetched_at: ${yamlString(fetchedAt)}`,
168
+ `edit_url: ${yamlString(editUrl)}`,
169
+ '---',
170
+ '',
171
+ ];
172
+ const lines = [
173
+ '# Account memory',
174
+ '',
175
+ '> This is a read-only copy of the account memory, the facts about the business that every',
176
+ '> Hiveku department agent reads. Owners and admins edit it on the Hiveku dashboard:',
177
+ `> ${editUrl}`,
178
+ '>',
179
+ '> Changes made to this file are not saved to Hiveku. The next pull replaces this file, and',
180
+ '> nothing uploads it. To add a fact, suggest it with account_memory_append (an owner or admin',
181
+ '> reviews it on the dashboard), or ask an owner or admin to change the document there.',
182
+ '> It is internal to the team: do not quote it to customers.',
183
+ '',
184
+ ];
185
+ if (memory.version > 0 && memory.content.trim()) {
186
+ lines.push(`Version ${memory.version}, last changed ${readableTime(memory.updated_at)}.`);
187
+ if (memory.truncated) {
188
+ lines.push(
189
+ 'Agents see a shortened version: with the suggestions, it is longer than the 8,000 characters they read.',
190
+ );
191
+ }
192
+ lines.push('', '---', '', memory.content.replace(/\s+$/, ''), '', '---', '');
193
+ } else {
194
+ lines.push('Nothing has been written yet. An owner or admin can start it on the dashboard.', '');
195
+ }
196
+
197
+ lines.push('# Suggestions from agents, not reviewed yet', '');
198
+ if (memory.suggestions.length) {
199
+ lines.push(
200
+ 'These lines are not part of the account memory until an owner or admin keeps them on the dashboard.',
201
+ '',
202
+ );
203
+ for (const s of memory.suggestions) {
204
+ lines.push(`- ${oneLine(s.text)} (suggested by ${oneLine(s.source)}, ${readableTime(s.at)})`);
205
+ }
206
+ } else {
207
+ lines.push('None waiting.');
208
+ }
209
+ lines.push('');
210
+ return fm.join('\n') + lines.join('\n');
211
+ }
212
+
213
+ /** Write the file read-only (0444), replacing any earlier read-only copy. */
214
+ export async function writeReadOnly(path, contents) {
215
+ if (existsSync(path)) {
216
+ // A 0444 file can be renamed over on POSIX, but not on Windows; open it up
217
+ // for the replace, then lock it again.
218
+ await chmod(path, 0o644).catch(() => {});
219
+ }
220
+ await writeFileAtomic(path, contents);
221
+ await chmod(path, 0o444);
222
+ }
223
+
224
+ /**
225
+ * The account id for the dashboard link, from get_account_info. The key is
226
+ * pinned to one account, so the first row is it. Any failure (a narrowed
227
+ * profile without the tool, an older server) gives null, and the link falls
228
+ * back to the unscoped dashboard page.
229
+ */
230
+ export async function resolveAccountId(client) {
231
+ try {
232
+ const result = await client.callToolJson('get_account_info', {});
233
+ const data = result?.data ?? result;
234
+ const row = Array.isArray(data) ? data[0] : data;
235
+ const id = typeof row?.id === 'string' ? row.id.trim() : '';
236
+ return UUID_RE.test(id) ? id : null;
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
package/src/codex.mjs CHANGED
@@ -68,6 +68,11 @@ invoked by name) and this file.
68
68
 
69
69
  Non-negotiables (also in CLAUDE.md, restated because they are load-bearing):
70
70
  - **Verify identity before ANY write:** \`get_account_info\` must return THIS account.
71
+ - **Context and memory.** Call \`account_context_get({ domain })\` first, before any copy, plan or analysis.
72
+ The account memory is read-only to you: owners edit it on the Hiveku dashboard, the local copy is
73
+ \`hiveku-data/account/ACCOUNT_MEMORY.md\` (never edit or upload it); suggest one line with \`account_memory_append\`.
74
+ Department memory is ONE document per department: read it (\`memory_list({ domain })\`), merge, then send the
75
+ whole document with \`memory_update({ memory_id, content })\`. Never create-then-overwrite on a 409.
71
76
  - **You are NOT the only writer.** Others push to these same projects while you work. Check what is
72
77
  current BEFORE you start (\`project_version_log\`) and AGAIN before you push (\`project_files_status\`
73
78
  — \`changed\` = they edited it, \`only_remote\` = they added files you lack). Before any tree-replace
@@ -82,6 +87,10 @@ Non-negotiables (also in CLAUDE.md, restated because they are load-bearing):
82
87
  attributed to the authenticated user (\`crm_list_users\`).
83
88
  - **Every completed task ends with an "Owner update"** — 2–4 calm, plain-language sentences a busy owner
84
89
  can skim: benefit first, no alarm vocabulary, accurate.
90
+ - **Hiveku itself in your way?** A Hiveku tool still failing after one sensible retry → \`hiveku_report_issue\`;
91
+ a capability no tool offers (search \`hiveku_docs_search\` first) → \`hiveku_request_feature\`. No secrets or personal data.
92
+ - **Tell the user only if it changes what they get:** one or two calm sentences — flagged to the Hiveku team (give the
93
+ ref), the team is quick to fix these, you'll let them know when it's sorted. No error codes, blame or promised times.
85
94
  - Video generation is paid + capped — \`marketing_generate_video\` with \`dry_run: true\` first.
86
95
 
87
96
  Discover tools with \`hiveku_docs_search\` / \`hiveku_docs_get\` and \`hiveku_playbooks_list\` /
@@ -97,9 +106,17 @@ function codexTomlRegion({ baseUrl, profile, apiKey }) {
97
106
  // Prefer an inlined key (self-contained, no env setup needed; the file is
98
107
  // git-ignored). Fall back to the OLYMPUS_API_KEY env var when we can't
99
108
  // resolve a real key at scaffold time (e.g. `init` before the user pastes it).
109
+ //
110
+ // http_headers is Codex's map of static HTTP headers for a streamable HTTP
111
+ // MCP server (Codex config reference, "mcp_servers.<id>.http_headers"; the
112
+ // docs' example is `http_headers = { "X-Figma-Region" = "us-east-1" }`, next
113
+ // to bearer_token_env_var). TOML allows the key once per table, so the
114
+ // client label rides in the same inline table as an inlined key.
115
+ // X-Hiveku-Client: codex is what the MCP server's memory log shows as
116
+ // "via Codex"; it is a label, never authentication.
100
117
  const auth = apiKey
101
- ? `http_headers = { "Authorization" = "Bearer ${apiKey}" }`
102
- : `bearer_token_env_var = "OLYMPUS_API_KEY"`;
118
+ ? `http_headers = { "Authorization" = "Bearer ${apiKey}", "X-Hiveku-Client" = "codex" }`
119
+ : `bearer_token_env_var = "OLYMPUS_API_KEY"\nhttp_headers = { "X-Hiveku-Client" = "codex" }`;
103
120
  // default_tools_approval_mode = "approve": this folder exists so the agent
104
121
  // can operate the account — per-call approval prompts would also auto-cancel
105
122
  // every tool call in headless `codex exec` (CI/cron), this lane's core use.
package/src/init.mjs CHANGED
@@ -64,6 +64,9 @@ exposes the **${profile}** profile of Olympus tools.
64
64
  - \`rules/\` — synced agent rules
65
65
  - \`skills/\` — synced agent skills
66
66
  - \`commands/\`, \`agents/\`, \`identity/\` — other memory types
67
+ - \`hiveku-data/account/ACCOUNT_MEMORY.md\` — the account memory (the business
68
+ facts every department agent reads), **read-only**: owners and admins edit it on
69
+ the Hiveku dashboard under Account memory, and nothing uploads this file
67
70
  - \`projects/<slug>/\` — coder project source pulled via \`hiveku-sync pull-project\`
68
71
  - \`.hiveku/manifest.json\` — memory last-sync record
69
72
  - \`.hiveku/projects.json\` — project ID ↔ folder mapping
@@ -81,6 +84,16 @@ changes (new skill, updated rule, etc.), call the corresponding MCP tool:
81
84
  commands, agents, identity entries
82
85
  - \`memory_delete\` — to remove an entry
83
86
 
87
+ \`memory_update\` replaces the whole entry. Each department's memory is ONE
88
+ document, so read the current one with \`memory_list({ domain })\`, merge your
89
+ change into its full text, and send the whole document. Use \`memory_create\`
90
+ only when none exists; a 409 means one does, so read and merge, never overwrite.
91
+
92
+ The account memory is the exception: no tool sets it. Read it with
93
+ \`account_memory_get\`, suggest one line with \`account_memory_append\` (an owner
94
+ keeps or removes it), and tell the user that owners and admins edit it on the
95
+ Hiveku dashboard under Account memory.
96
+
84
97
  Then run \`hiveku-sync pull\` to refresh the local copies.
85
98
 
86
99
  ## Coder projects (pull / status / push)
@@ -112,6 +125,14 @@ You can also ask Claude in this workspace to:
112
125
  Claude has the \`project_files_list\`, \`project_file_get\`, \`project_files_status\`,
113
126
  and \`project_commit\` MCP tools available under the **${profile}** profile.
114
127
 
128
+ ## When Hiveku itself gets in your way
129
+
130
+ - A Hiveku tool still failing after one sensible retry → \`hiveku_report_issue\`; a capability
131
+ no tool offers (search \`hiveku_docs_search\` first) → \`hiveku_request_feature\`. No secrets or personal data.
132
+ - Tell the user only if it changes what they get: one or two calm sentences — flagged to the Hiveku
133
+ team (give the ref), the team is quick to fix these, you'll let them know when it's sorted. No
134
+ error codes, blame or promised times.
135
+
115
136
  ## Tenant scope
116
137
 
117
138
  The API key in \`.env\` is **pinned to one Hiveku account**. You cannot switch
package/src/projects.mjs CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  writeFileAtomic,
20
20
  readJson,
21
21
  } from './util.mjs';
22
+ import { isAccountMemoryPath } from './account-memory.mjs';
22
23
  // NOTE: push.mjs imports helpers from this module; the cycle is safe because
23
24
  // both sides reference the imports only inside functions (never at module eval).
24
25
  import { computePushPlan } from './push.mjs';
@@ -118,6 +119,12 @@ async function listAllRemoteFiles(client, projectId) {
118
119
  return all;
119
120
  }
120
121
 
122
+ /**
123
+ * Every local file push, commit and status consider. The pulled account memory
124
+ * (hiveku-data/account/) is left out wherever it sits: it is a read-only copy
125
+ * that owners edit on the dashboard, and nothing may upload it, not even when
126
+ * an account folder and a project folder are the same folder.
127
+ */
121
128
  export async function walkLocalFiles(rootDir) {
122
129
  const out = [];
123
130
  async function recurse(dir) {
@@ -131,6 +138,7 @@ export async function walkLocalFiles(rootDir) {
131
138
  if (entry.name.startsWith('.')) continue;
132
139
  if (IGNORE_DIRS.has(entry.name)) continue;
133
140
  const full = join(dir, entry.name);
141
+ if (isAccountMemoryPath(relative(rootDir, full).split(sep).join('/'))) continue;
134
142
  if (entry.isDirectory()) {
135
143
  await recurse(full);
136
144
  } else if (entry.isFile()) {
package/src/pull.mjs CHANGED
@@ -2,6 +2,11 @@
2
2
  * `hiveku-sync pull` — fetches memory/rules/skills via the MCP memory_list tool
3
3
  * and writes them as local .md files under the current folder.
4
4
  *
5
+ * The account memory (domains `account` and `account-suggestions`) is not a
6
+ * knowledge entry: it is classified explicitly, never written under memory/,
7
+ * and pulled once from account_memory_get into a read-only
8
+ * hiveku-data/account/ACCOUNT_MEMORY.md (see account-memory.mjs).
9
+ *
5
10
  * Safety: refuses to overwrite when the .env API key no longer matches the
6
11
  * manifest's recorded key preview (prevents pulling Account A's data into a
7
12
  * folder previously synced for Account B — the 2026-05-11 cross-tenant bug
@@ -22,6 +27,15 @@ import {
22
27
  TYPE_TO_FOLDER,
23
28
  SUPPORTED_TYPES,
24
29
  } from './util.mjs';
30
+ import {
31
+ ACCOUNT_MEMORY_FILE,
32
+ classifyMemoryEntry,
33
+ ACCOUNT_MEMORY_TOOL,
34
+ normalizeAccountMemory,
35
+ renderAccountMemoryFile,
36
+ resolveAccountId,
37
+ writeReadOnly,
38
+ } from './account-memory.mjs';
25
39
 
26
40
  const MANIFEST_PATH = '.hiveku/manifest.json';
27
41
 
@@ -58,7 +72,19 @@ function renderEntry(entry, type) {
58
72
  return `${frontmatter}${body}`;
59
73
  }
60
74
 
61
- export async function runPull({ baseDir = process.cwd(), profile } = {}) {
75
+ /**
76
+ * Write hiveku-data/account/ACCOUNT_MEMORY.md from account_memory_get, read-only.
77
+ * Returns { file, version, suggestions, accountId }, or throws (the caller
78
+ * records the error and keeps the previous copy).
79
+ */
80
+ export async function pullAccountMemory({ client, baseDir, fetchedAt = new Date().toISOString() }) {
81
+ 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 };
85
+ }
86
+
87
+ export async function runPull({ baseDir = process.cwd(), profile, client: injectedClient } = {}) {
62
88
  const envPath = join(baseDir, '.env');
63
89
  const dotenv = await loadDotenv(envPath);
64
90
  const apiKey = readEnv(process.env, dotenv, 'OLYMPUS_API_KEY');
@@ -88,10 +114,10 @@ export async function runPull({ baseDir = process.cwd(), profile } = {}) {
88
114
  );
89
115
  }
90
116
 
91
- const client = new HivekuMcpClient({ baseUrl, apiKey, profile: activeProfile });
92
- await client.initialize();
117
+ const client = injectedClient || new HivekuMcpClient({ baseUrl, apiKey, profile: activeProfile });
118
+ if (!injectedClient) await client.initialize();
93
119
 
94
- const summary = { total: 0, by_type: {}, errors: [] };
120
+ const summary = { total: 0, by_type: {}, errors: [], account_memory: null, account_rows_skipped: 0 };
95
121
  const writtenFiles = new Set();
96
122
 
97
123
  for (const type of SUPPORTED_TYPES) {
@@ -103,10 +129,19 @@ export async function runPull({ baseDir = process.cwd(), profile } = {}) {
103
129
  summary.errors.push({ type, message: err.message });
104
130
  continue;
105
131
  }
106
- summary.by_type[type] = entries.length;
107
- summary.total += entries.length;
108
-
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 = [];
109
137
  for (const entry of entries) {
138
+ if (classifyMemoryEntry(entry, folder).kind === 'knowledge') knowledge.push(entry);
139
+ else summary.account_rows_skipped += 1;
140
+ }
141
+ summary.by_type[type] = knowledge.length;
142
+ summary.total += knowledge.length;
143
+
144
+ for (const entry of knowledge) {
110
145
  const filename = entryFilename(entry);
111
146
  const fullPath = join(baseDir, folder, filename);
112
147
  await writeFileAtomic(fullPath, renderEntry(entry, type));
@@ -114,6 +149,17 @@ export async function runPull({ baseDir = process.cwd(), profile } = {}) {
114
149
  }
115
150
  }
116
151
 
152
+ // The account memory: one read-only file, never uploaded by anything here.
153
+ // A failure keeps the previous copy (and its manifest line), so it is not
154
+ // reported as removed upstream.
155
+ try {
156
+ summary.account_memory = await pullAccountMemory({ client, baseDir });
157
+ writtenFiles.add(ACCOUNT_MEMORY_FILE);
158
+ } catch (err) {
159
+ summary.errors.push({ type: 'account memory', message: err.message });
160
+ if ((manifest?.files ?? []).includes(ACCOUNT_MEMORY_FILE)) writtenFiles.add(ACCOUNT_MEMORY_FILE);
161
+ }
162
+
117
163
  // v1: don't delete stale files; just record them in the manifest so the
118
164
  // user can prune manually if they want. Avoids losing local edits.
119
165
  const previouslySeen = new Set(manifest?.files ?? []);
@@ -129,6 +175,8 @@ export async function runPull({ baseDir = process.cwd(), profile } = {}) {
129
175
  last_sync_at: new Date().toISOString(),
130
176
  counts: summary.by_type,
131
177
  files: [...writtenFiles].sort(),
178
+ // Pulled for reading only: edited on the dashboard, never uploaded.
179
+ read_only: [ACCOUNT_MEMORY_FILE],
132
180
  removed_remotely_since_last_sync: removedRemotely,
133
181
  },
134
182
  null,