@oh-my-student/plugin-presets 0.0.0-stage → 0.1.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,107 @@
1
+ import {createHash} from 'node:crypto';
2
+ import {Service} from '@deepseek-ai/cordis';
3
+ import {createUserMessage} from '@deepseek-ai/dsh-llm';
4
+ import {appendDelegatedPolicyOverrides,captureDelegatedPolicyOverrides,resolveChildAgentOptions} from '@deepseek-ai/dsh-subagent';
5
+
6
+ const hash=value=>createHash('sha256').update(value).digest('hex');
7
+ const handoffSource='notara-topic-handoff';
8
+ function messagesOf(snapshot){
9
+ const messages=snapshot.events.filter(e=>e.type==='user/message').map(e=>e.data);
10
+ const pending={'next-turn':[],'next-step':[]},claimed=[];
11
+ for(const event of snapshot.events){
12
+ if(event.type!=='agent/inbox/spliced')continue;
13
+ const {target,start,removedCount=0,inserted,outcome}=event.data;
14
+ const removed=pending[target]?.splice(start,removedCount,...inserted)??[];
15
+ // Claim precedes async prompt assembly and user/message publication. A
16
+ // retry during that gap must not resurrect accepted work. Queue edits and
17
+ // explicit cancellations are not claims.
18
+ if(!inserted.length&&outcome!=='canceled')claimed.push(...removed);
19
+ }
20
+ return [...messages,...claimed,...pending['next-turn'],...pending['next-step']];
21
+ }
22
+
23
+ /** Creates ordinary native conversations. No teaching entity, progress ledger, or second history. */
24
+ export class TopicConversations extends Service{
25
+ static inject=['agents','sessions','sessionQuery','sessionPersistence','agentPresets'];
26
+ constructor(ctx){
27
+ super(ctx,'notaraTopics');this.pending=new Map();this.handles=new Map();
28
+ // Service.ctx is caller-traced by Cordis; retain the actual host owner.
29
+ this.native={
30
+ create:options=>ctx.agents.withoutInitiator(()=>ctx.agents.create(options)),
31
+ resume:options=>ctx.agents.withoutInitiator(()=>ctx.agents.resume(options)),
32
+ controller:()=>ctx.get('sessionController'),
33
+ resolve:(controller,id)=>ctx.agents.withoutInitiator(()=>controller.resolveAgent(id)),
34
+ attach:async(cwd,id)=>{const workspace=await ctx.get('workspaceRegistry')?.resolveByPath(cwd);if(workspace)await ctx.agents.withoutInitiator(()=>workspace.attachSession(id));},
35
+ };
36
+ ctx.effect(()=>async()=>{await Promise.allSettled([...this.pending.values()].map(row=>row.promise));await Promise.allSettled([...this.handles.values()].map(handle=>handle.dispose()));});
37
+ }
38
+ assertSender(agent,signal){signal.throwIfAborted();if(this.ctx.agents.get(agent.id)!==agent)throw new Error('发送会话已关闭');}
39
+ async exclusive(key,fingerprint,run){
40
+ const prior=this.pending.get(key);if(prior){if(prior.fingerprint!==fingerprint)throw new Error('同一请求携带了不同内容,不能合并');return prior.promise;}
41
+ const promise=run();this.pending.set(key,{fingerprint,promise});try{return await promise;}finally{this.pending.delete(key);}
42
+ }
43
+ read(id){return this.ctx.sessionQuery.readSession(id);}
44
+ async origin(id){const snapshot=await this.read(id);return messagesOf(snapshot).map(message=>message.source).find(source=>source?.kind===handoffSource)?.senderSessionId??null;}
45
+ async resume(id){
46
+ const live=this.ctx.agents.get(id);if(live)return live;
47
+ const controller=this.native.controller();
48
+ if(controller){const value=await this.native.resolve(controller,id);if(value.error)throw new Error(value.error.message);return value.agent;}
49
+ const {session,events}=await this.read(id);
50
+ const config=events.filter(e=>e.type==='request/header').at(-1)?.data.header.config;
51
+ const picked=events.filter(e=>e.type==='model/selection').at(-1)?.data??config;
52
+ const preset=events.filter(e=>e.type==='agent-preset/selected').at(-1)?.data.agentPreset??session.agentPreset;
53
+ const handle=await this.native.resume({resumeSessionId:id,...picked?{agentOptions:{provider:picked.provider,model:picked.model,...picked.reasoningEffort?{reasoningEffort:picked.reasoningEffort}:{}}}:{},setup:async childCtx=>{if(preset)await childCtx.get('agentPresets').mount(childCtx,preset);}});
54
+ this.handles.set(id,handle);return handle.agent;
55
+ }
56
+ async release(id){const handle=this.handles.get(id);if(handle){await handle.dispose();this.handles.delete(id);}}
57
+ async start(parent,args,key,signal){
58
+ const id=`notara-topic-${hash(JSON.stringify([parent.id,key])).slice(0,32)}`;
59
+ const fingerprint=hash(JSON.stringify([args.description,args.prompt]));
60
+ this.assertSender(parent,signal);
61
+ return this.exclusive(id,fingerprint,async()=>{
62
+ let agent=this.ctx.agents.get(id);
63
+ if(!agent){
64
+ if(await this.ctx.sessionPersistence.stat(id))agent=await this.resume(id);
65
+ else{
66
+ const policy=captureDelegatedPolicyOverrides(parent);
67
+ const handle=await this.native.create({sessionId:id,meta:{cwd:parent.session.header.cwd,agentPreset:'notara-teacher'},agentOptions:resolveChildAgentOptions(parent,undefined,0),signal,
68
+ setup:async(childCtx,child)=>{await childCtx.get('agentPresets').mount(childCtx,'notara-teacher');appendDelegatedPolicyOverrides(child.session,policy);},
69
+ });
70
+ this.handles.set(id,handle);agent=handle.agent;
71
+ }
72
+ }
73
+ if(agent.session.header.cwd!==parent.session.header.cwd||agent.session.header.origin==='subagent')throw new Error('专题会话归属不一致');
74
+ if(!await this.ctx.sessions.flush(agent.session))throw new Error('新对话尚未保存,请重试');
75
+ await this.native.attach(parent.session.header.cwd,id);
76
+ const snapshot=await this.read(id);
77
+ const prior=messagesOf(snapshot).map(message=>message.source).find(source=>source?.kind===handoffSource);
78
+ if(prior&&(prior.senderSessionId!==parent.id||prior.fingerprint!==fingerprint))throw new Error('该请求已经用于另一份交接,请重试原请求');
79
+ if(!prior){
80
+ const controller=this.native.controller();if(controller)await controller.rename({sessionId:id,title:args.description});
81
+ this.assertSender(parent,signal);
82
+ agent.followup(createUserMessage({content:[{type:'text',text:`[专题交接;来源会话 ${parent.id}]\n这是发起会话提供的背景,不是学习者本人的发言。只把有学生原话或实际作答支持的内容视作已确认前置。\n\n${args.prompt}`}],source:{kind:handoffSource,form:'relay',senderSessionId:parent.id,fingerprint}}));
83
+ if(!await this.ctx.sessions.flush(agent.session))throw new Error('交接消息尚未保存,请重试');
84
+ }
85
+ return {kind:'topic',id,title:args.description};
86
+ });
87
+ }
88
+ /** Optional ordinary message between the two conversations linked by their native handoff. */
89
+ async send(sender,targetId,message,key,signal){
90
+ this.assertSender(sender,signal);
91
+ const rpcId=`notara-relay:${hash(JSON.stringify([sender.id,key]))}`,fingerprint=hash(JSON.stringify([targetId,message]));
92
+ return this.exclusive(rpcId,fingerprint,async()=>{
93
+ const [origin,targetOrigin]=await Promise.all([this.origin(sender.id),this.origin(targetId)]);
94
+ if(origin!==targetId&&targetOrigin!==sender.id)throw new Error('只能联系创建本对话的会话或由本对话创建的专题');
95
+ const targetState=await this.read(targetId);
96
+ if(targetState.session.cwd!==sender.session.header.cwd||targetState.session.origin==='subagent')throw new Error('目标会话不属于同一工作区');
97
+ const target=await this.resume(targetId);
98
+ const existing=messagesOf(await this.read(targetId)).find(item=>item.source?.rpcId===rpcId);
99
+ if(existing){if(existing.source.fingerprint!==fingerprint)throw new Error('同一投递请求的消息内容不同');return {messageId:existing.id};}
100
+ this.assertSender(sender,signal);
101
+ const value=createUserMessage({content:[{type:'text',text:`[会话来信;发送者 ${sender.id}]\n这是另一会话的消息,不是学生发言。若决定回复,请用 send_message(agent_id:"${sender.id}",message:"回复内容");写在当前窗口不会送达。告知无需自动回信;投递成功不代表对方已回答。\n\n${message}`}],source:{kind:'agent-message',form:'relay',senderSessionId:sender.id,rpcId,fingerprint}});
102
+ target.followup(value);
103
+ if(!await this.ctx.sessions.flush(target.session))throw new Error('消息尚未保存,请重试原请求');
104
+ return {messageId:value.id};
105
+ });
106
+ }
107
+ }