ai_co_discuss 0.3.71 → 0.3.73

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.
@@ -1,13 +1,14 @@
1
1
  /** Free-room delivery: one live execution per member, carrying all of that member's open requests. */
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
- import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
5
5
  import { basename, join, resolve } from 'node:path';
6
6
  import { adapter_of, BRANCHABLE_ADAPTERS, branch_session, capabilities_of, cleanup_room, deliver_task, delivery_receipt, session_exists, stored_delivery } from './adapters/index.mjs';
7
- import { api, read_rule } from './discussion_runner.mjs';
7
+ import { api, overlapping_directories, read_rule } from './discussion_runner.mjs';
8
8
  import { SegmentLog } from './execution_progress.mjs';
9
9
  import { free_task_text, TASK_SUBCOMMANDS, with_expert_prompt } from './expert_prompts.mjs';
10
10
  import { atomic_json } from './files.mjs';
11
+ import { outside_directory, room_directories } from './project_files.mjs';
11
12
  import { SessionDriver } from './session_driver.mjs';
12
13
  import { effective_profile, load_room_workflow, workflow_in_use, workflow_task_field } from './workflows.mjs';
13
14
  import { DEFAULT_SCHEDULER_MODE, GOAL_PROMPT_VERSION, decide_goal_wake, decide_sequence_checkpoint, REVIEW_CONFIDENCE, REVIEW_PROMPT_VERSION, SEQUENCE_CONFIDENCE, SEQUENCE_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, decide_review_completion, route_free_message, route_prompt_version, scheduler_thresholds, some_members } from './scheduler.mjs';
@@ -94,6 +95,25 @@ export function edited_changes(changes, claimed, platform = process.platform) {
94
95
  return changes.filter(path => named.some(item => item.endsWith(normal(path))));
95
96
  }
96
97
 
98
+ /**
99
+ * The room's project directories a member works with besides its own cwd, each `{path, access}`: a member working in
100
+ * the room's project gets the others at its own project access (read or write); a member working outside them gets
101
+ * them all read-only. Software without project files gets none, and a folder gone from this computer is left out.
102
+ */
103
+ export function member_extra_directories(config, profile) {
104
+ if (!profile || !capabilities_of(profile.adapter).project) return [];
105
+ // '' marks a member that joined before the room had a project: it reaches every room directory at its own access.
106
+ const cwd = profile.cwd ?? config.cwd;
107
+ const access = outside_directory(config, profile) || profile.project_access === 'read' ? 'read' : 'write';
108
+ return room_directories(config).filter(path => !(cwd && overlapping_directories(path, cwd)) && existsSync(path))
109
+ .map(path => ({ path, access }));
110
+ }
111
+
112
+ /** A changed path in an extra directory, reported in full so it cannot be mistaken for one in the main directory. */
113
+ function extra_change(directory, path) {
114
+ return `${cwd_key(directory)}/${path}`;
115
+ }
116
+
97
117
  /** Write a non-secret task and a member-only credential file for one execution. */
98
118
  export function free_task_files(config, member, execution, binding, { session = null, suggestions = [], reviews = [], stopped = [], process_access = true,
99
119
  workflow = null, room_members = [], goal = null } = {}) {
@@ -109,6 +129,7 @@ export function free_task_files(config, member, execution, binding, { session =
109
129
  atomic_json(access_path, { site: config.site, room: config.room, member: member.name, token: config.members[member.name].token,
110
130
  session_id: binding.session_id || '', execution_id: execution.id });
111
131
  const adapter = adapter_of(profile.adapter);
132
+ const extra_directories = member_extra_directories(config, profile);
112
133
  const cli = process.platform === 'win32' ? adapter?.windows_command || 'codiscuss.cmd' : 'codiscuss';
113
134
  const inbox = Boolean(adapter?.into_window?.(profile, binding));
114
135
  const bound_access = process_access && !inbox && Boolean(adapter?.capabilities.access_env);
@@ -119,7 +140,7 @@ export function free_task_files(config, member, execution, binding, { session =
119
140
  const { instructions, prompt } = free_task_text({ name: member.label || member.name, role: member.role, discussion: direct_question,
120
141
  discussion_request, profile, cli,
121
142
  room: config.room, messages, read_after: execution.read_until, execution_id: execution.id, task_path,
122
- access_file: bound_access ? '' : access_path, read_rule: read_rule(profile.adapter), inbox });
143
+ access_file: bound_access ? '' : access_path, read_rule: read_rule(profile.adapter), inbox, extra_directories });
123
144
  const skills = discussion_request || direct_question
124
145
  ? ['discuss-participant/SKILL.md', 'discuss-participant/references/discussion.md',
125
146
  ...member.role === 'host' ? ['discuss-host/SKILL.md', 'discuss-host/references/discussion.md'] : [],
@@ -146,13 +167,14 @@ export function free_task_files(config, member, execution, binding, { session =
146
167
  ...(stopped.length ? { stopped_executions: stopped } : {}),
147
168
  role: member.role || 'member',
148
169
  read_after: execution.read_until, cwd: profile.cwd || null, project_access: profile.project_access, draft_directory,
170
+ ...(extra_directories.length ? { extra_directories } : {}),
149
171
  skills: skills.map(file => join(config.project, '.agents/skills', file)),
150
172
  // The workflow's SKILL.md is not a skill read on every task: the host opens it when a request fits its description.
151
173
  ...(workflow_field ? { workflow: { ...workflow_field, skill_path: host_workflow.skill_path } } : {}),
152
174
  ...(member.role === 'host' && config.host_workflows ? { host_workflows: true } : {}),
153
175
  cli, subcommands: TASK_SUBCOMMANDS,
154
176
  ...(bound_access ? { access_env: 'DISCUSS_ACCESS_FILE' } : { access_file: access_path }), instructions });
155
- return { id, participant: member.name, directory: config.directory, draft_directory, ...(goal_field ? { goal: goal_field } : {}),
177
+ return { id, participant: member.name, directory: config.directory, draft_directory, extra_directories, ...(goal_field ? { goal: goal_field } : {}),
156
178
  skill_directories: [skills_directory, ...(host_workflow ? [host_workflow.directory] : [])], access_path, prompt: with_expert_prompt(prompt, profile), inbox,
157
179
  profile, ...(workflow_field ? { workflow: { ...workflow_field, skill: host_workflow.skill } } : {}) };
158
180
  }
@@ -201,6 +223,9 @@ export class FreeRoomRunner {
201
223
  // Routed requests whose decision is still pending: their host waits, so one pick replaces the host's start.
202
224
  this.routing = new Set();
203
225
  this.children = new Set();
226
+ // Per live execution, how to pass an interjection to its running software; and interjections already handed over.
227
+ this.steers = new Map();
228
+ this.interjecting = new Map();
204
229
  this.members = new Map();
205
230
  // An empty set proves every tracked handle for this execution closed; a missing set proves nothing after restart.
206
231
  this.execution_processes = new Map();
@@ -294,6 +319,7 @@ export class FreeRoomRunner {
294
319
  if (LIVE.includes(row.status) && !present.has(row.member)) { this.stop_member(row.member); this.save(row.member, { status: 'idle' }); }
295
320
  }
296
321
  await this.report_finished(active);
322
+ await this.deliver_interjections(inbox.executions);
297
323
  const pending_stops = await this.report_stops(active);
298
324
  // Start in the room's speaking order: execution ids follow it, and the website lays out working cards by them.
299
325
  const rank = (room.speaking_order?.items || []).map(item => item.name);
@@ -677,7 +703,7 @@ export class FreeRoomRunner {
677
703
  async reserve(member, record) {
678
704
  let execution;
679
705
  const profile = this.config.participants[member.name];
680
- const cwd = profile && !capabilities_of(profile.adapter).project ? '' : cwd_key(profile?.cwd || this.config.cwd);
706
+ const cwd = profile && !capabilities_of(profile.adapter).project ? '' : cwd_key(profile?.cwd ?? this.config.cwd);
681
707
  try { execution = await api(this.config, '/executions', { member_id: member.id, client_id: record.client_id, cwd }); }
682
708
  catch (error) {
683
709
  if (/HTTP (404|409)/.test(error.message)) return this.save(member.name, { status: 'idle', error: '' });
@@ -697,10 +723,13 @@ export class FreeRoomRunner {
697
723
  }
698
724
  if (!adapter) return this.fail(member.name, execution.id, `未配置 ${member.name} 的本机接入`);
699
725
  if (this.context_errors.has(member.name)) return this.fail(member.name, execution.id, this.context_errors.get(member.name));
700
- const directory = capabilities_of(adapter.adapter).project ? adapter.cwd || this.config.cwd : undefined;
726
+ const directory = capabilities_of(adapter.adapter).project ? adapter.cwd ?? this.config.cwd : undefined;
701
727
  // A read-only member's software cannot write the project, so files changing meanwhile were written by someone else.
702
728
  const before = adapter.project_access === 'read' ? null : workspace_snapshot(directory);
703
- if (before) this.snapshots.set(execution.id, { member: member.name, directory, before });
729
+ // The room's other directories this member may write are watched as well; their changes are reported with full paths.
730
+ const extras = member_extra_directories(this.config, adapter).filter(item => item.access === 'write')
731
+ .map(item => ({ directory: item.path, before: workspace_snapshot(item.path) })).filter(item => item.before);
732
+ if (before || extras.length) this.snapshots.set(execution.id, { member: member.name, directory, before, extras });
704
733
  const binding = this.store.binding(member.name);
705
734
  // A member whose session a rewind restarted is bound to '' and must not fall back to the session it was invited with.
706
735
  if (binding.session_id === undefined && adapter.session_id) binding.session_id = adapter.session_id;
@@ -731,7 +760,7 @@ export class FreeRoomRunner {
731
760
  ...(review_request ? { before_model_start: () => this.check_review_delivery(member, review_request) } : {}),
732
761
  api_context: { site: this.config.site, room: this.config.room, token: this.config.members[member.name].token,
733
762
  home: this.config.api_home, mode: 'free', role: member.role || 'member', execution_id: execution.id,
734
- cwd: directory, project_access: adapter.project_access,
763
+ cwd: directory, project_access: adapter.project_access, extra_directories: task.extra_directories,
735
764
  requests: execution.requests, read_after: execution.read_until, session, discussion_request: session_request,
736
765
  scheduler_suggestions, review_handoffs, ...(task.workflow ? { workflow: task.workflow } : {}),
737
766
  ...(Number.isInteger(this.config.api_max_requests) ? { max_requests: this.config.api_max_requests } : {}),
@@ -760,7 +789,8 @@ export class FreeRoomRunner {
760
789
  if (result.session_id) this.store.bind(member.name, result.session_id);
761
790
  this.clear_stopped(member.name);
762
791
  this.save(member.name, { status: result.status === 'sidecar_pending' ? 'sidecar_pending' : 'running', attempts: 0 });
763
- await this.report_delivery(execution.id, result.capabilities || capabilities_of(adapter.adapter).delivery, member.name);
792
+ if (result.steer) this.steers.set(execution.id, result.steer);
793
+ await this.report_delivery(execution.id, { ...(result.capabilities || capabilities_of(adapter.adapter).delivery), steer: Boolean(result.steer) }, member.name);
764
794
  } catch (error) {
765
795
  if (review_request && error.delivery_status === 'not_sent') return this.reject_review(member, execution, error.message);
766
796
  if (adapter.project_access === 'own') return this.retry_or_fail(member.name, execution, `投递失败:${error.message}`);
@@ -874,9 +904,9 @@ export class FreeRoomRunner {
874
904
  * progress. Kept locally until the execution ends so a restarted runner still knows whether it could stop it.
875
905
  * A lost report is retained for the final stop result; sites and runners must use the same capability protocol.
876
906
  */
877
- async report_delivery(execution_id, { stoppable, progress }, member) {
907
+ async report_delivery(execution_id, { stoppable, progress, steer = false }, member) {
878
908
  this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(`delivery:${execution_id}`, JSON.stringify({ stoppable, progress, member }));
879
- await api(this.config, `/executions/${execution_id}`, { action: 'delivery', stoppable, progress })
909
+ await api(this.config, `/executions/${execution_id}`, { action: 'delivery', stoppable, progress, steer })
880
910
  .catch(error => this.log(`执行 #${execution_id} 的投递能力未能上报:${error.message}`));
881
911
  }
882
912
  /** Retry durable stop reports even after the member went idle, left, or started a later execution. */
@@ -971,9 +1001,13 @@ export class FreeRoomRunner {
971
1001
  for (const [execution_id, snapshot] of this.snapshots) {
972
1002
  if (active.has(execution_id) || this.execution_processes.get(execution_id)?.size) continue;
973
1003
  this.snapshots.delete(execution_id);
974
- const after = workspace_snapshot(snapshot.directory);
975
- if (!after) continue;
976
- const changes = changed_between(snapshot.before, after).slice(0, 200);
1004
+ const after = snapshot.before ? workspace_snapshot(snapshot.directory) : null;
1005
+ if (!after && !snapshot.extras?.length) continue;
1006
+ const extra = (snapshot.extras || []).flatMap(item => {
1007
+ const current = workspace_snapshot(item.directory);
1008
+ return current ? changed_between(item.before, current).map(path => extra_change(item.directory, path)) : [];
1009
+ });
1010
+ const changes = [...(after ? changed_between(snapshot.before, after) : []), ...extra].slice(0, 200);
977
1011
  // `edited` is sent only when the adapter tracked edits; without it the site cannot tell who wrote a shared file.
978
1012
  const edited = snapshot.edited ? { edited: edited_changes(changes, snapshot.edited) } : {};
979
1013
  try { await api(this.config, `/executions/${execution_id}`, { action: 'report', changes, ...edited }); }
@@ -1008,6 +1042,31 @@ export class FreeRoomRunner {
1008
1042
  this.save(name, { status: 'idle', error: reason });
1009
1043
  this.stop_member(name);
1010
1044
  }
1045
+ /**
1046
+ * Hand the owner's pending interjections to the software running each execution, in order, through the steer handle
1047
+ * its delivery returned; report each as delivered or failed. A handle is dropped once its execution is no longer live.
1048
+ */
1049
+ async deliver_interjections(executions) {
1050
+ const live = new Set(executions.map(item => item.id));
1051
+ for (const id of this.steers.keys()) if (!live.has(id)) this.steers.delete(id);
1052
+ for (const execution of executions) {
1053
+ for (const interjection of execution.interjections || []) {
1054
+ // An interjection is handed over once; a lost report is sent again with the same outcome.
1055
+ let report = this.interjecting.get(interjection.id);
1056
+ if (!report) {
1057
+ const steer = this.steers.get(execution.id);
1058
+ report = { status: 'failed', reason: '本次投递不支持执行中插话' };
1059
+ if (steer) {
1060
+ try { report = await steer(interjection.text) ? { status: 'delivered' } : { status: 'failed', reason: '它已结束本轮,插话未送达' }; }
1061
+ catch (error) { report = { status: 'failed', reason: String(error.message).slice(0, 400) }; }
1062
+ }
1063
+ this.interjecting.set(interjection.id, report);
1064
+ }
1065
+ try { await api(this.config, `/executions/${execution.id}`, { action: 'interjection', interjection_id: interjection.id, ...report }); }
1066
+ catch (error) { this.log(`插话 #${interjection.id} 的结果未能上报:${error.message}`); }
1067
+ }
1068
+ }
1069
+ }
1011
1070
  /** Count live processes per member so one session never has two writers. */
1012
1071
  track(name, child, execution_id = this.row(name)?.execution_id) {
1013
1072
  this.children.add(child);
@@ -1,6 +1,7 @@
1
- /** Read-only directory and text previews, confined to a room's saved project directory. */
1
+ /** Read-only directory and text previews, confined to a room's saved project directories. */
2
2
  import { closeSync, fstatSync, openSync, readdirSync, readSync, realpathSync, statSync } from 'node:fs';
3
3
  import { isAbsolute, relative, resolve } from 'node:path';
4
+ import { adapter_profile } from './adapters/profiles.mjs';
4
5
  import { PINYIN_TABLE } from './pinyin_table.mjs';
5
6
 
6
7
  const DIRECTORY_PAGE_SIZE = 200;
@@ -113,7 +114,7 @@ function search_rank(name, path, needle) {
113
114
  return [tier, path.split('/').length, path.length];
114
115
  }
115
116
 
116
- /** Find files and folders whose name (or path, when the query has a "/") contains the query, case-insensitively. */
117
+ /** Find files and folders whose name (or path, when the query has a "/") contains the query, case-insensitively, under one root. */
117
118
  function search_project_files(root, query) {
118
119
  const needle = query.trim().toLocaleLowerCase().replaceAll('\\', '/');
119
120
  if (!needle) throw new Error('搜索关键词不能为空');
@@ -139,10 +140,52 @@ function search_project_files(root, query) {
139
140
  }
140
141
  }
141
142
  }
143
+ return { matches, complete };
144
+ }
145
+
146
+ /** Rank and cut the matches of every searched directory together; each entry keeps the `root` it was found in. */
147
+ function search_result(query, searches) {
148
+ const needle = query.trim().toLocaleLowerCase().replaceAll('\\', '/');
149
+ const matches = searches.flatMap(({ matches: found }, root) => found.map(item => ({ ...item, root })));
142
150
  const ranked = matches.map(item => ({ item, rank: search_rank(item.name, item.path, needle) }))
143
151
  .sort((left, right) => left.rank[0] - right.rank[0] || left.rank[1] - right.rank[1] || left.rank[2] - right.rank[2]);
144
152
  return { query, kind: 'search', entries: ranked.slice(0, SEARCH_RESULT_LIMIT).map(({ item }) => item),
145
- total: matches.length, complete };
153
+ total: matches.length, complete: searches.every(search => search.complete) };
154
+ }
155
+
156
+ /** The room's project directories: the main one (`cwd`) first, then the extra ones in the order they were added. */
157
+ export function room_directories(config) {
158
+ return [config.cwd, ...(config.extra_directories || [])].filter(Boolean);
159
+ }
160
+
161
+ /** Whether `directory` is one of the room's project directories or lies inside one; Windows paths compare without case. */
162
+ export function inside_room(config, directory) {
163
+ const key = path => process.platform === 'win32' ? resolve(path).toLowerCase() : resolve(path);
164
+ return room_directories(config).some(root => {
165
+ const rest = relative(key(root), key(directory));
166
+ return !rest.startsWith('..') && !isAbsolute(rest);
167
+ });
168
+ }
169
+
170
+ /**
171
+ * The directory a member works in when it lies outside every project directory of the room, or '' when it works in
172
+ * one of them (or the room has none). Such a member only reads the room's directories. A member whose `cwd` is ''
173
+ * joined before the room had a project: it keeps its own session's folder and is not outside.
174
+ */
175
+ export function outside_directory(config, profile) {
176
+ const cwd = profile?.cwd ?? config.cwd;
177
+ return cwd && room_directories(config).length && !inside_room(config, cwd) ? cwd : '';
178
+ }
179
+
180
+ /**
181
+ * Whether the room's directories stay read-only for this member by the software's own limits, not only by its task
182
+ * text: at 只读 for any software whose levels the runner sets, at 可写 only where a sandbox or the file tools hold
183
+ * (`limits_directories`); never for an App, a manual member's own permissions, 自动 or 全放开.
184
+ */
185
+ export function read_only_enforced(profile) {
186
+ const facts = adapter_profile(profile?.adapter);
187
+ if (!facts?.access_levels) return false;
188
+ return profile.project_access === 'read' || (profile.project_access === 'write' && Boolean(facts.capabilities.limits_directories));
146
189
  }
147
190
 
148
191
  /** The room's project root, resolved through links and required to be a folder. */
@@ -153,9 +196,12 @@ function project_root(cwd) {
153
196
  return root;
154
197
  }
155
198
 
156
- /** List one directory page, preview one file, or search names under the configured project root. */
199
+ /**
200
+ * List one directory page or preview one file under one project directory, or search names under it; a search over
201
+ * several directories is `search_project_roots`.
202
+ */
157
203
  export function read_project_files(cwd, path = '', offset = 0, query = '') {
158
- if (query) return search_project_files(project_root(cwd), String(query));
204
+ if (query) return search_project_roots([cwd], query);
159
205
  if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('目录分页位置无效');
160
206
  const root = project_root(cwd);
161
207
  const target = project_path(root, path);
@@ -176,3 +222,9 @@ export function read_project_files(cwd, path = '', offset = 0, query = '') {
176
222
  return { path: normalized, kind: 'directory', entries, total: items.length,
177
223
  next_offset: offset + entries.length < items.length ? offset + entries.length : null };
178
224
  }
225
+
226
+ /** Search names under every directory of a room at once; each entry's `root` is the index of its directory. */
227
+ export function search_project_roots(directories, query) {
228
+ if (!directories.length) project_root('');
229
+ return search_result(String(query), directories.map(directory => search_project_files(project_root(directory), String(query))));
230
+ }
@@ -49,12 +49,13 @@ export function bridge_token(home, site) {
49
49
  const path = join(home, 'site-connections.json');
50
50
  const hashed = createHash('sha256').update(link.bridge_token).digest('hex');
51
51
  const settings = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { sites: {} };
52
- // A website that was disconnected has no entry, and reconnecting replaces it; either way no bridge is granted here.
53
- if (settings.sites[site] && settings.sites[site].bridge_hash !== hashed) {
52
+ // The entry also tells the local website that this site reaches it only through the relay, so a browser connection
53
+ // credential kept from before no longer works. A website disconnected meanwhile has lost its pairing: no bridge then.
54
+ if (settings.sites[site]?.bridge_hash !== hashed) {
54
55
  const release = acquire_lock(join(home, 'connection-lock'));
55
56
  try {
56
57
  const latest = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { sites: {} };
57
- if (latest.sites[site]) atomic_json(path, { ...latest, sites: { ...latest.sites, [site]: { ...latest.sites[site], bridge_hash: hashed } } });
58
+ if (machine_links(home).sites[site]) atomic_json(path, { ...latest, sites: { ...latest.sites, [site]: { ...latest.sites[site], bridge_hash: hashed } } });
58
59
  } finally { release(); }
59
60
  }
60
61
  return link.bridge_token;