@yeaft/webchat-agent 0.1.791 → 0.1.792
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/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
|
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) {
|