@evandu/super-pi 0.1.135 → 0.1.137

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.
Files changed (44) hide show
  1. package/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +5 -5
  3. package/dist/client/assets/{CodeMirrorEditor-D6awRUZO.js → CodeMirrorEditor-CkA_5D5r.js} +2 -2
  4. package/dist/client/assets/{dist-BGgidsaF.js → dist-9Fm8XKu4.js} +1 -1
  5. package/dist/client/assets/{dist-spB2Q2CG.js → dist-B02Erd0t.js} +1 -1
  6. package/dist/client/assets/{dist-DoDmoWTn.js → dist-B3Mfuv1D.js} +1 -1
  7. package/dist/client/assets/{dist-yTTCht0J.js → dist-BfEmujEC.js} +1 -1
  8. package/dist/client/assets/{dist-WkVUFX28.js → dist-BqGjijI6.js} +1 -1
  9. package/dist/client/assets/{dist-DOwrSiuY.js → dist-BsqzrZ5R.js} +1 -1
  10. package/dist/client/assets/{dist-BYMYC_2I.js → dist-BySjislr.js} +1 -1
  11. package/dist/client/assets/{dist-Cwb-Lb5-.js → dist-C2tSd-AY.js} +1 -1
  12. package/dist/client/assets/{dist-DCiuVvGU.js → dist-CrxmpY93.js} +1 -1
  13. package/dist/client/assets/{dist-1jSyapdW.js → dist-CsNytPug.js} +1 -1
  14. package/dist/client/assets/{dist-mPR8uuzk.js → dist-D-LtG92p.js} +1 -1
  15. package/dist/client/assets/{dist-DeZsNbFx.js → dist-DGhdkud4.js} +1 -1
  16. package/dist/client/assets/{dist-BkVaPA_q.js → dist-DL4Q05ts.js} +1 -1
  17. package/dist/client/assets/{dist-CNuKauOq.js → dist-D_umYm0o.js} +1 -1
  18. package/dist/client/assets/{dist-CubYGjAk.js → dist-DbovsYeH.js} +1 -1
  19. package/dist/client/assets/{dist-C46474ys.js → dist-DcdzfM-n.js} +1 -1
  20. package/dist/client/assets/{dist-BoeM5xW7.js → dist-DdVs8Y0X.js} +1 -1
  21. package/dist/client/assets/{dist-DHBWUQhM.js → dist-MC2XuZgq.js} +1 -1
  22. package/dist/client/assets/{dist-D0Iw5XQA.js → dist-b9CfAgzG.js} +1 -1
  23. package/dist/client/assets/{dist-lZUWPQbT.js → dist-j9X7j381.js} +1 -1
  24. package/dist/client/assets/{dist-CCkdCH6a.js → dist-k6bOz9RS.js} +1 -1
  25. package/dist/client/assets/{dist-Dk3e6RfY.js → dist-ufRHdQ8G.js} +1 -1
  26. package/dist/client/assets/{index-Dyta-HkQ.js → index-DVmE8H4Y.js} +12 -12
  27. package/dist/client/assets/index-FxTrUapG.css +1 -0
  28. package/dist/client/index.html +2 -2
  29. package/dist/server/active-memory.d.ts +17 -2
  30. package/dist/server/active-memory.js +76 -8
  31. package/dist/server/application/catalog-service.js +8 -6
  32. package/dist/server/application/conversation-service.js +4 -2
  33. package/dist/server/application/scheduled-task-service.d.ts +7 -0
  34. package/dist/server/application/scheduled-task-service.js +14 -5
  35. package/dist/server/files.js +11 -1
  36. package/dist/server/http-server.d.ts +0 -3
  37. package/dist/server/http-server.js +131 -87
  38. package/dist/server/model-filter.d.ts +13 -3
  39. package/dist/server/model-filter.js +7 -5
  40. package/dist/server/store.d.ts +27 -0
  41. package/dist/server/store.js +44 -0
  42. package/dist/server/subagents.js +10 -2
  43. package/package.json +9 -1
  44. package/dist/client/assets/index-V93Kzott.css +0 -1
@@ -9,6 +9,13 @@ export class ActiveMemory {
9
9
  inverted = new Map();
10
10
  indexedWorkspaces = new Set();
11
11
  indexedMessages = new Set();
12
+ /**
13
+ * messageId -> which workspace's index holds it and under which terms. This is the reverse side of
14
+ * `inverted`, and it is what makes removal surgical: a revert knows exactly which messages it cut
15
+ * (the store's `plan.removedMessageIds`), so exactly those postings can be dropped - the rest of the
16
+ * workspace stays indexed instead of paying a full rebuild on the next search.
17
+ */
18
+ termsByMessage = new Map();
12
19
  notes;
13
20
  capabilities = new Map();
14
21
  reviews = new Map();
@@ -132,13 +139,19 @@ export class ActiveMemory {
132
139
  if (this.indexedWorkspaces.has(workspaceId))
133
140
  return;
134
141
  const index = new Map();
135
- for (const conversation of this.store.snapshot().conversations.filter(c => c.workspaceId === workspaceId))
142
+ const store = this.store;
143
+ const conversations = store.conversationsForWorkspace
144
+ ? store.conversationsForWorkspace(workspaceId)
145
+ : store.snapshot().conversations.filter(c => c.workspaceId === workspaceId);
146
+ for (const conversation of conversations)
136
147
  for (const message of conversation.messages) {
137
148
  if (message.role !== "user" && message.role !== "assistant")
138
149
  continue;
139
150
  const value = { conversationId: conversation.id, title: conversation.title, messageId: message.id, role: message.role, content: message.content };
140
151
  this.indexedMessages.add(message.id);
141
- for (const word of message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? []) {
152
+ const terms = message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? [];
153
+ this.termsByMessage.set(message.id, { workspaceId, terms });
154
+ for (const word of terms) {
142
155
  let bucket = index.get(word);
143
156
  if (!bucket)
144
157
  index.set(word, bucket = new Map());
@@ -149,7 +162,7 @@ export class ActiveMemory {
149
162
  this.indexedWorkspaces.add(workspaceId);
150
163
  }
151
164
  /**
152
- * Drops a workspace's search index so the next search rebuilds it from the store.
165
+ * Drops exactly the given messages from the search index, leaving the rest of the workspace indexed.
153
166
  *
154
167
  * WHY THIS EXISTS. `ensureIndex` builds once per workspace and `addToIndex` only ever adds, so the
155
168
  * index outlives the messages it indexed: a revert removes messages from the store while the index
@@ -158,17 +171,54 @@ export class ActiveMemory {
158
171
  * history" after a revert every file-level assertion proved had really truncated. The store is the
159
172
  * source of truth; the index is a cache of it, and a cache that survives an event that invalidated
160
173
  * its source is wrong by definition.
174
+ *
175
+ * WHY SURGICAL. The caller (revert) knows precisely which message ids the store dropped, and this
176
+ * method trusts that list rather than re-deriving it: the ids not in `termsByMessage` were never
177
+ * indexed (no search had built the index, or a role the index skips), so a no-op for them is
178
+ * correct, and the next search - if the index was never built - builds from the post-revert store
179
+ * anyway. Dropping the whole workspace index instead would force the next search to rebuild every
180
+ * surviving conversation's entries for no reason, and the recall gap in between would answer
181
+ * searches with nothing at all.
161
182
  */
162
- invalidateSearchIndex(workspaceId) {
163
- this.indexedWorkspaces.delete(workspaceId);
164
- this.inverted.delete(workspaceId);
183
+ removeFromIndex(messageIds) {
184
+ for (const messageId of messageIds) {
185
+ const entry = this.termsByMessage.get(messageId);
186
+ if (!entry)
187
+ continue;
188
+ const terms = this.inverted.get(entry.workspaceId);
189
+ if (terms) {
190
+ for (const term of entry.terms) {
191
+ const bucket = terms.get(term);
192
+ if (!bucket)
193
+ continue;
194
+ bucket.delete(messageId);
195
+ if (!bucket.size)
196
+ terms.delete(term);
197
+ }
198
+ }
199
+ this.termsByMessage.delete(messageId);
200
+ this.indexedMessages.delete(messageId);
201
+ }
165
202
  }
166
203
  addToIndex(conversation, message) {
167
204
  if (!this.indexedWorkspaces.has(conversation.workspaceId) || (message.role !== "user" && message.role !== "assistant"))
168
205
  return;
169
206
  let terms = this.inverted.get(conversation.workspaceId);
170
207
  const value = { conversationId: conversation.id, title: conversation.title, messageId: message.id, role: message.role, content: message.content };
171
- for (const term of message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? []) {
208
+ const messageTerms = message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? [];
209
+ // A REFRESH MUST ALSO FORGET THE OLD TERMS. The buckets map a term to the message that contains
210
+ // it, and a term that only the OLD content had would keep pointing at this message with a stale
211
+ // copy - so a search for a word the model has since dropped would still hand the reader the old
212
+ // text. The terms this message had are remembered exactly so they can be dropped here.
213
+ const previous = this.termsByMessage.get(message.id);
214
+ if (previous)
215
+ for (const term of previous.terms) {
216
+ const bucket = terms.get(term);
217
+ if (bucket)
218
+ bucket.delete(message.id);
219
+ }
220
+ this.termsByMessage.set(message.id, { workspaceId: conversation.workspaceId, terms: messageTerms });
221
+ for (const term of messageTerms) {
172
222
  let bucket = terms.get(term);
173
223
  if (!bucket)
174
224
  terms.set(term, bucket = new Map());
@@ -270,6 +320,21 @@ export class ActiveMemory {
270
320
  this.cancel(id); }
271
321
  async waitForIdle() { await Promise.allSettled([...this.workspaceReviews.values()]); await this.serial; }
272
322
  async completed(conversation) {
323
+ // REFRESH THIS TURN'S MESSAGES, DO NOT JUST FILL IN THE UNSEEN ONES.
324
+ //
325
+ // The index is a CACHE of the store's content, and an assistant message's content changes while
326
+ // the model is still writing it: the entry was built from the empty placeholder the turn opened
327
+ // with, so it holds the answer BEFORE the answer. "I have seen this message" is not "I have seen
328
+ // its final content" - treating the two as the same froze this turn's answer in the index, and a
329
+ // frozen answer is an answer that can never be recalled (measured by
330
+ // scripts/active-memory-index-content.test.mjs: the recall came back `undefined`).
331
+ //
332
+ // Only THIS TURN's messages are refreshed - the ones after the last user message. The rest of the
333
+ // transcript was indexed when it was final, and re-tokenizing a whole conversation on every turn
334
+ // end is exactly the cost this file is about.
335
+ const lastUserIndex = conversation.messages.map((message) => message.role).lastIndexOf("user");
336
+ for (const message of lastUserIndex >= 0 ? conversation.messages.slice(lastUserIndex + 1) : conversation.messages)
337
+ this.addToIndex(conversation, message);
273
338
  for (const message of conversation.messages)
274
339
  if (!this.indexedMessages.has(message.id))
275
340
  this.addToIndex(conversation, message);
@@ -301,7 +366,10 @@ export class ActiveMemory {
301
366
  const settings = await this.configuration();
302
367
  if (!settings.enabled || !settings.reviewInterval)
303
368
  return;
304
- const workspace = this.store.snapshot().workspaces.find(w => w.id === snapshot.workspaceId);
369
+ const store = this.store;
370
+ const workspace = store.workspace
371
+ ? store.workspace(snapshot.workspaceId)
372
+ : store.snapshot().workspaces.find(w => w.id === snapshot.workspaceId);
305
373
  if (!workspace)
306
374
  return;
307
375
  const agent = await this.runtime.agentFor?.(snapshot);
@@ -1,4 +1,4 @@
1
- import { assertModelAllowed, conversationWorkspace } from "../model-filter.js";
1
+ import { assertModelAllowed } from "../model-filter.js";
2
2
  import { readAgents, resolveAgentDirectories } from "../agents.js";
3
3
  import { loadPromptTemplateSummaries } from "../pi-session.js";
4
4
  function summarize(agent) {
@@ -75,10 +75,12 @@ export class CatalogService {
75
75
  const catalog = await this.runtime.catalog(selection);
76
76
  if (!conversationId || !this.store)
77
77
  return catalog;
78
- const state = this.store.snapshot();
79
- const workspace = conversationWorkspace(state, conversationId);
78
+ const workspace = this.store.workspaceForConversation(conversationId);
79
+ if (!workspace)
80
+ throw new Error("会话所属工作区不存在");
81
+ const rules = this.store.modelFilterRules();
80
82
  return { ...catalog, models: catalog.models.filter(model => { try {
81
- assertModelAllowed(state.modelFilterRules, workspace, model);
83
+ assertModelAllowed(rules, workspace, model);
82
84
  return true;
83
85
  }
84
86
  catch {
@@ -108,7 +110,7 @@ export class CatalogService {
108
110
  throw new Error("Store unavailable");
109
111
  await this.store.setModelFilterRules(rules);
110
112
  this.broadcaster.broadcastState();
111
- for (const conversation of this.store.snapshot().conversations) {
113
+ for (const conversation of this.store.catalogConversationTargets()) {
112
114
  if (!conversation.archivedAt) {
113
115
  try {
114
116
  await this.broadcastCatalog(conversation.model, conversation.id);
@@ -182,7 +184,7 @@ export class CatalogService {
182
184
  const cached = this.promptCatalogs.get(workspaceId);
183
185
  if (cached && cached.expiresAt > Date.now())
184
186
  return cached.catalog;
185
- const workspace = this.store?.snapshot().workspaces.find((item) => item.id === workspaceId);
187
+ const workspace = this.store?.workspace(workspaceId);
186
188
  if (!workspace)
187
189
  return { source: "unavailable", prompts: [], workspaceId, fetchedAt: new Date().toISOString(), error: "workspace not found" };
188
190
  try {
@@ -359,8 +359,10 @@ export class ConversationService {
359
359
  // The recall index is a cache of the store's messages, and the store just lost several: left alone it
360
360
  // keeps answering the memory extension's searches with the withdrawn turns, so the model is told about
361
361
  // history the person reverted away even though the transcript, Pi's file and the memory store all
362
- // agree it is gone. Invalidate it and the next search rebuilds from what actually survived.
363
- this.memory?.invalidateSearchIndex(workspaceId);
362
+ // agree it is gone. The store's plan says exactly which message ids were dropped, so exactly those
363
+ // postings leave the index - the surviving conversations of this workspace stay indexed and the next
364
+ // search pays no rebuild.
365
+ this.memory?.removeFromIndex(applied.removedMessageIds);
364
366
  console.log(`Super Pi: reverted conversation ${conversationId} to ${applied.boundaryIndex + 1} message(s): ` +
365
367
  `${plan.removedMessageIds.length} message(s) and ${applied.doomedRunIds.length} sub-agent run(s) dropped from state.json; ` +
366
368
  `Pi's file ${outcome.sessionFile} kept ${outcome.linesKept} line(s) and lost ${outcome.entriesDropped}; ` +
@@ -147,6 +147,13 @@ export declare class ScheduledTaskService {
147
147
  * one that has to use it. A failure here is the task's failure - a model that Pi no longer offers
148
148
  * cannot be silently ignored, because the run would then happen on some other model and the panel
149
149
  * would say it succeeded.
150
+ *
151
+ * AND WHEN THERE IS NO OVERRIDE, THE DEFAULTS BEHIND IT GO ON THE CONVERSATION TOO. Clearing the
152
+ * override in the panel means 「跟着工作区默认走」, and the only way to honour that on a conversation
153
+ * that already ran with the override is to PUT THE DEFAULT BACK. The conversation's model is a
154
+ * copy-at-birth value (AGENTS.md: 建会话时把默认值盖到会话字段上,之后永不回查工作区), so "do
155
+ * nothing when there is no override" keeps the WITHDRAWN override pinned for good - and the panel
156
+ * says the run succeeded on a model the user just took away.
150
157
  */
151
158
  private applyConfiguration;
152
159
  /** Writes down how the run went, and tells the panel. */
@@ -302,14 +302,23 @@ export class ScheduledTaskService {
302
302
  * one that has to use it. A failure here is the task's failure - a model that Pi no longer offers
303
303
  * cannot be silently ignored, because the run would then happen on some other model and the panel
304
304
  * would say it succeeded.
305
+ *
306
+ * AND WHEN THERE IS NO OVERRIDE, THE DEFAULTS BEHIND IT GO ON THE CONVERSATION TOO. Clearing the
307
+ * override in the panel means 「跟着工作区默认走」, and the only way to honour that on a conversation
308
+ * that already ran with the override is to PUT THE DEFAULT BACK. The conversation's model is a
309
+ * copy-at-birth value (AGENTS.md: 建会话时把默认值盖到会话字段上,之后永不回查工作区), so "do
310
+ * nothing when there is no override" keeps the WITHDRAWN override pinned for good - and the panel
311
+ * says the run succeeded on a model the user just took away.
305
312
  */
306
313
  async applyConfiguration(task, conversationId) {
307
- if (task.model) {
308
- await this.conversationService.setModel(conversationId, task.model.provider, task.model.modelId);
309
- }
310
- if (task.agentId) {
311
- await this.conversationService.setAgent(conversationId, task.agentId);
314
+ const state = this.store.snapshot();
315
+ const workspace = state.workspaces.find((item) => item.id === task.workspaceId);
316
+ const model = task.model ?? workspace?.defaultModel ?? state.defaultModel;
317
+ if (model) {
318
+ await this.conversationService.setModel(conversationId, model.provider, model.modelId);
312
319
  }
320
+ const agentId = task.agentId ?? workspace?.defaultAgentId ?? null;
321
+ await this.conversationService.setAgent(conversationId, agentId);
313
322
  }
314
323
  /** Writes down how the run went, and tells the panel. */
315
324
  async record(task, outcome) {
@@ -650,7 +650,17 @@ export async function writeWorkspaceFile(root, requested, content, baseEtag) {
650
650
  throw new FilesError(415, "拒绝写入:内容包含二进制字节(\\0)", "binary");
651
651
  const original = await readFile(target);
652
652
  const hadBom = original.length >= 3 && original[0] === 0xef && original[1] === 0xbb && original[2] === 0xbf;
653
- const output = hadBom ? `\uFEFF${content}` : content;
653
+ // THE LINE ENDINGS THE FILE HAD, NOT THE ONES THE EDITOR NORMALIZED.
654
+ //
655
+ // CodeMirror's document is always LF (it splits lines on `/\r\n?|\n/`), so a CRLF file comes back
656
+ // from the editor as LF text - and writing that back is how one edited character turns into a
657
+ // whole-file diff, on Windows, in every repository with `core.autocrlf=true`. The contract in this
658
+ // file already says it ("a CRLF file is saved back as CRLF instead of silently rewritten to LF");
659
+ // it just was not implemented HERE: the BOM's byte-level convention was honoured two lines above
660
+ // and the EOL's was dropped.
661
+ const eol = original.includes("\r\n") ? "\r\n" : "\n";
662
+ const body = eol === "\r\n" ? content.replace(/\r?\n/g, "\r\n") : content.replace(/\r\n/g, "\n");
663
+ const output = hadBom ? `\uFEFF${body}` : body;
654
664
  const temporary = await mkdtemp(`${target}.super-pi-${process.pid}-`);
655
665
  const temporaryFile = join(temporary, basename(target));
656
666
  try {
@@ -30,7 +30,4 @@ export interface HttpServerOptions {
30
30
  context: ServerContext;
31
31
  onShutdown: (reason: string) => Promise<void>;
32
32
  }
33
- /**
34
- * Creates the HTTP server providing static client assets and all `/api/*` endpoints.
35
- */
36
33
  export declare function createHttpServer(options: HttpServerOptions): NodeHttpServer;