@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/README.md +108 -4
- package/bin/hiveku-sync.mjs +65 -3
- package/package.json +1 -1
- package/src/claude-md.mjs +193 -0
- package/src/codex.mjs +222 -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
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where `pull` writes each memory entry: filed by its OWNER, the way the
|
|
3
|
+
* Memory page files it (memory surfaces audit G14).
|
|
4
|
+
*
|
|
5
|
+
* <type folder>/<owner>/<name>.md an agent's own entry: rules/sales/,
|
|
6
|
+
* skills/seo/, memory/helpdesk/, ...
|
|
7
|
+
* <type folder>/shared/<name>.md an entry no agent owns: the page's
|
|
8
|
+
* "Shared with every agent"
|
|
9
|
+
*
|
|
10
|
+
* The owner is the `owner` field memory_list returns (the builder's one owner
|
|
11
|
+
* rule); an older builder that does not return it gets the same rule from
|
|
12
|
+
* memory-owner.mjs. A Marketing topic is its own owner (rules/seo/,
|
|
13
|
+
* rules/ppc/), and `marketing` is the Marketing parent.
|
|
14
|
+
*
|
|
15
|
+
* <type folder>/<owner>/not-followed/<name>.md
|
|
16
|
+
* an entry filed under an owner that no
|
|
17
|
+
* agent reads, which the Memory page
|
|
18
|
+
* hides: a rule, skill or note filed for
|
|
19
|
+
* the chief of staff outside her
|
|
20
|
+
* `_account:*` rows, an owner that is no
|
|
21
|
+
* agent (`mini`), a note a department
|
|
22
|
+
* owns under a name its agent never
|
|
23
|
+
* reads. memory_list's `placement` says
|
|
24
|
+
* so (hidden, a reason other than
|
|
25
|
+
* `system`), or ownedPlacement does.
|
|
26
|
+
* Kept out of the Codex rules digest and
|
|
27
|
+
* skill mirror, which read one folder
|
|
28
|
+
* level only.
|
|
29
|
+
*
|
|
30
|
+
* The `_account:*` rows are not an agent's own entries. They are placed the
|
|
31
|
+
* way the page shows them (memory_list's `placement`, or accountPlacement),
|
|
32
|
+
* and written read-only, because owners and admins change them on the Memory
|
|
33
|
+
* page:
|
|
34
|
+
*
|
|
35
|
+
* memory/orchestrator/how-it-works.md `_account:soul` (the chief of staff's How it works)
|
|
36
|
+
* memory/orchestrator/background.md `_account:claude` (her Background)
|
|
37
|
+
* rules/orchestrator/<slug>.md `_account:_rule:<slug>`
|
|
38
|
+
* skills/orchestrator/<slug>.md `_account:_skill:<slug>`
|
|
39
|
+
* memory/orchestrator/<topic>.md `_account:memory:<topic>` (her notes)
|
|
40
|
+
* memory/orchestrator/moved/<topic>.md the five topics now kept in About your
|
|
41
|
+
* business: her old copies, each opening
|
|
42
|
+
* with a line that says where the current
|
|
43
|
+
* text is
|
|
44
|
+
* hiveku-data/account/voice/<name>.md Voice and pronunciation (About your business)
|
|
45
|
+
*
|
|
46
|
+
* Rows the page never shows (internal state, an old `_account:*` shape) are
|
|
47
|
+
* not written. Pure: it plans the files; pull.mjs writes them.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { createHash } from 'node:crypto';
|
|
51
|
+
|
|
52
|
+
import { accountMemoryDashboardUrl, ACCOUNT_DATA_DIR, ACCOUNT_MEMORY_FILE } from './account-memory.mjs';
|
|
53
|
+
import {
|
|
54
|
+
SHARED_OWNER,
|
|
55
|
+
accountPlacement,
|
|
56
|
+
accountPlacementLabel,
|
|
57
|
+
isAccountMemoryRow,
|
|
58
|
+
isAccountScopedRow,
|
|
59
|
+
isSystemRow,
|
|
60
|
+
memoryTypeOf,
|
|
61
|
+
movedTopicHeading,
|
|
62
|
+
ownedPlacement,
|
|
63
|
+
ownerOf,
|
|
64
|
+
} from './memory-owner.mjs';
|
|
65
|
+
import { safeSlug, toFrontmatter, TYPE_TO_FOLDER } from './util.mjs';
|
|
66
|
+
|
|
67
|
+
/** Voice and pronunciation, under About your business (read-only, never uploaded: push leaves hiveku-data/account/ out). */
|
|
68
|
+
export const VOICE_DIR = `${ACCOUNT_DATA_DIR}/voice`;
|
|
69
|
+
|
|
70
|
+
/** The subfolder of an owner's folder that holds the entries no agent reads (the page hides them). */
|
|
71
|
+
export const NOT_FOLLOWED_DIR = 'not-followed';
|
|
72
|
+
|
|
73
|
+
/** The page's words for a row it does not show (builder memory-placement.ts NOT_ON_MEMORY_PAGE). */
|
|
74
|
+
const NOT_SHOWN_LABEL = 'Not shown on the Memory page';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The line a moved topic's file opens with: where the topic went, where the
|
|
78
|
+
* current text is, and that this is the chief of staff's old copy. True
|
|
79
|
+
* whether or not the topic was copied across yet (the page says which).
|
|
80
|
+
*/
|
|
81
|
+
export function movedTopicPointer(domain) {
|
|
82
|
+
const heading = movedTopicHeading(domain);
|
|
83
|
+
const where = heading ? `Moved to About your business, under "${heading}".` : 'Moved to About your business.';
|
|
84
|
+
return `> ${where} The current text is in \`${ACCOUNT_MEMORY_FILE}\`. This file is the chief of staff's old copy and may be out of date.`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Owners whose name cannot be a folder file under this one (the true owner stays in the front matter). */
|
|
88
|
+
export const OTHER_OWNER_FOLDER = 'other';
|
|
89
|
+
|
|
90
|
+
/** A lowercase name that is a folder on every system: a letter first, 50 characters at most. */
|
|
91
|
+
const FOLDER_NAME = /^[a-z][a-z0-9_-]{0,49}$/;
|
|
92
|
+
|
|
93
|
+
/** Names Windows keeps for devices, with or without an extension: no folder or file may use one. */
|
|
94
|
+
const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\..*)?$/i;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* File names an agent tool would read as its own instructions: Claude Code
|
|
98
|
+
* loads a folder's CLAUDE.md when it reads files there, and Codex reads
|
|
99
|
+
* AGENTS.md. On a case-insensitive disk `claude.md` is that file, so the chief
|
|
100
|
+
* of staff's Background (`_account:claude`) or a note named `claude` must
|
|
101
|
+
* never be written under that name.
|
|
102
|
+
*/
|
|
103
|
+
const INSTRUCTION_FILE_NAME = /^(?:claude|agents)(?:\..*)?$/i;
|
|
104
|
+
|
|
105
|
+
/** Added after the first part of a file name that is kept (the Hiveku plugin's DEVICE_STEM_SUFFIX). */
|
|
106
|
+
const KEPT_NAME_SUFFIX = '-entry';
|
|
107
|
+
|
|
108
|
+
/** The folder an owner's entries go in: `shared` for no owner. */
|
|
109
|
+
export function ownerFolder(owner) {
|
|
110
|
+
if (owner === null || owner === undefined || owner === '') return SHARED_OWNER;
|
|
111
|
+
const clean = String(owner)
|
|
112
|
+
.toLowerCase()
|
|
113
|
+
.replace(/[^a-z0-9_-]+/g, '_')
|
|
114
|
+
.replace(/^[^a-z]+/, '')
|
|
115
|
+
.slice(0, 50);
|
|
116
|
+
return FOLDER_NAME.test(clean) && !WINDOWS_DEVICE_NAME.test(clean) ? clean : OTHER_OWNER_FOLDER;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A file name (without `.md`) for an entry name: a slug, never hidden, never a kept name. */
|
|
120
|
+
export function fileStem(name) {
|
|
121
|
+
const slug = safeSlug(name).replace(/^\.+/, '') || 'unnamed';
|
|
122
|
+
if (WINDOWS_DEVICE_NAME.test(slug) || INSTRUCTION_FILE_NAME.test(slug)) {
|
|
123
|
+
return slug.replace(/^[^.]*/, (head) => head + KEPT_NAME_SUFFIX);
|
|
124
|
+
}
|
|
125
|
+
return slug;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** An entry's name: memory_list's `name`, else the domain without its type prefix. */
|
|
129
|
+
function entryName(entry) {
|
|
130
|
+
if (typeof entry.name === 'string' && entry.name.trim()) return entry.name;
|
|
131
|
+
return String(entry.domain || '').replace(/^_[^:]+:/, '');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const PLACES = new Set(['agent', 'shared', 'business', 'hidden']);
|
|
135
|
+
|
|
136
|
+
/** memory_list's `placement`, when the builder returned one in the shape it documents. */
|
|
137
|
+
function builderPlacement(entry) {
|
|
138
|
+
const p = entry?.placement;
|
|
139
|
+
if (!p || typeof p !== 'object' || Array.isArray(p) || !PLACES.has(p.where)) return null;
|
|
140
|
+
if (p.where === 'agent' && (typeof p.agent !== 'string' || typeof p.group !== 'string')) return null;
|
|
141
|
+
return p;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** memory_list's `owner`, when the builder returned the field (null is an answer: no owner). */
|
|
145
|
+
function builderOwner(entry) {
|
|
146
|
+
if (!entry || !Object.prototype.hasOwnProperty.call(entry, 'owner')) return { known: false };
|
|
147
|
+
const owner = entry.owner;
|
|
148
|
+
if (owner === null) return { known: true, owner: null };
|
|
149
|
+
if (typeof owner === 'string') return { known: true, owner: owner.trim().toLowerCase() || null };
|
|
150
|
+
return { known: false };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The Memory page link for an `_account:*` row: memory_list's own, or built like the builder's memoryPageUrl. */
|
|
154
|
+
function accountEditUrl(entry, placement, accountId, appUrl) {
|
|
155
|
+
if (typeof entry.memory_page_url === 'string' && /^https:\/\//.test(entry.memory_page_url)) return entry.memory_page_url;
|
|
156
|
+
const page = accountMemoryDashboardUrl(accountId, appUrl);
|
|
157
|
+
const query = new URLSearchParams();
|
|
158
|
+
if (placement.where === 'agent' && placement.agent === 'orchestrator') {
|
|
159
|
+
query.set('agent', 'orchestrator');
|
|
160
|
+
const item = String(entry.domain || '').trim();
|
|
161
|
+
if (item && item.length <= 60) query.set('item', item);
|
|
162
|
+
} else if (placement.where === 'business' && placement.group === 'voice') {
|
|
163
|
+
query.set('open', 'voice');
|
|
164
|
+
}
|
|
165
|
+
const search = query.toString();
|
|
166
|
+
return search ? `${page}?${search}` : page;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The slug of an `_account:*` row after its prefixes: `_account:_rule:x` is `x`. */
|
|
170
|
+
function accountSlug(domain) {
|
|
171
|
+
return String(domain || '')
|
|
172
|
+
.trim()
|
|
173
|
+
.replace(/^_account:/i, '')
|
|
174
|
+
.replace(/^(?:_rule:|_skill:|_command:|_agent:|memory:)/i, '');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Folder, type and name for an `_account:*` row the page shows, or null. */
|
|
178
|
+
function accountTarget(entry, placement) {
|
|
179
|
+
const slug = accountSlug(entry.domain);
|
|
180
|
+
if (placement.where === 'business') {
|
|
181
|
+
return placement.group === 'voice' ? { dir: VOICE_DIR, type: 'memory', name: slug } : null;
|
|
182
|
+
}
|
|
183
|
+
if (placement.where === 'hidden') return null;
|
|
184
|
+
const owner = placement.where === 'shared' ? SHARED_OWNER : ownerFolder(placement.agent);
|
|
185
|
+
switch (placement.group) {
|
|
186
|
+
case 'how-it-works':
|
|
187
|
+
return { dir: `memory/${owner}`, type: 'memory', name: 'how-it-works' };
|
|
188
|
+
case 'background':
|
|
189
|
+
return { dir: `memory/${owner}`, type: 'memory', name: 'background' };
|
|
190
|
+
case 'rules':
|
|
191
|
+
return { dir: `rules/${owner}`, type: 'rule', name: slug };
|
|
192
|
+
case 'skills':
|
|
193
|
+
return { dir: `skills/${owner}`, type: 'skill', name: slug };
|
|
194
|
+
case 'shortcuts':
|
|
195
|
+
return { dir: `commands/${owner}`, type: 'command', name: slug };
|
|
196
|
+
case 'specialists':
|
|
197
|
+
return { dir: `agents/${owner}`, type: 'agent', name: slug };
|
|
198
|
+
case 'profile':
|
|
199
|
+
return { dir: `identity/${owner}`, type: 'identity', name: slug };
|
|
200
|
+
case 'moved':
|
|
201
|
+
return { dir: `memory/${owner}/moved`, type: 'memory', name: slug };
|
|
202
|
+
default:
|
|
203
|
+
return { dir: `memory/${owner}`, type: 'memory', name: slug };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function shortHash(text) {
|
|
208
|
+
return createHash('sha256').update(String(text), 'utf8').digest('hex').slice(0, 6);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Plan every file one pull writes. `entries` is memory_list's `data` (every
|
|
213
|
+
* account-level row, any type). Returns
|
|
214
|
+
* files: [{ rel, body, readOnly, notFollowed, type, owner, domain }] in domain order
|
|
215
|
+
* skipped: { account, system, hidden } rows deliberately not written
|
|
216
|
+
* listed: every domain the listing returned (moved or skipped rows included)
|
|
217
|
+
* Two entries whose names reduce to the same file get distinct files: the
|
|
218
|
+
* later one (in domain order) gets a short hash of its domain.
|
|
219
|
+
*/
|
|
220
|
+
export function planKnowledgeFiles(entries, { accountId = null, appUrl } = {}) {
|
|
221
|
+
const skipped = { account: 0, system: 0, hidden: 0 };
|
|
222
|
+
const listed = new Set();
|
|
223
|
+
const files = [];
|
|
224
|
+
const taken = new Set();
|
|
225
|
+
const rows = entries
|
|
226
|
+
.filter((e) => e && typeof e.domain === 'string' && e.domain.trim())
|
|
227
|
+
.sort((a, b) => (a.domain < b.domain ? -1 : a.domain > b.domain ? 1 : 0));
|
|
228
|
+
|
|
229
|
+
for (const entry of rows) {
|
|
230
|
+
listed.add(entry.domain);
|
|
231
|
+
// About your business: written once from account_memory_get (account-memory.mjs).
|
|
232
|
+
if (isAccountMemoryRow(entry.domain)) {
|
|
233
|
+
skipped.account += 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const placementFromBuilder = builderPlacement(entry);
|
|
237
|
+
let target;
|
|
238
|
+
let owner;
|
|
239
|
+
let shownUnder = typeof placementFromBuilder?.label === 'string' ? placementFromBuilder.label : null;
|
|
240
|
+
let readOnly = false;
|
|
241
|
+
let editUrl = null;
|
|
242
|
+
let notFollowed = false;
|
|
243
|
+
let pointer = null;
|
|
244
|
+
|
|
245
|
+
if (isAccountScopedRow(entry.domain)) {
|
|
246
|
+
const placement = placementFromBuilder ?? accountPlacement(entry.domain, entry.content);
|
|
247
|
+
target = accountTarget(entry, placement);
|
|
248
|
+
if (!target) {
|
|
249
|
+
skipped.hidden += 1;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
owner = placement.where === 'agent' ? placement.agent : null;
|
|
253
|
+
shownUnder ??= accountPlacementLabel(placement);
|
|
254
|
+
readOnly = true;
|
|
255
|
+
editUrl = accountEditUrl(entry, placement, accountId, appUrl);
|
|
256
|
+
if (placement.where === 'agent' && placement.group === 'moved') pointer = movedTopicPointer(entry.domain);
|
|
257
|
+
} else {
|
|
258
|
+
const system = placementFromBuilder
|
|
259
|
+
? placementFromBuilder.where === 'hidden' && placementFromBuilder.reason === 'system'
|
|
260
|
+
: isSystemRow(entry.domain, entry.content);
|
|
261
|
+
const type = memoryTypeOf(entry.domain);
|
|
262
|
+
if (system || !type) {
|
|
263
|
+
skipped.system += 1;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const fromBuilder = builderOwner(entry);
|
|
267
|
+
owner = fromBuilder.known ? fromBuilder.owner : ownerOf(entry);
|
|
268
|
+
// Hidden for any other reason: filed under its owner, in the folder no agent reads.
|
|
269
|
+
const placement = placementFromBuilder ?? ownedPlacement(entry.domain, owner);
|
|
270
|
+
notFollowed = placement.where === 'hidden';
|
|
271
|
+
if (notFollowed) shownUnder ??= NOT_SHOWN_LABEL;
|
|
272
|
+
const dir = `${TYPE_TO_FOLDER[type]}/${ownerFolder(owner)}`;
|
|
273
|
+
target = { dir: notFollowed ? `${dir}/${NOT_FOLLOWED_DIR}` : dir, type, name: entryName(entry) };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let rel = `${target.dir}/${fileStem(target.name)}.md`;
|
|
277
|
+
if (taken.has(rel.toLowerCase())) rel = `${target.dir}/${fileStem(target.name)}-${shortHash(entry.domain)}.md`;
|
|
278
|
+
taken.add(rel.toLowerCase());
|
|
279
|
+
|
|
280
|
+
const frontmatter = toFrontmatter({
|
|
281
|
+
id: entry.id,
|
|
282
|
+
name: entry.name,
|
|
283
|
+
type: target.type,
|
|
284
|
+
domain: entry.domain,
|
|
285
|
+
owner: owner ?? SHARED_OWNER,
|
|
286
|
+
shown_under: shownUnder ?? undefined,
|
|
287
|
+
followed_by: notFollowed ? 'no agent' : undefined,
|
|
288
|
+
read_only: readOnly ? 'true' : undefined,
|
|
289
|
+
edit_url: editUrl ?? undefined,
|
|
290
|
+
memory_page_url: !readOnly && typeof entry.memory_page_url === 'string' ? entry.memory_page_url : undefined,
|
|
291
|
+
project_id: entry.project_id,
|
|
292
|
+
version: entry.version,
|
|
293
|
+
updated_at: entry.updated_at,
|
|
294
|
+
});
|
|
295
|
+
files.push({
|
|
296
|
+
rel,
|
|
297
|
+
body: `${frontmatter}${pointer ? `${pointer}\n\n` : ''}${entry.content ?? ''}`,
|
|
298
|
+
readOnly,
|
|
299
|
+
notFollowed,
|
|
300
|
+
type: target.type,
|
|
301
|
+
owner: owner ?? null,
|
|
302
|
+
domain: entry.domain,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return { files, skipped, listed };
|
|
306
|
+
}
|