@yeaft/webchat-agent 0.1.791 → 0.1.794
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/package.json +1 -1
- package/unify/config.js +14 -0
- package/unify/engine.js +92 -4
- package/unify/groups/project-doc.js +148 -0
- package/unify/prompts.js +27 -0
- package/unify/vp/seed-defaults.js +28 -0
- package/unify/vp/stock-ids.js +53 -0
- package/unify/vp/vp-bridge.js +10 -2
- package/unify/vp/vp-crud.js +17 -7
- package/unify/web-bridge.js +7 -0
package/package.json
CHANGED
package/unify/config.js
CHANGED
|
@@ -42,6 +42,11 @@ const DEFAULTS = {
|
|
|
42
42
|
// plumb the knob through so the UI can set it).
|
|
43
43
|
unifyMaxConcurrentThreads: 6,
|
|
44
44
|
unifyAutoArchiveIdleDays: 30,
|
|
45
|
+
// CLAUDE.md / AGENTS.md project-doc cap, in bytes. Mirrors Codex's
|
|
46
|
+
// `project_doc_max_bytes`. 0 disables the feature (no project-doc
|
|
47
|
+
// block is injected). Hand-edited values are NOT clamped — we let
|
|
48
|
+
// power users opt into larger docs at their own context-window risk.
|
|
49
|
+
projectDocMaxBytes: 32 * 1024,
|
|
45
50
|
};
|
|
46
51
|
|
|
47
52
|
// ─── config.json reader ─────────────────────────────────────────
|
|
@@ -215,6 +220,10 @@ function loadLegacyConfig(dir, overrides) {
|
|
|
215
220
|
maxOutputTokens: overrides.maxOutputTokens ?? fileConfig.maxOutputTokens ?? DEFAULTS.maxOutputTokens,
|
|
216
221
|
messageTokenBudget: overrides.messageTokenBudget ?? (env.YEAFT_MESSAGE_TOKEN_BUDGET ? parseInt(env.YEAFT_MESSAGE_TOKEN_BUDGET, 10) : null) ?? fileConfig.messageTokenBudget ?? DEFAULTS.messageTokenBudget,
|
|
217
222
|
maxContinueTurns: overrides.maxContinueTurns ?? fileConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
|
|
223
|
+
// CLAUDE.md / AGENTS.md project-doc cap, in bytes. Legacy config
|
|
224
|
+
// has no field for this, so it always lands on the default unless
|
|
225
|
+
// explicitly overridden via CLI.
|
|
226
|
+
projectDocMaxBytes: overrides.projectDocMaxBytes ?? fileConfig.projectDocMaxBytes ?? DEFAULTS.projectDocMaxBytes,
|
|
218
227
|
// task-318: legacy path never had the `unify` section — defaults.
|
|
219
228
|
unify: normaliseUnifySection(null),
|
|
220
229
|
providers: null,
|
|
@@ -309,6 +318,11 @@ export function loadConfig(overrides = {}) {
|
|
|
309
318
|
messageTokenBudget: overrides.messageTokenBudget ?? jsonConfig.messageTokenBudget ?? DEFAULTS.messageTokenBudget,
|
|
310
319
|
maxContinueTurns: overrides.maxContinueTurns ?? jsonConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
|
|
311
320
|
|
|
321
|
+
// CLAUDE.md / AGENTS.md project-doc cap, in bytes. Set to 0 to
|
|
322
|
+
// disable the feature entirely. Read from `projectDocMaxBytes` in
|
|
323
|
+
// ~/.yeaft/config.json or threaded through CLI overrides.
|
|
324
|
+
projectDocMaxBytes: overrides.projectDocMaxBytes ?? jsonConfig.projectDocMaxBytes ?? DEFAULTS.projectDocMaxBytes,
|
|
325
|
+
|
|
312
326
|
// task-318: Unify runtime caps. `unify` is a nested section so we
|
|
313
327
|
// don't pollute the flat config namespace used by chat/crew code.
|
|
314
328
|
unify: normaliseUnifySection(jsonConfig.unify),
|
package/unify/engine.js
CHANGED
|
@@ -21,6 +21,7 @@ import { randomUUID } from 'crypto';
|
|
|
21
21
|
import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
|
|
22
22
|
import { LLMContextError, LLMAbortError } from './llm/adapter.js';
|
|
23
23
|
import { runMemoryPreflow, buildRelevantScopes } from './groups/pre-flow.js';
|
|
24
|
+
import { readProjectDoc, pickProjectDocFile, DEFAULT_PROJECT_DOC_MAX_BYTES } from './groups/project-doc.js';
|
|
24
25
|
import { shouldConsolidate, partitionMessages } from './memory/consolidate.js';
|
|
25
26
|
import { runCompact as runCompactOrchestrator } from './compact/orchestrator.js';
|
|
26
27
|
import { evaluateCompactTriggers } from './compact/triggers.js';
|
|
@@ -323,6 +324,18 @@ export class Engine {
|
|
|
323
324
|
/** @type {string|null} */
|
|
324
325
|
#abortReason = null;
|
|
325
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Per-engine cache of the resolved CLAUDE.md / AGENTS.md project doc.
|
|
329
|
+
* Shape: `{ workDir, path, mtimeMs, text } | null`. A non-null record
|
|
330
|
+
* means "for THIS workDir, the file at `path` with this `mtimeMs`
|
|
331
|
+
* resolved to `text`" — when the next turn's stat returns the same
|
|
332
|
+
* `(path, mtimeMs)` tuple, we skip the read entirely. mtime changes
|
|
333
|
+
* (or a different picked file, e.g. user added AGENTS.md) invalidate
|
|
334
|
+
* automatically because the `path`/`mtimeMs` comparison fails.
|
|
335
|
+
* @type {{ workDir: string, path: string, mtimeMs: number, text: string }|null}
|
|
336
|
+
*/
|
|
337
|
+
#projectDocCache = null;
|
|
338
|
+
|
|
326
339
|
/**
|
|
327
340
|
* @param {{
|
|
328
341
|
* adapter: import('./llm/adapter.js').LLMAdapter,
|
|
@@ -685,10 +698,11 @@ export class Engine {
|
|
|
685
698
|
* @param {object} [args.vpPersona]
|
|
686
699
|
* @param {object} [args.activeScope] — DESIGN-PROMPT §3 ④ structured scope summary
|
|
687
700
|
* @param {string} [args.groupAnnouncement]
|
|
701
|
+
* @param {string} [args.projectDoc] — resolved CLAUDE.md / AGENTS.md text (already truncated)
|
|
688
702
|
* @param {object} [args.taskCtx] — legacy task-context sub-block (optional)
|
|
689
703
|
* @returns {string}
|
|
690
704
|
*/
|
|
691
|
-
#buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, groupAnnouncement, taskCtx } = {}) {
|
|
705
|
+
#buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, groupAnnouncement, projectDoc, taskCtx } = {}) {
|
|
692
706
|
// Get relevant skill content if SkillManager is wired
|
|
693
707
|
let skillContent = '';
|
|
694
708
|
if (this.#skillManager && prompt) {
|
|
@@ -708,6 +722,7 @@ export class Engine {
|
|
|
708
722
|
vpPersona,
|
|
709
723
|
activeScope,
|
|
710
724
|
groupAnnouncement,
|
|
725
|
+
projectDoc,
|
|
711
726
|
taskCtx,
|
|
712
727
|
// Worker-shape harness is descriptive metadata for human inspection;
|
|
713
728
|
// production prompts skip it to save tokens. Re-enable via env when
|
|
@@ -716,6 +731,76 @@ export class Engine {
|
|
|
716
731
|
});
|
|
717
732
|
}
|
|
718
733
|
|
|
734
|
+
/**
|
|
735
|
+
* Resolve the CLAUDE.md / AGENTS.md project doc text for the current
|
|
736
|
+
* group's working directory. mtime-cached on the engine so we only
|
|
737
|
+
* re-read the file when the user actually edited it.
|
|
738
|
+
*
|
|
739
|
+
* Cache strategy:
|
|
740
|
+
* 1. Cheap path: `pickProjectDocFile` (two stats, no read).
|
|
741
|
+
* 2. If the picked `(path, mtimeMs)` matches the cache → return
|
|
742
|
+
* cached text. No disk read, no UTF-8 decode, no truncation work.
|
|
743
|
+
* 3. Cache miss → call `readProjectDoc` (bounded `readSync` into a
|
|
744
|
+
* pre-sized buffer), refresh the cache, return the fresh text.
|
|
745
|
+
*
|
|
746
|
+
* Returns '' when:
|
|
747
|
+
* - workDir is empty / not a string
|
|
748
|
+
* - config.projectDocMaxBytes === 0 (feature disabled)
|
|
749
|
+
* - neither CLAUDE.md nor AGENTS.md exists in workDir
|
|
750
|
+
* - the picked file is empty after trim
|
|
751
|
+
*
|
|
752
|
+
* @param {string|undefined} workDir
|
|
753
|
+
* @returns {string}
|
|
754
|
+
*/
|
|
755
|
+
#getProjectDocBlock(workDir) {
|
|
756
|
+
if (typeof workDir !== 'string' || !workDir.trim()) {
|
|
757
|
+
this.#projectDocCache = null;
|
|
758
|
+
return '';
|
|
759
|
+
}
|
|
760
|
+
const maxBytes = Number.isFinite(this.#config?.projectDocMaxBytes)
|
|
761
|
+
? this.#config.projectDocMaxBytes
|
|
762
|
+
: DEFAULT_PROJECT_DOC_MAX_BYTES;
|
|
763
|
+
if (maxBytes === 0) {
|
|
764
|
+
this.#projectDocCache = null;
|
|
765
|
+
return '';
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Step 1 — stat-only check. Cheap (two `statSync` calls) and lets
|
|
769
|
+
// us short-circuit the read when the file hasn't moved.
|
|
770
|
+
const picked = pickProjectDocFile(workDir);
|
|
771
|
+
if (!picked) {
|
|
772
|
+
this.#projectDocCache = null;
|
|
773
|
+
return '';
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Step 2 — cache hit? Same workDir, same picked file, same mtime
|
|
777
|
+
// ⇒ the previously decoded text is still authoritative. Skip the
|
|
778
|
+
// file read entirely.
|
|
779
|
+
const cache = this.#projectDocCache;
|
|
780
|
+
if (
|
|
781
|
+
cache
|
|
782
|
+
&& cache.workDir === workDir
|
|
783
|
+
&& cache.path === picked.path
|
|
784
|
+
&& cache.mtimeMs === picked.mtimeMs
|
|
785
|
+
) {
|
|
786
|
+
return cache.text;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Step 3 — cache miss. Read + decode, then refresh the cache.
|
|
790
|
+
const doc = readProjectDoc(workDir, { maxBytes });
|
|
791
|
+
if (!doc) {
|
|
792
|
+
this.#projectDocCache = null;
|
|
793
|
+
return '';
|
|
794
|
+
}
|
|
795
|
+
this.#projectDocCache = {
|
|
796
|
+
workDir,
|
|
797
|
+
path: doc.path,
|
|
798
|
+
mtimeMs: doc.mtimeMs,
|
|
799
|
+
text: doc.text,
|
|
800
|
+
};
|
|
801
|
+
return doc.text;
|
|
802
|
+
}
|
|
803
|
+
|
|
719
804
|
/**
|
|
720
805
|
* Build the full tool context for Phase 5 tools.
|
|
721
806
|
*
|
|
@@ -1037,7 +1122,7 @@ export class Engine {
|
|
|
1037
1122
|
* string-prompt shape (no regression for existing callers).
|
|
1038
1123
|
* @yields {EngineEvent}
|
|
1039
1124
|
*/
|
|
1040
|
-
async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null } = {}) {
|
|
1125
|
+
async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null } = {}) {
|
|
1041
1126
|
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
1042
1127
|
yield {
|
|
1043
1128
|
type: 'error',
|
|
@@ -1096,7 +1181,7 @@ export class Engine {
|
|
|
1096
1181
|
const runSignal = abortCtrl.signal;
|
|
1097
1182
|
|
|
1098
1183
|
try {
|
|
1099
|
-
yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted, getCurrentTodos, setCurrentTodos });
|
|
1184
|
+
yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos });
|
|
1100
1185
|
} finally {
|
|
1101
1186
|
if (signal) {
|
|
1102
1187
|
try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
|
|
@@ -1114,7 +1199,7 @@ export class Engine {
|
|
|
1114
1199
|
* in a try/finally without indenting the whole loop.
|
|
1115
1200
|
* @private
|
|
1116
1201
|
*/
|
|
1117
|
-
async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null }) {
|
|
1202
|
+
async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null }) {
|
|
1118
1203
|
|
|
1119
1204
|
// ─── Pre-query: FTS5 Memory Recall + AMS snapshot ─────
|
|
1120
1205
|
// Memory has a SINGLE render outlet now (DESIGN-PROMPT §3 ③):
|
|
@@ -1183,12 +1268,15 @@ export class Engine {
|
|
|
1183
1268
|
envelope: inboundEnvelope || null,
|
|
1184
1269
|
};
|
|
1185
1270
|
|
|
1271
|
+
const projectDoc = this.#getProjectDocBlock(workDir);
|
|
1272
|
+
|
|
1186
1273
|
const systemPrompt = this.#buildSystemPrompt({
|
|
1187
1274
|
prompt,
|
|
1188
1275
|
memoryInjection,
|
|
1189
1276
|
vpPersona,
|
|
1190
1277
|
activeScope,
|
|
1191
1278
|
groupAnnouncement,
|
|
1279
|
+
projectDoc,
|
|
1192
1280
|
});
|
|
1193
1281
|
|
|
1194
1282
|
// ─── Compact summary as messages-array head (DESIGN-PROMPT §4.3) ─
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-doc.js — Read CLAUDE.md / AGENTS.md from a group's workDir.
|
|
3
|
+
*
|
|
4
|
+
* Per-group, the user may park a project-level instructions file in the
|
|
5
|
+
* group's configured `workDir`. This module is the stateless reader for
|
|
6
|
+
* those files. The Engine owns the cache (per session, per VP) so that
|
|
7
|
+
* mtime-driven invalidation can happen without a singleton cache.
|
|
8
|
+
*
|
|
9
|
+
* File-selection rule (matches user spec):
|
|
10
|
+
* • If both `CLAUDE.md` and `AGENTS.md` exist, the one with the newer
|
|
11
|
+
* mtime wins. Tie → CLAUDE.md (deterministic; this is the project's
|
|
12
|
+
* own convention).
|
|
13
|
+
* • If only one exists, pick it.
|
|
14
|
+
* • If neither exists, return null. Caller skips the prompt block.
|
|
15
|
+
*
|
|
16
|
+
* Why two filenames? CLAUDE.md is this project's convention; AGENTS.md
|
|
17
|
+
* is the cross-tool convention adopted by Codex / OpenAI Codex CLI. Both
|
|
18
|
+
* carry the same kind of payload — long-form project instructions — and
|
|
19
|
+
* we want users coming from either ecosystem to "just work".
|
|
20
|
+
*
|
|
21
|
+
* Size cap. We read up to `maxBytes` and truncate larger files with a
|
|
22
|
+
* console warning. Default cap is 32 KB (`DEFAULT_PROJECT_DOC_MAX_BYTES`),
|
|
23
|
+
* mirroring Codex's `project_doc_max_bytes`. Setting the cap to 0 in the
|
|
24
|
+
* engine config disables the feature entirely (the caller short-circuits
|
|
25
|
+
* before reaching this module).
|
|
26
|
+
*
|
|
27
|
+
* NO module-level cache. The engine holds the cache so a mtime change
|
|
28
|
+
* between sessions doesn't ride along into a fresh engine instance, and
|
|
29
|
+
* tests can construct two engines without interfering with each other.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { statSync, openSync, readSync, closeSync } from 'fs';
|
|
33
|
+
import { join } from 'path';
|
|
34
|
+
|
|
35
|
+
/** Filenames probed in `workDir`, in tie-break order (first wins on tie). */
|
|
36
|
+
export const PROJECT_DOC_FILENAMES = ['CLAUDE.md', 'AGENTS.md'];
|
|
37
|
+
|
|
38
|
+
/** Default max bytes pulled into the prompt block. Matches Codex. */
|
|
39
|
+
export const DEFAULT_PROJECT_DOC_MAX_BYTES = 32 * 1024;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Stat both candidate filenames in `workDir` and return whichever has the
|
|
43
|
+
* newer mtime, or null when neither exists / workDir is unusable.
|
|
44
|
+
*
|
|
45
|
+
* Pure stat — does NOT read file contents. Returns a lightweight stat
|
|
46
|
+
* record so the caller can compare against a cached `mtimeMs` before
|
|
47
|
+
* deciding to re-read.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} workDir
|
|
50
|
+
* @returns {{ path: string, mtimeMs: number } | null}
|
|
51
|
+
*/
|
|
52
|
+
export function pickProjectDocFile(workDir) {
|
|
53
|
+
if (typeof workDir !== 'string' || !workDir.trim()) return null;
|
|
54
|
+
try {
|
|
55
|
+
const dirStat = statSync(workDir);
|
|
56
|
+
if (!dirStat.isDirectory()) return null;
|
|
57
|
+
} catch {
|
|
58
|
+
// Non-existent / permission-denied / not a path we can stat.
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let best = null;
|
|
63
|
+
for (const name of PROJECT_DOC_FILENAMES) {
|
|
64
|
+
const path = join(workDir, name);
|
|
65
|
+
let s;
|
|
66
|
+
try {
|
|
67
|
+
s = statSync(path);
|
|
68
|
+
} catch {
|
|
69
|
+
// Missing or unreadable; try the next candidate.
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!s.isFile()) continue;
|
|
73
|
+
// Strict-greater so ties favor the order in PROJECT_DOC_FILENAMES.
|
|
74
|
+
if (!best || s.mtimeMs > best.mtimeMs) {
|
|
75
|
+
best = { path, mtimeMs: s.mtimeMs };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return best;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the picked project-doc file. Returns null when nothing is
|
|
83
|
+
* eligible (no workDir, no file, empty contents after trim).
|
|
84
|
+
*
|
|
85
|
+
* Bounded I/O. We allocate `maxBytes + 1` bytes and `readSync` once —
|
|
86
|
+
* never letting a runaway file balloon the agent's heap. The extra
|
|
87
|
+
* byte tells us whether the file was actually larger (so we know to
|
|
88
|
+
* warn about truncation).
|
|
89
|
+
*
|
|
90
|
+
* Codepoint-safe truncation. When we cut mid-byte inside a multi-byte
|
|
91
|
+
* UTF-8 sequence (very likely for `zh-CN` docs), we walk back to the
|
|
92
|
+
* last codepoint boundary before decoding, so the model sees clean
|
|
93
|
+
* text instead of a trailing `U+FFFD` replacement character.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} workDir
|
|
96
|
+
* @param {{ maxBytes?: number }} [opts]
|
|
97
|
+
* @returns {{ path: string, mtimeMs: number, text: string } | null}
|
|
98
|
+
*/
|
|
99
|
+
export function readProjectDoc(workDir, opts = {}) {
|
|
100
|
+
const maxBytes = Number.isFinite(opts.maxBytes) && opts.maxBytes >= 0
|
|
101
|
+
? opts.maxBytes
|
|
102
|
+
: DEFAULT_PROJECT_DOC_MAX_BYTES;
|
|
103
|
+
if (maxBytes === 0) return null;
|
|
104
|
+
|
|
105
|
+
const picked = pickProjectDocFile(workDir);
|
|
106
|
+
if (!picked) return null;
|
|
107
|
+
|
|
108
|
+
// Allocate one extra byte so a `bytesRead === maxBytes + 1` tells us
|
|
109
|
+
// there's more content beyond the cap — i.e. the file was truncated.
|
|
110
|
+
const cap = maxBytes + 1;
|
|
111
|
+
const buffer = Buffer.allocUnsafe(cap);
|
|
112
|
+
let fd;
|
|
113
|
+
let bytesRead = 0;
|
|
114
|
+
try {
|
|
115
|
+
fd = openSync(picked.path, 'r');
|
|
116
|
+
bytesRead = readSync(fd, buffer, 0, cap, 0);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
} finally {
|
|
120
|
+
if (fd !== undefined) {
|
|
121
|
+
try { closeSync(fd); } catch { /* ignore */ }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let useBytes = bytesRead;
|
|
126
|
+
const truncated = bytesRead > maxBytes;
|
|
127
|
+
if (truncated) {
|
|
128
|
+
useBytes = maxBytes;
|
|
129
|
+
// Walk back into the buffer until the cut isn't sitting in the
|
|
130
|
+
// middle of a multi-byte UTF-8 sequence. Each continuation byte
|
|
131
|
+
// matches the pattern `10xxxxxx` (0x80–0xBF). We scan back at most
|
|
132
|
+
// 3 bytes — UTF-8 codepoints are ≤ 4 bytes total.
|
|
133
|
+
let scan = 0;
|
|
134
|
+
while (scan < 3 && useBytes > 0 && (buffer[useBytes] & 0xC0) === 0x80) {
|
|
135
|
+
useBytes -= 1;
|
|
136
|
+
scan += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const text = buffer.toString('utf8', 0, useBytes).trim();
|
|
141
|
+
if (truncated) {
|
|
142
|
+
console.warn(
|
|
143
|
+
`[unify/project-doc] ${picked.path} exceeds ${maxBytes} bytes — truncated.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (!text) return null;
|
|
147
|
+
return { path: picked.path, mtimeMs: picked.mtimeMs, text };
|
|
148
|
+
}
|
package/unify/prompts.js
CHANGED
|
@@ -195,6 +195,12 @@ const PROMPTS = {
|
|
|
195
195
|
// DESIGN-PROMPT §3 ④ — Active Scope header
|
|
196
196
|
activeScopeHeader: '## active_scope',
|
|
197
197
|
groupAnnouncementHeader: '[Group Announcement]',
|
|
198
|
+
// Project-doc (CLAUDE.md / AGENTS.md) header + one-liner intro. Both
|
|
199
|
+
// filenames are recognized: CLAUDE.md is this project's convention,
|
|
200
|
+
// AGENTS.md is the cross-tool convention (Codex / OpenAI Codex CLI).
|
|
201
|
+
projectDocHeader: '[Project Doc]',
|
|
202
|
+
projectDocIntro:
|
|
203
|
+
'The user keeps project-level instructions and context in `CLAUDE.md` or `AGENTS.md` at the group\'s working directory. Treat the content below as authoritative project context — coding conventions, task guidance, workflow rules, etc.',
|
|
198
204
|
vpPersonaIntro: (name, role) =>
|
|
199
205
|
`You ARE **${name}**${role ? ` (${role})` : ''}. Speak in the first person as ${name}; do not refer to yourself as "Yeaft" or as a generic AI assistant. The text below is your identity, expertise, and decision style.`,
|
|
200
206
|
},
|
|
@@ -206,6 +212,10 @@ const PROMPTS = {
|
|
|
206
212
|
// DESIGN-PROMPT §3 ④ — Active Scope header
|
|
207
213
|
activeScopeHeader: '## active_scope',
|
|
208
214
|
groupAnnouncementHeader: '[群组公告]',
|
|
215
|
+
// 项目文档块:CLAUDE.md / AGENTS.md(与 Codex 通用命名兼容)。
|
|
216
|
+
projectDocHeader: '[项目文档]',
|
|
217
|
+
projectDocIntro:
|
|
218
|
+
'用户把项目级的说明和上下文记录在群组工作目录下的 `CLAUDE.md` 或 `AGENTS.md` 中。下面的内容是权威的项目上下文 —— 编码规范、任务指导、工作流约定等,请遵循它来工作。',
|
|
209
219
|
vpPersonaIntro: (name, role) =>
|
|
210
220
|
`你就是 **${name}**${role ? `(${role})` : ''}。请以 ${name} 的第一人称发言;不要自称 "Yeaft" 或泛指的 AI 助手。下面的文字是你的身份、专业方向与判断风格。`,
|
|
211
221
|
},
|
|
@@ -271,6 +281,7 @@ export function normalizePromptLanguage(language) {
|
|
|
271
281
|
* activeScope?: object,
|
|
272
282
|
* vpPersona?: object,
|
|
273
283
|
* groupAnnouncement?: string,
|
|
284
|
+
* projectDoc?: string,
|
|
274
285
|
* }} params
|
|
275
286
|
* @returns {string}
|
|
276
287
|
*/
|
|
@@ -283,6 +294,7 @@ export function buildSystemPrompt({
|
|
|
283
294
|
activeScope,
|
|
284
295
|
vpPersona,
|
|
285
296
|
groupAnnouncement = '',
|
|
297
|
+
projectDoc = '',
|
|
286
298
|
} = {}) {
|
|
287
299
|
// Normalize app locales like `zh-CN` to prompt dictionary/template keys.
|
|
288
300
|
const effectiveLang = normalizePromptLanguage(language);
|
|
@@ -310,6 +322,21 @@ export function buildSystemPrompt({
|
|
|
310
322
|
}
|
|
311
323
|
}
|
|
312
324
|
|
|
325
|
+
// ─── 1.4 Project Doc (CLAUDE.md / AGENTS.md from group workDir) ───
|
|
326
|
+
// The group's working-directory may contain a project-level
|
|
327
|
+
// instructions file. The engine resolves "newest of CLAUDE.md vs
|
|
328
|
+
// AGENTS.md by mtime" and threads the resulting text through here.
|
|
329
|
+
// Empty/whitespace = no block emitted. Sits ABOVE the announcement
|
|
330
|
+
// because user-authored project files are higher signal than the
|
|
331
|
+
// group-level announcement (which is typically a short rule).
|
|
332
|
+
const docText = (typeof projectDoc === 'string') ? projectDoc.trim() : '';
|
|
333
|
+
if (docText) {
|
|
334
|
+
const docHeader = lang.projectDocHeader || '[Project Doc]';
|
|
335
|
+
const docIntro = lang.projectDocIntro || '';
|
|
336
|
+
const introLine = docIntro ? `${docIntro}\n\n` : '';
|
|
337
|
+
parts.push(`${docHeader}\n${introLine}${docText}`);
|
|
338
|
+
}
|
|
339
|
+
|
|
313
340
|
// ─── 1.5 Group Announcement (CLAUDE.md-style shared prefix) ───
|
|
314
341
|
// When a group has set an announcement, every VP in the group sees it
|
|
315
342
|
// near the top of the system prompt — before tools, memory, mode-specific
|
|
@@ -31,6 +31,7 @@ import { existsSync, mkdirSync, readdirSync, statSync } from 'fs';
|
|
|
31
31
|
import { join } from 'path';
|
|
32
32
|
import { createVp, VpCrudError } from './vp-crud.js';
|
|
33
33
|
import { DEFAULT_VP_LIB_DIR } from './vp-store.js';
|
|
34
|
+
import { STOCK_VP_IDS } from './stock-ids.js';
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* The 33 default VPs. Each entry is a valid `createVp` payload.
|
|
@@ -855,6 +856,33 @@ Bad for: requests that require pretending to be a licensed professional, bypassi
|
|
|
855
856
|
},
|
|
856
857
|
]);
|
|
857
858
|
|
|
859
|
+
/**
|
|
860
|
+
* Self-check: every seed persona's vpId must appear in STOCK_VP_IDS, and
|
|
861
|
+
* vice versa. The two lists live in separate modules to break a circular
|
|
862
|
+
* import (see stock-ids.js header), so the only thing keeping them in
|
|
863
|
+
* sync is this load-time assertion. If you add a new seed VP and forget
|
|
864
|
+
* to add its id to stock-ids.js#STOCK_VP_ID_LIST (or vice versa), the
|
|
865
|
+
* agent will refuse to start with a clear error.
|
|
866
|
+
*/
|
|
867
|
+
const _seedIds = new Set(DEFAULT_VPS.map(v => v.vpId));
|
|
868
|
+
{
|
|
869
|
+
const missingInStockIds = [];
|
|
870
|
+
for (const id of _seedIds) {
|
|
871
|
+
if (!STOCK_VP_IDS.has(id)) missingInStockIds.push(id);
|
|
872
|
+
}
|
|
873
|
+
const missingInSeeds = [];
|
|
874
|
+
for (const id of STOCK_VP_IDS) {
|
|
875
|
+
if (!_seedIds.has(id)) missingInSeeds.push(id);
|
|
876
|
+
}
|
|
877
|
+
if (missingInStockIds.length || missingInSeeds.length) {
|
|
878
|
+
throw new Error(
|
|
879
|
+
'[seed-defaults] DEFAULT_VPS / STOCK_VP_IDS mismatch — '
|
|
880
|
+
+ `add to stock-ids.js: [${missingInStockIds.join(', ')}]; `
|
|
881
|
+
+ `add to DEFAULT_VPS: [${missingInSeeds.join(', ')}]`,
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
858
886
|
/**
|
|
859
887
|
* True iff `libDir` exists and contains at least one subdirectory that
|
|
860
888
|
* looks like a VP entry (has a `role.md` file). A stray empty directory
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stock-ids.js — single source of truth for "is this vpId a stock seed VP?".
|
|
3
|
+
*
|
|
4
|
+
* Owns the canonical list of seed vpIds and exposes `STOCK_VP_IDS` (a Set
|
|
5
|
+
* for O(1) lookup) plus `isStockVpId(vpId)`.
|
|
6
|
+
*
|
|
7
|
+
* Why this lives in its own tiny module instead of in seed-defaults.js:
|
|
8
|
+
* `seed-defaults.js` imports `createVp / VpCrudError` from `vp-crud.js`,
|
|
9
|
+
* so if `vp-crud.js` were to import `STOCK_VP_IDS` from `seed-defaults`
|
|
10
|
+
* directly, the module graph would be circular (vp-crud → seed-defaults
|
|
11
|
+
* → vp-crud). The cycle "works" today only because every consumer reads
|
|
12
|
+
* STOCK_VP_IDS inside a function body (live binding), but a future
|
|
13
|
+
* refactor that moves the check to module top level — say, to validate
|
|
14
|
+
* input on import — would crash with `STOCK_VP_IDS is undefined`. Moving
|
|
15
|
+
* the Set into a leaf module with zero inbound deps from elsewhere in
|
|
16
|
+
* the package breaks the cycle for good.
|
|
17
|
+
*
|
|
18
|
+
* Authoritative-vs-derived: this file is the SOURCE OF TRUTH for stock
|
|
19
|
+
* ids. seed-defaults.js asserts at module load that every entry in
|
|
20
|
+
* DEFAULT_VPS appears here, and vice versa — see the self-check at the
|
|
21
|
+
* bottom of seed-defaults.js. So adding a new seed VP only requires
|
|
22
|
+
* adding both the persona object *and* its id here (two-file change,
|
|
23
|
+
* caught by the assertion if you forget either).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const STOCK_VP_ID_LIST = Object.freeze([
|
|
27
|
+
// engineering
|
|
28
|
+
'steve', 'linus', 'martin', 'dieter', 'ada', 'grace', 'alice', 'ken',
|
|
29
|
+
'margaret', 'shannon', 'alan', 'norman',
|
|
30
|
+
// philosophy / psychology
|
|
31
|
+
'kongzi', 'socrates', 'nietzsche', 'kahneman', 'jung',
|
|
32
|
+
// strategy / business
|
|
33
|
+
'sunzi', 'clausewitz', 'simaqian', 'harari',
|
|
34
|
+
'buffett', 'munger', 'dalio', 'bezos', 'drucker',
|
|
35
|
+
// arts / culture
|
|
36
|
+
'luxun', 'sudongpo', 'borges', 'einstein', 'kubrick', 'miyazaki',
|
|
37
|
+
// assistant
|
|
38
|
+
'omni',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** @type {ReadonlySet<string>} */
|
|
42
|
+
export const STOCK_VP_IDS = new Set(STOCK_VP_ID_LIST);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True iff `vpId` is a stock seed VP that ships with the agent.
|
|
46
|
+
* Pure function of the string — undefined / non-string input returns false.
|
|
47
|
+
*
|
|
48
|
+
* @param {unknown} vpId
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
export function isStockVpId(vpId) {
|
|
52
|
+
return typeof vpId === 'string' && STOCK_VP_IDS.has(vpId);
|
|
53
|
+
}
|
package/unify/vp/vp-bridge.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { defaultRegistry } from './registry.js';
|
|
25
25
|
import { VpLoader } from './vp-loader.js';
|
|
26
|
+
import { STOCK_VP_IDS } from './stock-ids.js';
|
|
26
27
|
|
|
27
28
|
/** Process-singleton VpLoader; lazily started on first subscribe. */
|
|
28
29
|
let _loaderStarted = false;
|
|
@@ -175,8 +176,8 @@ function ensureLoader(registry = defaultRegistry) {
|
|
|
175
176
|
* Serialise a VP (entity layer shape) to the wire-format the web layer
|
|
176
177
|
* expects (spec §2.1). Pure; no IO.
|
|
177
178
|
*
|
|
178
|
-
* @param {{id:string,name:string,role:string,traits?:string[],modelHint?:string,personaHash?:string}} vp
|
|
179
|
-
* @returns {{vpId:string,displayName:string,subtitle:string,role:string,traits:string[],modelHint:?string,personaHash:?string}}
|
|
179
|
+
* @param {{id:string,name:string,role:string,nameZh?:string,aliases?:string[],traits?:string[],modelHint?:string,personaHash?:string}} vp
|
|
180
|
+
* @returns {{vpId:string,displayName:string,displayNameZh:string,aliases:string[],subtitle:string,role:string,traits:string[],modelHint:?string,personaHash:?string,isStock:boolean}}
|
|
180
181
|
*/
|
|
181
182
|
export function serializeVpForWire(vp) {
|
|
182
183
|
return {
|
|
@@ -191,6 +192,13 @@ export function serializeVpForWire(vp) {
|
|
|
191
192
|
traits: Array.isArray(vp.traits) ? vp.traits.slice() : [],
|
|
192
193
|
modelHint: vp.modelHint ?? null,
|
|
193
194
|
personaHash: vp.personaHash ?? null,
|
|
195
|
+
// task-vp-customize: mark seed VPs so the frontend can disable
|
|
196
|
+
// Edit/Delete and surface a "Stock" badge. Pure id check — see
|
|
197
|
+
// stock-ids.js#STOCK_VP_IDS for the contract. `Set.has(undefined)`
|
|
198
|
+
// is fine, but we still coerce to plain boolean so the wire field
|
|
199
|
+
// is strictly `true | false` (never `undefined`) and downstream
|
|
200
|
+
// `!!` reads can collapse cleanly.
|
|
201
|
+
isStock: STOCK_VP_IDS.has(vp.id) === true,
|
|
194
202
|
};
|
|
195
203
|
}
|
|
196
204
|
|
package/unify/vp/vp-crud.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* Error codes returned to the caller (wire-visible):
|
|
15
15
|
* 'duplicate' — vpId already exists (on create)
|
|
16
16
|
* 'not_found' — vpId does not exist on disk (on update/delete)
|
|
17
|
+
* 'stock_readonly' — vpId is one of the seed/stock VPs (mutation refused)
|
|
17
18
|
* <reason from validateVpId> — invalid shape
|
|
18
19
|
*/
|
|
19
20
|
|
|
@@ -24,6 +25,7 @@ import { validateVpId } from '../groups/ids.js';
|
|
|
24
25
|
import { DEFAULT_VP_LIB_DIR, parseRoleMd } from './vp-store.js';
|
|
25
26
|
import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store-v2.js';
|
|
26
27
|
import { VP_STUB_MARKER } from '../memory/seed-backfill.js';
|
|
28
|
+
import { STOCK_VP_IDS } from './stock-ids.js';
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* Default memory root used when callers don't pass `options.memoryRoot`.
|
|
@@ -34,13 +36,6 @@ import { VP_STUB_MARKER } from '../memory/seed-backfill.js';
|
|
|
34
36
|
*/
|
|
35
37
|
const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
|
|
36
38
|
|
|
37
|
-
/**
|
|
38
|
-
* Build the seed summary body for a freshly-created VP. Pulled into a
|
|
39
|
-
* helper so tests can pin the exact format.
|
|
40
|
-
*
|
|
41
|
-
* @param {object} payload same shape as createVp
|
|
42
|
-
* @returns {string}
|
|
43
|
-
*/
|
|
44
39
|
/**
|
|
45
40
|
* Build the seed body for a freshly-created VP's `<root>/vp/<id>/summary.md`.
|
|
46
41
|
*
|
|
@@ -218,6 +213,11 @@ export function updateVp(payload, options = {}) {
|
|
|
218
213
|
const v = validateVpId(vpId);
|
|
219
214
|
if (!v.ok) throw new VpCrudError(v.reason, vpId);
|
|
220
215
|
|
|
216
|
+
// Stock VPs ship with the agent and are immutable from the CRUD path.
|
|
217
|
+
// The UI also disables Edit/Delete on these, but a misbehaving WS client
|
|
218
|
+
// could bypass the UI — refuse here so the file on disk stays canonical.
|
|
219
|
+
if (STOCK_VP_IDS.has(vpId)) throw new VpCrudError('stock_readonly', vpId);
|
|
220
|
+
|
|
221
221
|
const dir = vpDirFor(libDir, vpId);
|
|
222
222
|
if (!existsSync(dir) || !existsSync(vpRolePathFor(libDir, vpId))) {
|
|
223
223
|
throw new VpCrudError('not_found', vpId);
|
|
@@ -249,6 +249,10 @@ export function deleteVp(vpId, options = {}) {
|
|
|
249
249
|
if (!vpId || typeof vpId !== 'string' || vpId.includes('/') || vpId.includes('\\') || vpId === '..' || vpId === '.') {
|
|
250
250
|
throw new VpCrudError('illegal_character', vpId);
|
|
251
251
|
}
|
|
252
|
+
// Stock VPs ship with the agent. Refuse the delete server-side so a
|
|
253
|
+
// misbehaving WS client cannot wipe `~/.yeaft/virtual-persons/steve/`
|
|
254
|
+
// even if the UI's delete button is missing or disabled.
|
|
255
|
+
if (STOCK_VP_IDS.has(vpId)) throw new VpCrudError('stock_readonly', vpId);
|
|
252
256
|
const dir = vpDirFor(libDir, vpId);
|
|
253
257
|
if (!existsSync(dir)) {
|
|
254
258
|
throw new VpCrudError('not_found', vpId);
|
|
@@ -298,5 +302,11 @@ export function readVp(vpId, options = {}) {
|
|
|
298
302
|
modelHint,
|
|
299
303
|
persona: body,
|
|
300
304
|
planInstruction: typeof meta.planInstruction === 'string' ? String(meta.planInstruction) : '',
|
|
305
|
+
// Echo the authoritative stock flag on the read response so the UI's
|
|
306
|
+
// detail view doesn't have to trust the (potentially stale) list
|
|
307
|
+
// snapshot it was launched from. Defence-in-depth: same Set, two
|
|
308
|
+
// call sites; if either disagrees, the agent guard in updateVp /
|
|
309
|
+
// deleteVp is still the final word.
|
|
310
|
+
isStock: STOCK_VP_IDS.has(id) === true,
|
|
301
311
|
};
|
|
302
312
|
}
|
package/unify/web-bridge.js
CHANGED
|
@@ -1988,6 +1988,13 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId, envelope })
|
|
|
1988
1988
|
if (groupMeta && typeof groupMeta.announcement === 'string') {
|
|
1989
1989
|
out.groupAnnouncement = groupMeta.announcement;
|
|
1990
1990
|
}
|
|
1991
|
+
// Surface the group's configured working directory so the engine can
|
|
1992
|
+
// resolve CLAUDE.md / AGENTS.md at that path and inject it as a
|
|
1993
|
+
// [Project Doc] block above the announcement. Groups with no workDir
|
|
1994
|
+
// skip the block silently (matches the announcement contract).
|
|
1995
|
+
if (groupMeta && typeof groupMeta.workDir === 'string' && groupMeta.workDir.trim()) {
|
|
1996
|
+
out.workDir = groupMeta.workDir.trim();
|
|
1997
|
+
}
|
|
1991
1998
|
try {
|
|
1992
1999
|
const vp = readVp(resolvedVpId);
|
|
1993
2000
|
if (vp) {
|