@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.
@@ -0,0 +1,464 @@
1
+ /**
2
+ * Who owns a memory entry, and where the Memory page shows the rows that are
3
+ * not an agent's own (memory surfaces audit G14 and G9).
4
+ *
5
+ * The builder is the source of truth. Its memory_list answers each entry with
6
+ * `owner` (a department key, or null when the entry is shared with every
7
+ * agent) and `placement` (where the Memory page shows it). `pull` files each
8
+ * entry by those fields whenever they are present. This module is the
9
+ * FALLBACK for a builder that does not return them yet, and it is a port of
10
+ * the builder's own rules, so a folder pulled from either builder lands the
11
+ * same way:
12
+ *
13
+ * ownerOf src/lib/olympus/memory-department.ts resolveOwnerDepartment,
14
+ * with the one owner rule's Marketing exception for typed
15
+ * rows (below).
16
+ * accountPlacement src/components/memory/tree/tree-model.ts placeRow, for the
17
+ * `_account:*` rows (the chief of staff's own memory, and
18
+ * Voice and pronunciation under About your business).
19
+ * ownedPlacement the same placeRow for every other row, from its owner:
20
+ * which agent the page shows it under, or that the page
21
+ * hides it because no agent reads it (placeOwned).
22
+ * isSystemRow the same file's rule 7: rows the page never shows.
23
+ *
24
+ * THE ONE OWNER RULE for a typed row (`_rule:`, `_skill:`, `_command:`, `_agent:`):
25
+ * (a) the `department` column, when it is set and is not 'marketing';
26
+ * (b) when the column is empty or 'marketing' and the text carries a
27
+ * canonical Marketing-family marker (`<!-- department: x -->`, x in the
28
+ * Marketing family), that topic. These are the seeded starter rows
29
+ * (column 'marketing' plus a topic marker);
30
+ * (c) column 'marketing' with no family marker: the Marketing parent itself;
31
+ * (d) column empty: the marker, then the front matter, then the shape of the
32
+ * domain, else no owner (shared with every agent).
33
+ * Every other row: `_identity:*` by its slug (a canonical department), else
34
+ * the department its front matter declares; the rest by the column, then the
35
+ * marker, then the front matter, then the shape of the domain.
36
+ *
37
+ * Pure: no files, no network.
38
+ */
39
+
40
+ /**
41
+ * The Marketing family: the Marketing parent and its topics. Peers, each with
42
+ * its own owner key (builder src/lib/olympus/memory-types.ts
43
+ * MARKETING_FAMILY_DEPARTMENTS). Analytics is a Marketing topic (audit
44
+ * decision 6).
45
+ */
46
+ export const MARKETING_FAMILY = Object.freeze([
47
+ 'marketing',
48
+ 'content',
49
+ 'seo',
50
+ 'social',
51
+ 'ppc',
52
+ 'outbound',
53
+ 'branding',
54
+ 'customer_avatar',
55
+ 'customer_journey',
56
+ 'website_design',
57
+ 'knowledge_base',
58
+ 'workflow',
59
+ 'before_after_grid',
60
+ 'email',
61
+ 'analytics',
62
+ ]);
63
+
64
+ /** Every canonical department (memory-types.ts VALID_DEPARTMENTS). */
65
+ export const DEPARTMENTS = Object.freeze([
66
+ ...MARKETING_FAMILY,
67
+ 'sales',
68
+ 'helpdesk',
69
+ 'production',
70
+ 'accounting',
71
+ 'comms',
72
+ 'coder',
73
+ 'orchestrator',
74
+ ]);
75
+
76
+ const DEPARTMENT_SET = new Set(DEPARTMENTS);
77
+ const MARKETING_SET = new Set(MARKETING_FAMILY);
78
+
79
+ export function isDepartment(value) {
80
+ return typeof value === 'string' && DEPARTMENT_SET.has(value);
81
+ }
82
+
83
+ export function isMarketingTopic(value) {
84
+ return typeof value === 'string' && MARKETING_SET.has(value);
85
+ }
86
+
87
+ /** Trim and lowercase, blank to null (memory-department.ts normalize). */
88
+ function normalize(value) {
89
+ const v = typeof value === 'string' ? value.trim().toLowerCase() : '';
90
+ return v || null;
91
+ }
92
+
93
+ // ─── Reading the declarations out of the text ────────────────────────────
94
+
95
+ /** The first `<!-- department: x -->` anywhere (JavaScript's \w is [A-Za-z0-9_]). */
96
+ const MARKER_RE = /<!--\s*department:\s*(\w+)\s*-->/i;
97
+
98
+ /**
99
+ * The one marker line parseIdentity skips above front matter
100
+ * (agent-identity.ts LEADING_DEPARTMENT_MARKER: ASCII whitespace, an ASCII word).
101
+ */
102
+ const LEADING_MARKER_RE =
103
+ /^[ \t\n\r\f\v]*<!--[ \t\n\r\f\v]*department:[ \t\n\r\f\v]*[A-Za-z0-9_]+[ \t\n\r\f\v]*-->[ \t\n\r\f\v]*\n/i;
104
+
105
+ /** A double-quoted front matter value, decoded the way the identity editor wrote it. */
106
+ function decodeQuoted(inner) {
107
+ try {
108
+ const decoded = JSON.parse(`"${inner}"`);
109
+ return typeof decoded === 'string' ? decoded : inner;
110
+ } catch {
111
+ return inner;
112
+ }
113
+ }
114
+
115
+ /** The `department:` field of the front matter at the top (parseIdentity), raw, or ''. */
116
+ function frontMatterDepartmentRaw(content) {
117
+ const withoutTag = String(content ?? '').replace(LEADING_MARKER_RE, '');
118
+ const fm = withoutTag.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
119
+ const header = fm?.[1] ?? '';
120
+ const m = header.match(/^department:\s*"((?:[^"\\\n]|\\.)*)"|^department:\s*(\S.*)$/im);
121
+ if (!m) return '';
122
+ const value = m[1] !== undefined ? decodeQuoted(m[1]) : (m[2] ?? '');
123
+ return value.replace(/^\s+/, '');
124
+ }
125
+
126
+ /** The first marker, when it names a canonical department. */
127
+ function markerDepartment(content) {
128
+ const marker = normalize(String(content ?? '').match(MARKER_RE)?.[1]);
129
+ return marker && isDepartment(marker) ? marker : null;
130
+ }
131
+
132
+ /**
133
+ * An explicit declaration in the text (memory-department.ts
134
+ * departmentTagFromContent): the first marker, then the front matter's
135
+ * `department:`, each only when it is a canonical department. A prose line
136
+ * such as "department: Engineering" is not a declaration.
137
+ */
138
+ export function departmentTagFromContent(content) {
139
+ if (!content) return null;
140
+ const marker = markerDepartment(content);
141
+ if (marker) return marker;
142
+ const declared = normalize(frontMatterDepartmentRaw(content));
143
+ return declared && isDepartment(declared) ? declared : null;
144
+ }
145
+
146
+ /** memory-types.ts deriveDepartmentFromDomain: a plain department name, or an `_identity:<dept>`. */
147
+ export function domainShapeDepartment(domain) {
148
+ if (typeof domain !== 'string' || !domain) return null;
149
+ if (domain.startsWith('_identity:')) {
150
+ const dept = domain.slice('_identity:'.length).trim().toLowerCase();
151
+ return isDepartment(dept) ? dept : null;
152
+ }
153
+ for (const prefix of ['_skill:', '_rule:', '_command:', '_agent:', '_workspace:', '_account:']) {
154
+ if (domain.startsWith(prefix)) return null;
155
+ }
156
+ return isDepartment(domain) ? domain : null;
157
+ }
158
+
159
+ // ─── Kinds of row ────────────────────────────────────────────────────────
160
+
161
+ const ACCOUNT_MEMORY_DOMAINS = new Set(['account', 'account-suggestions']);
162
+
163
+ /** The account memory's two rows (About your business): never a department's. */
164
+ export function isAccountMemoryRow(domain) {
165
+ return typeof domain === 'string' && ACCOUNT_MEMORY_DOMAINS.has(domain.trim().toLowerCase());
166
+ }
167
+
168
+ function lower(domain) {
169
+ return typeof domain === 'string' ? domain.trim().toLowerCase() : '';
170
+ }
171
+
172
+ /** `_account:*`: the chief of staff's own memory, and Voice and pronunciation. */
173
+ export function isAccountScopedRow(domain) {
174
+ return lower(domain).startsWith('_account:');
175
+ }
176
+
177
+ const TYPE_PREFIXES = [
178
+ ['_skill:', 'skill'],
179
+ ['_rule:', 'rule'],
180
+ ['_command:', 'command'],
181
+ ['_agent:', 'agent'],
182
+ ['_identity:', 'identity'],
183
+ ];
184
+
185
+ /**
186
+ * The memory type a domain holds (memory-types.ts inferType), or null for an
187
+ * `_<word>:` shape that is not one (internal state no page draws). A plain
188
+ * domain (`sales`, `seo`) is an agent's notes: type `memory`.
189
+ */
190
+ export function memoryTypeOf(domain) {
191
+ const d = lower(domain);
192
+ for (const [prefix, type] of TYPE_PREFIXES) if (d.startsWith(prefix)) return type;
193
+ if (/^_[a-z_]+:/.test(d)) return null;
194
+ return 'memory';
195
+ }
196
+
197
+ /** A rule, skill, shortcut or specialist: the rows the one owner rule's Marketing exception covers. */
198
+ export function isTypedRow(domain) {
199
+ const type = memoryTypeOf(domain);
200
+ return type === 'rule' || type === 'skill' || type === 'command' || type === 'agent';
201
+ }
202
+
203
+ // ─── The security preamble the agent servers add ─────────────────────────
204
+
205
+ /** memory-sanitize.ts SECURITY_MARKER: text the agent servers inject, matched byte for byte. */
206
+ const SECURITY_MARKER = '# Security context — read this BEFORE every action';
207
+ const PREAMBLE_TERMINATOR_RE = /^─{4,}\s*$/m;
208
+ const TOP_HEADING_RE = /^# /m;
209
+
210
+ /** memory-sanitize.ts stripAgentPreamble. */
211
+ function stripAgentPreamble(content) {
212
+ let out = content;
213
+ for (;;) {
214
+ const i = out.indexOf(SECURITY_MARKER);
215
+ if (i < 0) return out;
216
+ const rest = out.slice(i);
217
+ const m = rest.match(PREAMBLE_TERMINATOR_RE);
218
+ let end;
219
+ if (m) {
220
+ end = (m.index ?? 0) + m[0].length;
221
+ } else {
222
+ const next = rest.slice(SECURITY_MARKER.length).match(TOP_HEADING_RE);
223
+ end = next ? SECURITY_MARKER.length + next.index : rest.length;
224
+ }
225
+ out = (out.slice(0, i) + rest.slice(end)).replace(/^\n+/, '');
226
+ }
227
+ }
228
+
229
+ /** The row holds nothing but the agents' own security preamble: never an owner's memory. */
230
+ export function isPreambleOnly(content) {
231
+ const text = typeof content === 'string' ? content : '';
232
+ return text.includes(SECURITY_MARKER) && stripAgentPreamble(text).trim() === '';
233
+ }
234
+
235
+ /** Never shown on the Memory page (tree-model.ts rule 7), so never mirrored. */
236
+ const SYSTEM_PREFIXES = ['_workspace:', '_digest:', '_custom_field:'];
237
+
238
+ /**
239
+ * Internal state, never an owner's memory: the system prefixes, any other
240
+ * `_<word>:` shape no page draws, and a row that holds nothing but the
241
+ * security preamble. `_account:*` rows are placed by accountPlacement instead.
242
+ */
243
+ export function isSystemRow(domain, content) {
244
+ const d = lower(domain);
245
+ if (SYSTEM_PREFIXES.some((prefix) => d.startsWith(prefix))) return true;
246
+ if (isPreambleOnly(content)) return true;
247
+ if (!d.startsWith('_account:') && memoryTypeOf(d) === null) return true;
248
+ return false;
249
+ }
250
+
251
+ // ─── The owner ───────────────────────────────────────────────────────────
252
+
253
+ /**
254
+ * The department that owns an entry, or null when no agent does (it is
255
+ * shared with every agent). `row` is a memory_list entry: `domain`,
256
+ * `department` (the stored column) and `content`.
257
+ */
258
+ export function ownerOf(row) {
259
+ const domain = row?.domain;
260
+ const content = row?.content ?? '';
261
+ if (isAccountMemoryRow(domain)) return 'account';
262
+ const d = lower(domain);
263
+ if (d.startsWith('_identity:')) {
264
+ // The slug first (the only signal two departments cannot share), then the
265
+ // department the front matter declares, whatever it is.
266
+ const slug = d.slice('_identity:'.length).trim();
267
+ if (isDepartment(slug)) return slug;
268
+ return normalize(frontMatterDepartmentRaw(content));
269
+ }
270
+ const column = normalize(row?.department);
271
+ if (isTypedRow(domain)) {
272
+ if (column && column !== 'marketing') return column; // (a)
273
+ const marker = markerDepartment(content);
274
+ if (marker && isMarketingTopic(marker)) return marker; // (b)
275
+ if (column === 'marketing') return 'marketing'; // (c)
276
+ return departmentTagFromContent(content) ?? domainShapeDepartment(domain); // (d)
277
+ }
278
+ return column ?? departmentTagFromContent(content) ?? domainShapeDepartment(domain);
279
+ }
280
+
281
+ // ─── Where the page shows an `_account:*` row ────────────────────────────
282
+
283
+ /**
284
+ * The five business topics the chief of staff used to keep, now in About your
285
+ * business, each with the heading it has there (builder
286
+ * src/lib/account-memory/import-draft.ts TEMPLATE_SECTIONS).
287
+ */
288
+ const MOVED_TOPIC_HEADINGS = Object.freeze({
289
+ '_account:memory:about-this-business': 'About the business',
290
+ '_account:memory:team-and-roles': 'Team and roles',
291
+ '_account:memory:current-quarter-goals': 'Goals right now',
292
+ '_account:memory:active-initiatives': 'Active initiatives',
293
+ '_account:memory:user-preferences': 'How we like to work',
294
+ });
295
+ export const MOVED_TOPICS = Object.freeze(Object.keys(MOVED_TOPIC_HEADINGS));
296
+ const MOVED_SET = new Set(MOVED_TOPICS);
297
+
298
+ /** The About your business heading a moved topic went to, or null for a topic this release does not know. */
299
+ export function movedTopicHeading(domain) {
300
+ return MOVED_TOPIC_HEADINGS[lower(domain)] ?? null;
301
+ }
302
+
303
+ const VOICE_DOMAINS = new Set(['_account:pronunciations', '_account:voice_settings']);
304
+
305
+ function orchestrator(group) {
306
+ return { where: 'agent', agent: 'orchestrator', topic: null, group };
307
+ }
308
+
309
+ /**
310
+ * Where the Memory page shows an `_account:*` row (tree-model.ts placeRow,
311
+ * rules 2 and 3), in the builder's `placement` shape:
312
+ * Voice and pronunciation { where: 'business', group: 'voice' }
313
+ * the chief of staff { where: 'agent', agent: 'orchestrator', group }
314
+ * `soul` How it works, `claude` Background,
315
+ * `_skill:*`, `_rule:*`, `memory:*` (notes, or
316
+ * "Moved to About your business")
317
+ * anything else { where: 'hidden', reason }
318
+ */
319
+ export function accountPlacement(domain, content) {
320
+ const d = lower(domain);
321
+ if (isPreambleOnly(content)) return { where: 'hidden', reason: 'system' };
322
+ if (VOICE_DOMAINS.has(d)) return { where: 'business', group: 'voice' };
323
+ if (d === '_account:soul') return orchestrator('how-it-works');
324
+ if (d === '_account:claude') return orchestrator('background');
325
+ if (d.startsWith('_account:_skill:')) return orchestrator('skills');
326
+ if (d.startsWith('_account:_rule:')) return orchestrator('rules');
327
+ if (d.startsWith('_account:memory:')) return orchestrator(MOVED_SET.has(d) ? 'moved' : 'notes');
328
+ return { where: 'hidden', reason: 'legacy' };
329
+ }
330
+
331
+ // ─── Where the page shows every other row ────────────────────────────────
332
+
333
+ /** The team the Memory page lists (builder tree/team-keys.ts TEAM_AGENTS). */
334
+ export const TEAM_AGENTS = Object.freeze(['orchestrator', 'sales', 'helpdesk', 'marketing', 'production', 'accounting', 'coder', 'comms']);
335
+ const TEAM_SET = new Set(TEAM_AGENTS);
336
+
337
+ /**
338
+ * The departments whose own notes are ONE plain domain, their own name
339
+ * (tree-model.ts ONE_NOTES_NAME_DEPARTMENTS): their agents read that one
340
+ * document (the sales server reads `domain = 'sales'`), so a note one of them
341
+ * owns under another plain name is one its agent never reads.
342
+ */
343
+ const ONE_NOTES_NAME_DEPARTMENTS = new Set(['sales', 'helpdesk', 'comms', 'production', 'accounting', 'coder']);
344
+
345
+ /** The page's group for each type of row (tree-model.ts KIND_GROUP). */
346
+ const TYPE_GROUP = Object.freeze({
347
+ rule: 'rules',
348
+ skill: 'skills',
349
+ command: 'shortcuts',
350
+ agent: 'specialists',
351
+ identity: 'profile',
352
+ memory: 'notes',
353
+ });
354
+
355
+ /** The Marketing topic whose profile and notes are documents about customers (team-keys.ts IDEAL_CUSTOMERS_TOPIC). */
356
+ const IDEAL_CUSTOMERS_TOPIC = 'customer_avatar';
357
+
358
+ /**
359
+ * Where the Memory page shows a row that is neither `_account:*` nor internal,
360
+ * given its owner (the builder's `owner`, or ownerOf): tree-model.ts placeRow
361
+ * rules 4 to 6 and placeOwned, in memory_list's `placement` shape.
362
+ * no owner { where: 'shared', group }
363
+ * a note a department in ONE_NOTES_NAME_DEPARTMENTS owns under a plain name
364
+ * other than its own (`pipeline-notes`, column sales), or any row but a
365
+ * profile the chief of staff owns outside her `_account:*` rows
366
+ * { where: 'hidden', reason: 'unlisted' }
367
+ * a team agent { where: 'agent', agent, topic: null, group }
368
+ * a Marketing topic { where: 'agent', agent: 'marketing', topic, group }
369
+ * an owner that is no agent or topic (`mini`, `graphic_design`)
370
+ * { where: 'hidden', reason: 'no-agent' }
371
+ * A hidden row is one no agent reads: the chief of staff reads only her
372
+ * `_account:*` rows and profiles, and the builder's follow rule
373
+ * (memory-department.ts followersOfOwner) gives an orchestrator or no-agent
374
+ * owner no followers.
375
+ */
376
+ export function ownedPlacement(domain, owner) {
377
+ const type = memoryTypeOf(domain) ?? 'memory';
378
+ const group = TYPE_GROUP[type];
379
+ if (owner === null || owner === undefined || owner === '') return { where: 'shared', group };
380
+ // The page compares the stored name exactly, as the agents' own reads do.
381
+ if (type === 'memory' && ONE_NOTES_NAME_DEPARTMENTS.has(owner) && domain !== owner) return { where: 'hidden', reason: 'unlisted' };
382
+ if (owner === 'orchestrator' && type !== 'identity') return { where: 'hidden', reason: 'unlisted' };
383
+ if (TEAM_SET.has(owner) && owner !== 'marketing') return { where: 'agent', agent: owner, topic: null, group };
384
+ if (isMarketingTopic(owner)) {
385
+ const aboutCustomers = owner === IDEAL_CUSTOMERS_TOPIC && (type === 'identity' || type === 'memory');
386
+ return { where: 'agent', agent: 'marketing', topic: owner, group: aboutCustomers ? 'ideal-customers' : group };
387
+ }
388
+ return { where: 'hidden', reason: 'no-agent' };
389
+ }
390
+
391
+ // ─── The page's names ────────────────────────────────────────────────────
392
+
393
+ /** The page's job names for the team (builder memory-categories.ts AGENT_JOBS). */
394
+ const AGENT_JOBS = Object.freeze({
395
+ orchestrator: 'Chief of staff',
396
+ sales: 'Sales',
397
+ helpdesk: 'Support',
398
+ marketing: 'Marketing',
399
+ production: 'Production',
400
+ accounting: 'Accounting',
401
+ coder: 'Website',
402
+ comms: 'Communications',
403
+ });
404
+
405
+ /** The Marketing team's topics in owner words (memory-categories.ts MARKETING_TOPIC_NAMES). */
406
+ const TOPIC_NAMES = Object.freeze({
407
+ marketing: 'Marketing strategy',
408
+ seo: 'SEO',
409
+ content: 'Content',
410
+ social: 'Social',
411
+ ppc: 'Paid ads',
412
+ email: 'Email',
413
+ branding: 'Branding',
414
+ customer_journey: 'Customer journey',
415
+ knowledge_base: 'Knowledge base',
416
+ outbound: 'Outbound',
417
+ workflow: 'Workflow',
418
+ before_after_grid: 'Before and after',
419
+ website_design: 'Website design',
420
+ customer_avatar: 'Ideal customers',
421
+ analytics: 'Analytics',
422
+ });
423
+
424
+ /** The owner key `pull` files an entry with no owner under. */
425
+ export const SHARED_OWNER = 'shared';
426
+
427
+ /** "Sales", "Marketing team > SEO", "Shared with every agent", or the key itself for one the page does not know. */
428
+ export function ownerLabel(owner) {
429
+ if (!owner || owner === SHARED_OWNER) return 'Shared with every agent';
430
+ if (TOPIC_NAMES[owner]) return `Marketing team > ${TOPIC_NAMES[owner]}`;
431
+ return AGENT_JOBS[owner] ?? owner;
432
+ }
433
+
434
+ /** The order the page lists the team in: shared first, then the team, the Marketing topics after Marketing. */
435
+ export function ownerOrder(owner) {
436
+ if (!owner || owner === SHARED_OWNER) return 0;
437
+ const team = ['orchestrator', 'sales', 'helpdesk'];
438
+ if (team.includes(owner)) return 1 + team.indexOf(owner);
439
+ if (MARKETING_SET.has(owner)) return 10 + MARKETING_FAMILY.indexOf(owner);
440
+ const rest = ['production', 'accounting', 'coder', 'comms'];
441
+ if (rest.includes(owner)) return 40 + rest.indexOf(owner);
442
+ return 100;
443
+ }
444
+
445
+ const GROUP_WORDS = Object.freeze({
446
+ profile: 'Profile',
447
+ 'how-it-works': 'How it works',
448
+ background: 'Background',
449
+ rules: 'Rules',
450
+ skills: 'Skills',
451
+ notes: 'Notes',
452
+ moved: 'Moved to About your business',
453
+ 'ideal-customers': 'Ideal customers',
454
+ shortcuts: 'Shortcuts',
455
+ specialists: 'Specialists',
456
+ voice: 'Voice and pronunciation',
457
+ });
458
+
459
+ /** The page's label for an `_account:*` placement ("Chief of staff > How it works"). */
460
+ export function accountPlacementLabel(placement) {
461
+ if (placement?.where === 'business') return `About your business > ${GROUP_WORDS[placement.group] ?? placement.group}`;
462
+ if (placement?.where === 'agent') return `${ownerLabel(placement.agent)} > ${GROUP_WORDS[placement.group] ?? placement.group}`;
463
+ return 'Not shown on the Memory page';
464
+ }