wicker-study-mcp 2.9.0 → 2.11.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.
package/README.md CHANGED
@@ -17,7 +17,7 @@ the MCP server.
17
17
  For a manually supplied key, the same secure bootstrap is available as:
18
18
 
19
19
  ```sh
20
- WICKER_STUDY_URL='https://study.wicker.life' WICKER_STUDY_API_KEY='wsk_…' npx -y wicker-study-mcp@2.9.0 configure
20
+ WICKER_STUDY_URL='https://study.wicker.life' WICKER_STUDY_API_KEY='wsk_…' npx -y wicker-study-mcp@2.11.0 configure
21
21
  ```
22
22
 
23
23
  ```jsonc
@@ -77,7 +77,7 @@ they are how a key is obtained. Everything else needs one.
77
77
  `update_planning_objective`, `set_course_visibility`, `join_programme`
78
78
  - **Documents and calendars** — `analyze_documents`, `apply_changes`, `preview_calendar`,
79
79
  `save_calendar_link`, `sync_calendar_link`, `remove_calendar_link`
80
- - **Canvas** — `canvas_connect`, `canvas_updates` (announcements, assignments with
80
+ - **Canvas** — `canvas_groups` (your course teams and global memberships; pass a group ID to read teammates), `canvas_connect`, `canvas_updates` (announcements, assignments with
81
81
  submission state, events, grades), `canvas_course_requirements` (syllabus and the
82
82
  module item carrying the assessment rules), `canvas_list_remote_courses`,
83
83
  `canvas_list_remote_course_modules`, `canvas_import_remote_course`,
@@ -100,7 +100,7 @@ authoritative list of endpoints and scopes.
100
100
 
101
101
  Direct context reads do not call the model. `tutor_ask` uses the student's AI allowance and can prepare attendance changes, assignment/catch-up trackers, group milestones, focused practice, diagnostics and rubric-based draft reviews. Reuse conversation IDs. Approve only the exact proposal the student reviewed; receipts prevent double application. No tool sends email or submits assignments to Canvas. Personal completion is separate from Canvas submission status.
102
102
 
103
- Update an installed client to `wicker-study-mcp@2.9.0` and restart its MCP connection to discover the new tools. The companion skill is served at [SKILL.md](https://study.wicker.life/skills/wicker-study/SKILL.md); re-download it to update an existing copy.
103
+ Update an installed client to `wicker-study-mcp@2.11.0` and restart its MCP connection to discover the new tools. The companion skill is served at [SKILL.md](https://study.wicker.life/skills/wicker-study/SKILL.md); re-download it to update an existing copy.
104
104
 
105
105
  ## Licence
106
106
 
@@ -135,3 +135,35 @@ Do not infer or store sensitive preferences from course material or third-party
135
135
  ## AI activity log
136
136
 
137
137
  Settings → AI activity (`/app/settings?tab=activity`) shows API-key requests from this release onward, with read/write/prepare filters, outcome, duration, tool/client label and confirmed-review reference. The MCP tags requests automatically. One tool may make several HTTP requests; local actions that never reach the platform are not logged. Client labels and client-reported confirmation are not independent proof of approval. The server records confirmed prepared-review IDs separately. Arguments, query text, responses and credentials are excluded. Activity is private to the account, included in data export, and removed by account-data erasure.
138
+
139
+ Automatic Canvas refresh is configurable in Settings → Connections → Manage: on/off, update frequency (15 minutes to daily), material frequency (hourly to weekly), and studying/completed status. Defaults remain 30 minutes and six hours. Course selection is re-evaluated at least hourly across period boundaries. Summer/break monitoring retains the ending year and discovers upcoming next-year courses, selecting the latest eligible edition per course. Completion or no active programme pauses background collection; manual refresh remains available. These preferences require a signed-in browser, not an MCP write.
140
+
141
+ ## Feedback (2.10)
142
+
143
+ Use `feedback_prepare` to create an exact report preview, then show it and obtain explicit user approval before `feedback_submit` with the unchanged draft ID and revision. `feedback_list` and `feedback_read` expose only the user’s reports and public replies. `feedback_reply`, `feedback_withdraw_evidence`, and `feedback_react` each require a fresh `confirmed:true` after individual approval. A prepared draft is not a submitted report. Never attach chat or source excerpts without the user choosing to share them. Feedback is separate from remembered Tutor context.
144
+
145
+ Students can follow reports and withdraw evidence at `/app/feedback`; authorized staff review them at `/app/admin/feedback`. See [the operations guide](../docs/FEEDBACK.md) for data boundaries and retention.
146
+
147
+ Contact sharing is optional per report: use `shareContactEmail:true` only when the student chooses it and show the returned address in the preview. `feedback_withdraw_contact` stops sharing it after fresh confirmation. Reports show receipt, investigation and completion updates with public comments; AI-assisted replies are labeled and reviewed by the team.
148
+
149
+ ### Updating Claude Code
150
+
151
+ `claude mcp add` does not overwrite an existing registration. If it reports “MCP server wicker-study already exists in user config”, replace that registration:
152
+
153
+ ```sh
154
+ claude mcp remove --scope user wicker-study
155
+ claude mcp add --scope user wicker-study -- npx -y wicker-study-mcp@2.11.0
156
+ ```
157
+
158
+ Restart Claude Code afterward. Removing the MCP registration does not remove Wicker’s saved credentials in `~/.config/wicker-study/config.json`; you do not need to run `configure` again. This updates Claude Code, not the separate Claude Desktop configuration.
159
+
160
+ ## Local study generation
161
+
162
+ The 2.11.0 source adds `study_generation_contract`, `study_generation_sources`, `study_generation_start`, `study_generation_next`, `study_generation_submit`, `study_generation_refresh`, `study_generation_stop`, and `study_generation_add_notes`. Install this repository checkout until that package version is published.
163
+
164
+ 1. Read the current contract and list the course edition’s sources. If needed, download originals with `canvas_import_remote_course`; inspect graphics and preserve page numbers. Add supplementary extraction as explicitly labelled local notes.
165
+ 2. Start the student-requested private run with its exact source selection, or continue a version prepared in the web UI.
166
+ 3. Fetch `study_generation_next`. Use its exact evidence, prompt and response schema to compute one complete response locally. Use a fresh critique context for review steps and report real findings.
167
+ 4. Submit with its `requestId` and `contractId`. Retry network failures with the identical payload. Fetch next again until complete. Failed content can be corrected with `retry:true`; ready chapters remain saved.
168
+
169
+ Prompts and schemas are fetched from the deployed platform every step. An implementation fingerprint rejects submissions after pipeline changes; fetch next again. No platform generation worker, model call or AI allowance is used. Your local provider/subscription costs still apply. Local semantic reviews are agent-supplied; platform schema, citations and deterministic quality checks are enforced. This is not independent editorial verification, and nothing is shared automatically.
@@ -0,0 +1,126 @@
1
+ export function registerFeedbackTools(server, { z, run, api }) {
2
+ const id = z.string().min(1).max(180),
3
+ subject = z.object({
4
+ kind: z
5
+ .enum([
6
+ "general",
7
+ "answer",
8
+ "material",
9
+ "assignment",
10
+ "announcement",
11
+ "sync",
12
+ "attendance",
13
+ "credits",
14
+ "practice",
15
+ ])
16
+ .optional(),
17
+ route: z.string().max(500).optional(),
18
+ conversationId: id.optional(),
19
+ answerId: id.optional(),
20
+ answerRevision: id.optional(),
21
+ courseCode: id.optional(),
22
+ academicYear: id.optional(),
23
+ assetId: id.optional(),
24
+ itemId: id.optional(),
25
+ jobId: id.optional(),
26
+ });
27
+ const tool = (name, description, schema, handler) =>
28
+ server.tool(name, description, schema, run(handler));
29
+ tool(
30
+ "feedback_prepare",
31
+ "Prepare an encrypted feedback preview. This does NOT submit it. Show the complete returned preview and ask explicit confirmation. Include only the user’s intended feedback; excerpts require their consent. This report is separate from Tutor memory.",
32
+ {
33
+ category: z.enum([
34
+ "incorrect",
35
+ "outdated",
36
+ "missing",
37
+ "source",
38
+ "slow",
39
+ "broken",
40
+ "confusing",
41
+ "accessibility",
42
+ "suggestion",
43
+ "other",
44
+ "wrong-edition",
45
+ "incomplete-extraction",
46
+ "broken-download",
47
+ "ignored-context",
48
+ "too-wordy",
49
+ "wrong-action",
50
+ ]),
51
+ note: z.string().max(4000),
52
+ shareContactEmail: z
53
+ .boolean()
54
+ .optional()
55
+ .describe(
56
+ "Share the verified account email only if the user explicitly opts in. The preview shows the exact address.",
57
+ ),
58
+ subject: subject.optional(),
59
+ evidence: z
60
+ .array(
61
+ z.object({
62
+ label: z.string().max(100),
63
+ mediaType: z.literal("text/plain"),
64
+ content: z.string().max(12000),
65
+ }),
66
+ )
67
+ .max(5)
68
+ .optional(),
69
+ },
70
+ (args) => api("/api/feedback/drafts", { method: "POST", body: args }),
71
+ );
72
+ tool(
73
+ "feedback_submit",
74
+ "Submit exactly the reviewed feedback draft. Obtain fresh explicit user confirmation for this report; pass its unchanged revision. Retrying the same confirmed draft returns the same receipt.",
75
+ { draftId: id, revision: id },
76
+ (args) => api("/api/feedback/reports", { method: "POST", body: args }),
77
+ );
78
+ tool(
79
+ "feedback_list",
80
+ "Read the user’s submitted feedback and public review status. Private administrator notes are excluded.",
81
+ { before: id.optional() },
82
+ (args) => api("/api/feedback/reports", { query: args }),
83
+ );
84
+ tool(
85
+ "feedback_read",
86
+ "Read one owned feedback report, public replies and attachment metadata. Does not read any referenced chat or private course file.",
87
+ { reportId: id },
88
+ (args) => api(`/api/feedback/reports/${encodeURIComponent(args.reportId)}`),
89
+ );
90
+ tool(
91
+ "feedback_reply",
92
+ "Add the exact user-approved follow-up to an owned report.",
93
+ { reportId: id, body: z.string().min(1).max(4000) },
94
+ (args) =>
95
+ api(
96
+ `/api/feedback/reports/${encodeURIComponent(args.reportId)}/replies`,
97
+ { method: "POST", body: args },
98
+ ),
99
+ );
100
+ tool(
101
+ "feedback_withdraw_evidence",
102
+ "Permanently withdraw a shared attachment from an owned feedback report after explicit confirmation. Does not delete the original course or Tutor file.",
103
+ { reportId: id, evidenceId: id },
104
+ (args) =>
105
+ api(
106
+ `/api/feedback/reports/${encodeURIComponent(args.reportId)}/evidence/${encodeURIComponent(args.evidenceId)}`,
107
+ { method: "DELETE", body: args },
108
+ ),
109
+ );
110
+ tool(
111
+ "feedback_react",
112
+ "Record or remove the student’s explicitly chosen reaction to one exact Tutor answer revision.",
113
+ { subject, value: z.enum(["helpful", "not-helpful"]).nullable() },
114
+ (args) => api("/api/feedback/reactions", { method: "POST", body: args }),
115
+ );
116
+ tool(
117
+ "feedback_withdraw_contact",
118
+ "Stop sharing the account email on this report, after explicit user confirmation.",
119
+ { reportId: id },
120
+ (args) =>
121
+ api(
122
+ `/api/feedback/reports/${encodeURIComponent(args.reportId)}/contact`,
123
+ { method: "DELETE", body: args },
124
+ ),
125
+ );
126
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicker-study-mcp",
3
- "version": "2.9.0",
3
+ "version": "2.11.0",
4
4
  "description": "MCP server for Wicker Study: read course material and a student's academic record, study on their behalf, collect a private Canvas course snapshot, and \u2014 with an admin key \u2014 run the editorial workflow.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -30,6 +30,7 @@
30
30
  "files": [
31
31
  "server.mjs",
32
32
  "study-tools.mjs",
33
+ "feedback-tools.mjs",
33
34
  "write-confirmation.mjs",
34
35
  "config.mjs",
35
36
  "authorize.mjs",
package/server.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { registerFeedbackTools } from './feedback-tools.mjs'
2
3
  import { installWriteConfirmation, toolRequestContext } from './write-confirmation.mjs'
3
4
  import { registerStudyTools } from './study-tools.mjs'
4
5
  // Wicker Study MCP server — a thin stdio wrapper over the HTTP API so agents
@@ -65,7 +66,7 @@ async function apiResponse(path, { method = 'GET', body, query, timeoutMs } = {}
65
66
  const response = await fetch(url, {
66
67
  ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
67
68
  method,
68
- headers: { authorization: `Bearer ${requireKey()}`, accept: 'application/json', 'x-wicker-client': 'wicker-study-mcp 2.9.0', ...(toolRequestContext.getStore() ? { 'x-wicker-tool': toolRequestContext.getStore().tool, 'x-wicker-confirmed': String(toolRequestContext.getStore().confirmed) } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
69
+ headers: { authorization: `Bearer ${requireKey()}`, accept: 'application/json', 'x-wicker-client': 'wicker-study-mcp 2.11.0', ...(toolRequestContext.getStore() ? { 'x-wicker-tool': toolRequestContext.getStore().tool, 'x-wicker-confirmed': String(toolRequestContext.getStore().confirmed) } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
69
70
  body: body !== undefined ? JSON.stringify(body) : undefined
70
71
  })
71
72
  if (!response.ok) {
@@ -92,10 +93,11 @@ const json = (value) => ({ content: [{ type: 'text', text: typeof value === 'str
92
93
  const failed = (error) => ({ isError: true, content: [{ type: 'text', text: error.message }] })
93
94
  const run = (fn) => async (args) => { try { return json(await fn(args)) } catch (error) { return failed(error) } }
94
95
 
95
- const server = new McpServer({ name: 'wicker-study', version: '2.9.0' })
96
+ const server = new McpServer({ name: 'wicker-study', version: '2.11.0' })
96
97
  installWriteConfirmation(server, z)
97
98
  const courseId = z.string().describe('Course id (e.g. "sec"). Use list_courses to discover ids.')
98
99
  const chapterId = z.string().describe('Chapter id (e.g. "02").')
100
+ registerFeedbackTools(server, { z, run, api })
99
101
  registerStudyTools(server, { z, run, api, defaultCanvasUrl: DEFAULT_CANVAS_URL })
100
102
 
101
103
  const COURSE_SOURCE_EXTENSIONS = new Set(['.pdf', '.ppt', '.pptx', '.doc', '.docx', '.txt', '.md', '.csv', '.tex', '.m', '.py', '.r', '.html', '.htm', '.png', '.jpg', '.jpeg', '.webp'])
package/study-tools.mjs CHANGED
@@ -6,8 +6,21 @@ export function registerStudyTools(server, { z, run, api, defaultCanvasUrl }) {
6
6
  const canvasUrl = z.string().url().optional()
7
7
  const context = z.object({ courseId: code, courseCode: code, chapterId: code, sourcePath: z.string().max(500).optional(), attachmentIds: z.array(id).max(10).optional() }).optional()
8
8
  const tool = (name, description, schema, handler) => server.tool(name, description, schema, run(handler))
9
+ // The installed MCP is a transport. Prompts, schemas and validation always
10
+ // come from the deployed pipeline, never a bundled copy that can drift.
11
+ const courseIdentity = { courseCode: z.string().min(2).max(80), courseName: z.string().max(180).optional(), academicYear: z.string().regex(/^(20\d{2}-20\d{2}|undated)$/), period: z.string().max(80).optional() }
12
+ const selection = { sourceKeys: z.array(id).min(1).max(500), includeHistorical: z.boolean().optional() }
13
+ tool('study_generation_contract', 'Read the current deployed generation protocol and quality contract before local generation. The platform provides the exact prompt and JSON schema for every step; do not use a cached prompt pack. No platform model call.', {}, () => api('/api/study-versions/local/contract'))
14
+ tool('study_generation_sources', 'List authorised source keys for a course edition before starting local generation. To process original files locally, use canvas_import_remote_course; retain slide graphics, tables, notes and page numbers. Add your OCR/visual interpretations as explicit local notes, never mislabel them as original source text.', courseIdentity, args => api('/api/study-versions/sources', { query: args }))
15
+ tool('study_generation_start', 'Start a private local-generation run using the student’s chosen course and source keys. Use when the student requests local generation. Then repeatedly fetch next, compute its complete JSON response locally, and submit. No platform AI billing or hosted worker is started. The authorised run includes its generation and review steps; it does not publish or share the guide.', { ...courseIdentity, ...selection, title: z.string().max(180).optional(), confirmed: z.literal(true) }, body => api('/api/study-versions/local', { method: 'POST', body }))
16
+ tool('study_generation_next', 'Continue an already authorised local run. Fetch the latest exact prompt, source evidence, response schema and contractId. Compute the response with your local model and submit using the returned requestId. For quality review use a fresh critique context; report unsupported claims honestly. If request is null inspect status: complete/failed/stopped need no polling, active may need another next call. retry:true resumes failed/stopped work. Never regenerate a finished step or ask for approval on each continuation.', { versionId: id, retry: z.boolean().optional() }, ({versionId,...body}) => api(`/api/study-versions/${encodeURIComponent(versionId)}/local/next`, { method: 'POST', body, timeoutMs: 120000 }))
17
+ tool('study_generation_submit', 'Submit the complete JSON result for exactly one pending local request. Reuse requestId and identical response on network retry; changed contract or stale inputs require fetching next again. Server applies the same schema, citation, teaching-quality, repair and activation checks as hosted generation. This continues the existing authorised run and never publishes the guide.', { versionId: id, requestId: id, contractId: id, response: z.union([z.string().max(600000), z.record(z.unknown())]) }, ({versionId,...body}) => api(`/api/study-versions/${encodeURIComponent(versionId)}/local/submit`, { method: 'POST', body, timeoutMs: 120000 }))
18
+ tool('study_generation_refresh', 'Start a new local revision with a changed source selection after the student requests an update. Stop an active run first. Finished revisions remain readable. Then continue with study_generation_next.', { versionId: id, ...selection, confirmed: z.literal(true) }, ({versionId,...body}) => api(`/api/study-versions/${encodeURIComponent(versionId)}/local/refresh`, { method: 'POST', body }))
19
+ tool('study_generation_stop', 'Pause the student’s local run. Completed steps and published-to-self revisions are preserved; an outstanding local result can no longer be submitted.', { versionId: id }, ({versionId}) => api(`/api/study-versions/${encodeURIComponent(versionId)}/local/stop`, { method: 'POST', body: {} }))
20
+ tool('study_generation_add_notes', 'Save student-authorised local extraction or notes as a clearly labelled private source. Preserve original page numbers and identify source filenames in text. Describe relevant graphs, tables, diagrams and relationships accurately; distinguish inference from source facts. Does not alter the original Canvas document. Use its returned id in sourceKeys.', { ...courseIdentity, title: z.string().max(150), pages: z.array(z.object({page: z.number().int().positive().nullable(), text: z.string().max(300000)})).min(1).max(500), confirmed: z.literal(true) }, body => api('/api/study-versions/local/notes', { method: 'POST', body }))
9
21
  tool('read_course_source', 'Read 12 indexed passages from an authorised Canvas document returned by search_course. Follow nextOffset to read beyond the snippet, including later paper-list pages. Retains edition, path and page citations; never widens source access.', { assetId: id, courseCode: z.string().min(1).max(40), offset: z.number().int().min(0).max(100000).optional() }, args => api('/api/retrieve/source', { query: args }))
10
22
  tool('canvas_course_materials', 'List indexed originals and source editions for one course. Select an exact academic year for a retake; use read_course_source for extracted text.', { courseCode: z.string().min(1).max(40), academicYear: z.string().max(20).optional() }, args => api('/api/corpus/materials', { query: args }))
23
+ tool('canvas_groups', 'Read your Canvas group memberships across courses and global groups. Filter by courseCode and academicYear to keep retake teams separate. Pass a groupId from these memberships to read teammates; include its canvasUrl if several Canvas hosts are connected. A failed roster is unknown, not an empty team. Read-only: never joins or changes groups.', {canvasUrl,courseCode:code,academicYear:z.string().max(20).optional(),scope:z.enum(['all','course','global']).optional(),groupId:z.string().regex(/^\d{1,20}$/).optional(),refresh:z.boolean().optional()}, ({refresh,...args}) => api('/api/integrations/canvas/groups',{query:{...args,refresh:refresh?'1':undefined}}))
11
24
  tool('canvas_assignment_detail', 'Read the full assignment brief, due/unlock/lock dates, own submission status, grade, rubric and submission comments. Use Canvas numeric course and assignment IDs from canvas_updates. Direct the student to /app/updates?tab=assignments&assignment=COURSE_ID%3AASSIGNMENT_ID; Canvas remains a secondary link.', { canvasUrl, courseId: z.string().regex(/^\d+$/), assignmentId: z.string().regex(/^\d+$/), refresh: z.boolean().optional() }, ({ refresh, ...args }) => api('/api/integrations/canvas/assignment', { query: { ...args, canvasUrl: args.canvasUrl || defaultCanvasUrl, refresh: refresh ? '1' : undefined } }))
12
25
  tool('canvas_sync_logs', 'Inspect durable progress for one sync job, optionally filtered by stage or severity. before is the next cursor returned by an earlier page. Last progress and worker checkpoints are distinct; do not claim a task is advancing from its heartbeat alone.', { job: id, before: z.string().max(160).optional(), stage: z.string().max(40).optional(), level: z.enum(['info','warning','error']).optional() }, args => api('/api/account/integrations/canvas/corpus/logs', { query: args }))
13
26
  tool('canvas_sync_control', 'Stop or retry one owned course-edition sync, after the student requests that action. Retry resumes saved originals and unfinished stages; stop fences the running worker and pauses future material refreshes for that edition.', { jobId: id, action: z.enum(['stop','retry']) }, ({ jobId, action }) => api(`/api/integrations/canvas/corpus/jobs/${encodeURIComponent(jobId)}`, { method: 'POST', body: { action } }))
@@ -589,9 +589,19 @@ export async function importCanvasCourse({ courseUrl, accessToken, outputFolder,
589
589
  try {
590
590
  const assignment = initial && Object.hasOwn(initial, 'description') ? initial : await api.getJson(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/assignments/${encodeURIComponent(id)}`)
591
591
  const title = assignment.name || source.title || 'Canvas assignment'
592
+ source = { ...source, assignmentId: id, assignmentTitle: title }
592
593
  const outputPath = pagePath(join(base, 'assignments'), position, title, `assignment-${id}`)
593
594
  await write(outputPath, htmlRecord({ title, url: assignment.html_url, body: assignment.description, details: [['Due', assignment.due_at], ['Unlocks', assignment.unlock_at], ['Available until', assignment.lock_at], ['Points possible', assignment.points_possible], ['Submission types', (assignment.submission_types || []).join(', ')], ['Grading type', assignment.grading_type]] }))
594
595
  const links = uniqueLinks(assignment.description, assignment.html_url || `${canvas.origin}/courses/${canvas.courseId}/assignments/${id}`, canvas.origin, canvas.courseId)
596
+ // Some courses expose attachments as structured file objects, in addition
597
+ // to links in the assignment HTML. Resolve through the course file API.
598
+ for (const attachment of Array.isArray(assignment.attachments) ? assignment.attachments : []) {
599
+ const fileId = String(attachment.id || '')
600
+ if (/^\d+$/.test(fileId)) {
601
+ linkedFilePosition++
602
+ await importFile(fileId, join(root, 'linked-files'), linkedFilePosition, { ...source, itemType: 'Assignment attachment', title: attachment.display_name || attachment.filename || title })
603
+ }
604
+ }
595
605
  records.push({ kind: 'assignment', id, source, path: outputPath.slice(root.length + 1), canvasUrl: assignment.html_url || null, links })
596
606
  await indexAndFollowLinks({ title, pageUrl: assignment.html_url || `${canvas.origin}/courses/${canvas.courseId}/assignments/${id}`, body: assignment.description, outputPath, source, id: `assignment-${id}`, links })
597
607
  return outputPath
@@ -665,7 +675,7 @@ export async function importCanvasCourse({ courseUrl, accessToken, outputFolder,
665
675
  async function importTextItem(item, moduleBase, module) {
666
676
  const kind = String(item.type || '').toLowerCase()
667
677
  const itemId = String(item.id || item.content_id || `${module?.id || 'course'}-${item.position || 0}`)
668
- const source = { moduleId: module?.id || null, moduleName: module?.name || null, itemId, itemType: item.type || 'Unknown', title: text(item.title, 300) }
678
+ const source = { moduleId: module?.id || null, moduleName: module?.name || null, modulePosition: module?.position ?? null, itemPosition: item.position ?? null, itemId, itemType: item.type || 'Unknown', title: text(item.title, 300) }
669
679
  if (kind === 'file') return importFile(item.content_id || item.content_details?.content_id, moduleBase, item.position, source)
670
680
  if (kind === 'subheader') return null
671
681
  try {
@@ -704,7 +714,7 @@ export async function importCanvasCourse({ courseUrl, accessToken, outputFolder,
704
714
 
705
715
  for (const module of selectedModules.sort((left, right) => number(left.position) - number(right.position))) {
706
716
  const moduleBase = join(root, 'modules', `${prefix(module.position)} ${safeSegment(module.name)}--module-${safeSegment(module.id)}`)
707
- if (durable) module.items = await api.getPaged(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/modules/${encodeURIComponent(module.id)}/items?per_page=100`)
717
+ if (durable || !Array.isArray(module.items) || Number(module.items_count) > module.items.length) module.items = await api.getPaged(`/api/v1/courses/${encodeURIComponent(canvas.courseId)}/modules/${encodeURIComponent(module.id)}/items?per_page=100`)
708
718
  const hierarchy = []
709
719
  for (const item of (Array.isArray(module.items) ? module.items : []).sort((left, right) => number(left.position) - number(right.position))) {
710
720
  const indent = Math.min(12, Math.max(0, number(item.indent)))
@@ -1,6 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
2
  export const toolRequestContext = new AsyncLocalStorage()
3
- const writes = new Set('wicker_sign_out join_programme canvas_corpus_sync submit_answer set_mastery review_card add_to_deck create_flashcard review_flashcard resolve_mistake record_chapter_read save_academic_plan update_planning_objective set_course_visibility apply_changes save_calendar_link sync_calendar_link remove_calendar_link canvas_import_remote_course canvas_import_remote_course_set canvas_sync_control canvas_sync_course tutor_ask tutor_approve_action tutor_delete_conversation tutor_add_source tutor_remove_source tutor_forget_context tutor_confirm_update answer_study_diagnostic'.split(' '))
3
+ const writes = new Set('feedback_withdraw_contact feedback_submit feedback_reply feedback_withdraw_evidence feedback_react wicker_sign_out join_programme canvas_corpus_sync submit_answer set_mastery review_card add_to_deck create_flashcard review_flashcard resolve_mistake record_chapter_read save_academic_plan update_planning_objective set_course_visibility apply_changes save_calendar_link sync_calendar_link remove_calendar_link canvas_import_remote_course canvas_import_remote_course_set canvas_sync_control canvas_sync_course tutor_ask tutor_approve_action tutor_delete_conversation tutor_add_source tutor_remove_source tutor_forget_context tutor_confirm_update answer_study_diagnostic'.split(' '))
4
4
  export function requiresWriteConfirmation(name) {
5
5
  return writes.has(name) || name.startsWith('admin_') && !/^(admin_status|admin_inventory_|admin_list_|admin_estimate_)/.test(name)
6
6
  }